Documentation

Testing Your Plugin

This guide covers testing strategies for ze plugins using the SDK (pkg/plugin/sdk).

Unit Testing with net.Pipe

The SDK is designed for testability. Use net.Pipe() to create a connected pair of connections, then use sdk.NewWithConn() on one end and simulate the engine on the other.

import (
    "net"
    "testing"

    "github.com/ze-software/ze/pkg/plugin/rpc"
    "github.com/ze-software/ze/pkg/plugin/sdk"
)

func newTestPair(t *testing.T) (*sdk.Plugin, *rpc.MuxConn) {
    t.Helper()

    pluginEnd, engineEnd := net.Pipe()
    t.Cleanup(func() {
        pluginEnd.Close()
        engineEnd.Close()
    })

    p := sdk.NewWithConn("test-plugin", pluginEnd)

    engineConn := rpc.NewConn(engineEnd, engineEnd)
    engineMux := rpc.NewMuxConn(engineConn)
    t.Cleanup(func() { engineMux.Close() })

    return p, engineMux
}

The engine side uses rpc.MuxConn for bidirectional RPCs: it reads plugin requests via Requests() and sends engine callbacks via CallRPC.

Testing Event Handlers

Register callbacks with OnEvent, OnConfigure, or OnExecuteCommand, then run the plugin in a goroutine and simulate engine messages from the other end of the pipe.

func TestEventHandler(t *testing.T) {
    p, engineMux := newTestPair(t)

    eventReceived := make(chan string, 1)
    p.OnEvent(func(event string) error {
        eventReceived <- event
        return nil
    })

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    errCh := make(chan error, 1)
    go func() {
        errCh <- p.Run(ctx, sdk.Registration{})
    }()

    // Complete the 5-stage startup from the engine side
    completeStartup(t, ctx, engineMux)

    // Deliver an event
    eventInput := struct {
        Event string `json:"event"`
    }{Event: `{"type":"bgp","bgp":{"peer":{"address":"10.0.0.1"}}}`}

    _, err := engineMux.CallRPC(ctx, "ze-plugin-callback:deliver-event", eventInput)
    require.NoError(t, err)

    select {
    case got := <-eventReceived:
        assert.Contains(t, got, "10.0.0.1")
    case <-time.After(time.Second):
        t.Fatal("event callback not called")
    }

    // Shutdown
    byeInput := struct {
        Reason string `json:"reason"`
    }{Reason: "test-done"}
    _, _ = engineMux.CallRPC(ctx, "ze-plugin-callback:bye", byeInput)

    require.NoError(t, <-errCh)
}

Simulating the 5-Stage Startup

The Run() method performs the 5-stage startup protocol before entering the event loop. Tests must simulate all five stages from the engine side.

func completeStartup(t *testing.T, ctx context.Context, engineMux *rpc.MuxConn) {
    t.Helper()

    // Stage 1: read declare-registration, respond OK
    req := <-engineMux.Requests()
    assert.Equal(t, "ze-plugin-engine:declare-registration", req.Method)
    require.NoError(t, engineMux.SendOK(ctx, req.ID))

    // Stage 2: send configure
    configInput := struct {
        Sections []sdk.ConfigSection `json:"sections"`
    }{}
    _, err := engineMux.CallRPC(ctx, "ze-plugin-callback:configure", configInput)
    require.NoError(t, err)

    // Stage 3: read declare-capabilities, respond OK
    req = <-engineMux.Requests()
    assert.Equal(t, "ze-plugin-engine:declare-capabilities", req.Method)
    require.NoError(t, engineMux.SendOK(ctx, req.ID))

    // Stage 4: send share-registry
    registryInput := struct {
        Commands []sdk.RegistryCommand `json:"commands"`
    }{}
    _, err = engineMux.CallRPC(ctx, "ze-plugin-callback:share-registry", registryInput)
    require.NoError(t, err)

    // Stage 5: read ready, respond OK
    req = <-engineMux.Requests()
    assert.Equal(t, "ze-plugin-engine:ready", req.Method)
    require.NoError(t, engineMux.SendOK(ctx, req.ID))
}

The five stages are:

Stage Direction RPC Method
1 Plugin to Engine ze-plugin-engine:declare-registration
2 Engine to Plugin ze-plugin-callback:configure
3 Plugin to Engine ze-plugin-engine:declare-capabilities
4 Engine to Plugin ze-plugin-callback:share-registry
5 Plugin to Engine ze-plugin-engine:ready

Testing Command Execution

Plugins register command handlers with OnExecuteCommand. The handler receives a serial, command name, args, and peer address, and returns status, data, and error.

func TestCommandHandler(t *testing.T) {
    p, engineMux := newTestPair(t)

    p.OnExecuteCommand(func(serial, command string, args []string, peer string) (string, any, error) {
        if command == "show-status" {
            return "done", `{"status":"healthy"}`, nil
        }
        return "error", "unknown command", nil
    })

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    errCh := make(chan error, 1)
    go func() {
        errCh <- p.Run(ctx, sdk.Registration{
            Commands: []sdk.CommandDecl{
                {Name: "show-status", Description: "Show health status"},
            },
        })
    }()

    completeStartup(t, ctx, engineMux)

    // Send execute-command
    cmdInput := struct {
        Serial  string   `json:"serial"`
        Command string   `json:"command"`
        Args    []string `json:"args,omitempty"`
        Peer    string   `json:"peer,omitempty"`
    }{Serial: "1", Command: "show-status"}

    out := executeCommand(t, ctx, engineMux, cmdInput)
    assert.Equal(t, "done", out.Status)
    assert.Contains(t, string(out.Data), "healthy")
}

CallRPC cannot read this answer. A plugin answers execute-command with a head, its records and a terminator, so a test that reads one JSON result takes the head line's tail for its payload. Read the answer with CallAnswer and collapse it, which is the engine's own three steps:

func executeCommand(t *testing.T, ctx context.Context, mux *rpc.MuxConn, input any) *rpc.ExecuteCommandOutput {
    t.Helper()

    answer, err := mux.CallAnswer(ctx, "ze-plugin-callback:execute-command", input)
    require.NoError(t, err)

    document, collapseErr := rpc.CollapseAnswer(answer)
    require.NoError(t, answer.Err(), "the answer must reach its terminator")
    require.NoError(t, collapseErr)
    return &rpc.ExecuteCommandOutput{Status: answer.Status, Data: document}
}

answer.Err() is read AFTER the collapse, because the walk is what fills it.

Testing Configuration Handling

The OnConfigure callback receives config sections during Stage 2. The OnConfigVerify and OnConfigApply callbacks handle config reload.

func TestConfigHandler(t *testing.T) {
    p, engineMux := newTestPair(t)

    var receivedConfig []sdk.ConfigSection
    p.OnConfigure(func(sections []sdk.ConfigSection) error {
        receivedConfig = sections
        return nil
    })

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    errCh := make(chan error, 1)
    go func() {
        errCh <- p.Run(ctx, sdk.Registration{
            WantsConfig: []string{"bgp"},
        })
    }()

    // Stage 1
    req := <-engineMux.Requests()
    require.NoError(t, engineMux.SendOK(ctx, req.ID))

    // Stage 2: send config
    configInput := struct {
        Sections []sdk.ConfigSection `json:"sections"`
    }{
        Sections: []sdk.ConfigSection{
            {Root: "bgp", Data: `{"router-id":"1.2.3.4"}`},
        },
    }
    _, err := engineMux.CallRPC(ctx, "ze-plugin-callback:configure", configInput)
    require.NoError(t, err)

    assert.Equal(t, 1, len(receivedConfig))
    assert.Equal(t, "bgp", receivedConfig[0].Root)
}

Table-Driven Tests

Test multiple scenarios efficiently using Go table-driven test patterns:

func TestCommandDispatch(t *testing.T) {
    tests := []struct {
        name       string
        command    string
        wantStatus string
        wantData   string
    }{
        {"known command", "show-routes", "done", `{"count":42}`},
        {"unknown command", "invalid", "error", "unknown command"},
        {"empty args", "show-routes", "done", `{"count":42}`},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            p, engineMux := newTestPair(t)

            p.OnExecuteCommand(func(serial, cmd string, args []string, peer string) (string, any, error) {
                if cmd == "show-routes" {
                    return "done", `{"count":42}`, nil
                }
                return "error", "unknown command", nil
            })

            ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
            defer cancel()

            go func() { _ = p.Run(ctx, sdk.Registration{}) }()
            completeStartup(t, ctx, engineMux)

            cmdInput := struct {
                Serial  string `json:"serial"`
                Command string `json:"command"`
            }{Serial: "1", Command: tt.command}

            out := executeCommand(t, ctx, engineMux, cmdInput)
            assert.Equal(t, tt.wantStatus, out.Status)
            assert.JSONEq(t, tt.wantData, string(out.Data))
        })
    }
}

CI Integration

# .github/workflows/test.yml (or Forgejo/Woodpecker equivalent)
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Set up Go
        uses: actions/setup-go@v4
        with:
          go-version: '1.21'

      - name: Test
        run: go test -race -v ./...

      - name: Build plugin
        run: go build -o my-plugin

Coverage

Run with coverage:

go test -race -cover -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

Target at least 80% coverage on handlers.

Debugging

Enable debug logging with ze's hierarchical log system:

# Set log level for your plugin
export ZE_LOG_LEVEL=debug
./my-plugin

Or add structured logging in your plugin:

import "log/slog"

p.OnEvent(func(event string) error {
    slog.Debug("event received", "event", event)
    // ...
    return nil
})