Skip to content

plugin testing

Thomas Mangin edited this page Apr 8, 2026 · 1 revision

Pre-Alpha. This page describes behavior that may change.

The Ze plugin SDK is designed to be testable. The net.Pipe() pair is the usual trick: one end becomes the plugin, the other end is the test driving the engine side. You simulate the five-stage startup, deliver events, execute commands, and assert against what the plugin did. No real daemon needed.

This page covers the patterns that work. The full in-tree testing guide is at main/docs/plugin-development/testing.md.

The test pair helper

Every test starts with a net.Pipe() and an sdk.NewWithConn() call. A small helper factors out the boilerplate.

import (
    "net"
    "testing"

    "codeberg.org/thomas-mangin/ze/pkg/plugin/rpc"
    "codeberg.org/thomas-mangin/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
}

engineMux is the engine-side driver. You use it to send callbacks into the plugin (CallRPC) and to read the plugin's outgoing requests (Requests()).

Testing an event handler

The pattern is four steps: register the callback, run the plugin in a goroutine, drive the startup from the engine side, and deliver an event.

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{})
    }()

    completeStartup(t, ctx, engineMux)

    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 cleanly.
    byeInput := struct {
        Reason string `json:"reason"`
    }{Reason: "test-done"}
    _, _ = engineMux.CallRPC(ctx, "ze-plugin-callback:bye", byeInput)

    require.NoError(t, <-errCh)
}

completeStartup drives stages 1 through 5. The helper lives in pkg/plugin/sdk/sdk_test.go and is the one you borrow when writing new tests.

What to test

The handful of things that pay off most.

The happy path for every command. OnExecuteCommand with each registered command name, with valid arguments, asserting on the (status, data) return.

The error paths for every command. Missing arguments, invalid values, unknown commands. Assert on the "error" status string, not on a Go error.

OnConfigure with your YANG. Load your YANG file, feed a matching JSON payload through CallRPC("ze-plugin-callback:configure", ...), assert that your plugin's internal state reflects the config.

Event subscription. If your plugin subscribes to events at startup (SetStartupSubscriptions), assert that the subscription made it into the stage-5 ready payload that the plugin sent.

OnConfigVerify rejects bad config. The engine's reload path depends on this. A verify handler that never rejects anything does not actually protect you.

OnBye. The plugin should release its external resources on shutdown. Deliver a bye and assert that the hook ran.

What not to test in the SDK harness

Things that belong in a different test layer:

  • The wire format of the plugin protocol. That is pkg/plugin/rpc's job.
  • The 5-stage handshake itself. The SDK test suite already covers it.
  • Integration with a real daemon. Use the functional test suite (ze-test) for that. It spins up a real Ze and drives it through real commands.

Real-daemon tests

When you want to test your plugin against the real engine, ze-test is the driver. It has a wait-established synchroniser that lets you write a test that does not race the FSM.

ze-test mcp --wait-established ...
ze-test functional my-plugin ...

The functional tests live under main/test/. They are slower than the SDK unit tests, and they test real behaviour against a compiled binary, which is exactly what you want before shipping a plugin.

Fuzz testing

If your plugin parses external input (NLRI bytes, capability payloads, JSON from another process), write a fuzz harness. Ze has project-wide fuzz testing and the plugin's parser is no exception. The pattern is the same as any Go fuzz target.

func FuzzDecodeNLRI(f *testing.F) {
    f.Add([]byte{0x00, 0x18, 0x0a, 0x00, 0x00})
    f.Fuzz(func(t *testing.T, data []byte) {
        _, _ = decodeNLRI(data)  // Must not panic.
    })
}

make ze-fuzz-test runs every fuzz target in the tree.

See also

Adapted from main/docs/plugin-development/testing.md.

Home

About

First Steps

Configuration

Operation

Interfaces

Plugins

Plugin Development

Chaos Testing

Blueprints

Development

Reference

Clone this wiki locally