Skip to content

Unit Testing with goopstest

goopstest is a unit testing framework for goops charms. It allows you to simulate Juju environments and test your charm logic without needing a live Juju controller.

goopstest allows users to write unit tests in a "state-transition" style. Each test includes the following concepts:

  • Context: Charm function under test, Juju version, and other relevant context information.
  • State: Resources accessible to the charm, including status, leadership, configuration, relations.
  • Event: Hook name that will be run (ex. install, start, stop, etc.).

Charm developers are expected to write tests that follow the Arrange-Act-Assert pattern:

  • Arrange: Declare the context and input state.
  • Act: Execute a hook.
  • Assert: Verify that the output state matches the expected state.

Examples

Example 1: A basic charm

Here's an example of a simple charm that uses goops to check if the unit is a leader and set its status accordingly:

package charm

import (
    "github.com/gruyaume/goops"
)

func Configure() error {
    isLeader, err := goops.IsLeader()
    if err != nil {
        return err
    }

    if !isLeader {
        _ = goops.SetUnitStatus(goops.StatusBlocked, "Unit is not a leader")
        return nil
    }

    _ = goops.SetUnitStatus(goops.StatusActive, "Charm is active")

    return nil
}

And here's the corresponding unit test written using goopstest:

package charm_test

import (
    "testing"

    "github.com/gruyaume/goops/goopstest"
)

func TestCharm(t *testing.T) {
    // Arrange
    ctx := goopstest.NewContext(Configure)

    stateIn := goopstest.State{
        Leader: false,
    }

    // Act
    stateOut := ctx.Run("install", stateIn)

    // Assert
    expectedStatus := goopstest.Status{
        Name:    goopstest.StatusBlocked,
        Message: "Unit is not a leader",
    }
    if stateOut.UnitStatus != expectedStatus {
        t.Errorf("Expected unit status %v, got %v", expectedStatus, stateOut.UnitStatus)
    }
}

Example 2: A Kubernetes charm

Here's a Kubernetes charm example that uses goops to configure a Pebble service and start it:

package charm

import (
    "fmt"
    "strings"

    "github.com/canonical/pebble/client"
    "github.com/gruyaume/goops"
    "gopkg.in/yaml.v3"
)

func Configure() error {
    pebble := goops.Pebble("example")

    _, err := pebble.SysInfo()
    if err != nil {
        return fmt.Errorf("cannot connect to Pebble: %w", err)
    }

    err = pebble.Push(&client.PushOptions{
        Source: strings.NewReader(`# Example configuration file`),
        Path:   "/etc/config.yaml",
    })
    if err != nil {
        return fmt.Errorf("could not push file: %w", err)
    }

    layerData, err := yaml.Marshal(PebbleLayer{
        Summary:     "My service layer",
        Description: "This layer configures my service",
        Services: map[string]ServiceConfig{
            "my-service": {
                Startup:  "enabled",
                Override: "replace",
                Command:  "/bin/my-service --config /etc/my-service/config.yaml",
            },
        },
    })
    if err != nil {
        return fmt.Errorf("could not marshal layer data to YAML: %w", err)
    }

    err = pebble.AddLayer(&client.AddLayerOptions{
        Combine:   true,
        Label:     "example-layer",
        LayerData: layerData,
    })
    if err != nil {
        return fmt.Errorf("could not add Pebble layer: %w", err)
    }

    _, err = pebble.Start(&client.ServiceOptions{
        Names: []string{"my-service"},
    })
    if err != nil {
        return fmt.Errorf("could not start Pebble service: %w", err)
    }

    return nil
}

And here's the corresponding unit test using goopstest:

package charm_test

import (
    "os"
    "reflect"
    "testing"

    "github.com/canonical/pebble/client"
    "github.com/gruyaume/goops/goopstest"
)

func TestCharm(t *testing.T) {
    // Arrange
    ctx := goopstest.NewContext(Configure)

    dname, err := os.MkdirTemp("", "sampledir")
    if err != nil {
        t.Fatalf("Failed to create temporary directory: %v", err)
    }

    defer os.RemoveAll(dname)

    stateIn := goopstest.State{
        Containers: []goopstest.Container{
            {
                Name:       "example",
                CanConnect: true,
                Mounts: map[string]goopstest.Mount{
                    "config": {
                        Location: "/etc/config.yaml",
                        Source:   dname,
                    },
                },
            },
        },
    }

    // Act
    stateOut := ctx.Run("install", stateIn)

    // Assert
    if len(stateOut.Containers) != 1 {
        t.Fatalf("Expected 1 container in stateOut, got %d", len(stateOut.Containers))
    }

    if len(stateOut.Containers[0].Layers) != 1 {
        t.Fatalf("Expected 1 Pebble layer in container, got %d", len(stateOut.Containers[0].Layers))
    }

    expectedLayer := goopstest.Layer{
        Summary:     "My service layer",
        Description: "This layer configures my service",
        Services: map[string]goopstest.Service{
            "my-service": {
                Startup:  "enabled",
                Override: "replace",
                Command:  "/bin/my-service --config /etc/my-service/config.yaml",
            },
        },
        LogTargets: map[string]*goopstest.LogTarget{},
    }

    actualLayer := stateOut.Containers[0].Layers["example-layer"]
    if !reflect.DeepEqual(actualLayer, expectedLayer) {
        t.Fatalf("Expected Pebble layer 'example-layer' to match expected configuration.\nExpected: %+v\nActual: %+v", expectedLayer, actualLayer)
    }

    if stateOut.Containers[0].ServiceStatuses["my-service"] != client.StatusActive {
        t.Errorf("Expected service 'my-service' to be active, got %s", stateOut.Containers[0].ServiceStatuses["my-service"])
    }

    content, err := os.ReadFile(dname + "/etc/config.yaml")
    if err != nil {
        t.Fatalf("Failed to read pushed file: %v", err)
    }

    if string(content) != "# Example configuration file" {
        t.Errorf("Expected file content '# Example configuration file', got '%s'", string(content))
    }
}

Other resources

You can find more information about unit testing with goopstest in the following resources: