Testing Components

Testing TinySystems components ensures reliability and correctness. This guide covers unit testing, integration testing, and testing best practices.

Unit Testing

Basic Component Test

Test the Handle() method directly:

package mycomponent_test

import (
    "context"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/tiny-systems/module/api/v1alpha1"
    "github.com/tiny-systems/module/module"
)

func TestUppercaser_Handle(t *testing.T) {
    component := &Uppercaser{}

    // Track outputs
    var outputs []struct {
        port string
        msg  any
    }

    handler := module.Handler(func(ctx context.Context, port string, msg any) module.Result {
        outputs = append(outputs, struct {
            port string
            msg  any
        }{port, msg})
        return module.Ok(nil)
    })

    // Test input handling — Handle returns module.Result
    res := component.Handle(context.Background(), handler, "input", Input{
        Text: "hello world",
    })

    assert.NoError(t, res.Err())
    assert.Len(t, outputs, 1)
    assert.Equal(t, "output", outputs[0].port)

    result := outputs[0].msg.(Output)
    assert.Equal(t, "HELLO WORLD", result.Text)
}

Testing Settings

Settings never reach Handle — the framework dispatches them through the module.SettingsHandler capability interface. Call OnSettings directly:

func TestComponent_Settings(t *testing.T) {
    component := &MyComponent{}

    // Apply settings via the capability interface
    err := component.OnSettings(context.Background(), Settings{
        Timeout: 5000,
        Retries: 3,
    })
    assert.NoError(t, err)

    // Verify settings applied
    assert.Equal(t, 5000, component.settings.Timeout)
    assert.Equal(t, 3, component.settings.Retries)
}

Testing with Mock Handler

type mockHandler struct {
    calls []struct {
        port string
        msg  any
    }
    returnErr error
}

func (m *mockHandler) Handle(ctx context.Context, port string, msg any) module.Result {
    m.calls = append(m.calls, struct {
        port string
        msg  any
    }{port, msg})
    return module.Fail(m.returnErr) // nil error yields a successful zero Result
}

func TestComponent_MultipleOutputs(t *testing.T) {
    component := &Router{}
    mock := &mockHandler{}

    // Set up settings first (capability interface, not Handle)
    _ = component.OnSettings(context.Background(), Settings{
        Routes: []string{"route_a", "route_b"},
    })

    // Test routing
    res := component.Handle(context.Background(), mock.Handle, "input", Message{
        Type: "a",
        Data: "test",
    })

    assert.NoError(t, res.Err())
    assert.Len(t, mock.calls, 1)
    assert.Equal(t, "route_a", mock.calls[0].port)
}

Testing Control Ports

Testing Button Clicks

Control messages are dispatched to module.ControlHandler.OnControl, never to Handle. Components that emit from background loops receive their long-lived emitter via module.EmitterAware.OnEmitter:

func TestTicker_Control(t *testing.T) {
    ticker := &Ticker{}

    // Apply settings via the capability interface
    _ = ticker.OnSettings(context.Background(), Settings{
        Delay: 100,
    })

    // Inject the emitter the ticker will use from its goroutine
    var started bool
    ticker.OnEmitter(func(ctx context.Context, port string, msg any) module.Result {
        if port == "output" {
            started = true
        }
        return module.Ok(nil)
    })

    // Simulate leader context
    ctx := utils.WithLeader(context.Background(), true)

    // Start ticker
    err := ticker.OnControl(ctx, Control{Start: true})
    assert.NoError(t, err)

    // Wait for at least one tick
    time.Sleep(150 * time.Millisecond)
    assert.True(t, started)

    // Stop ticker
    _ = ticker.OnControl(ctx, Control{Stop: true})
}

Testing Leader-Only Behavior

func TestComponent_LeaderOnly(t *testing.T) {
    component := &LeaderComponent{}

    t.Run("leader processes control", func(t *testing.T) {
        ctx := utils.WithLeader(context.Background(), true)

        err := component.OnControl(ctx, Control{Action: true})
        assert.NoError(t, err)
        assert.True(t, component.processed)
    })

    t.Run("non-leader ignores control", func(t *testing.T) {
        component := &LeaderComponent{}
        ctx := utils.WithLeader(context.Background(), false)

        err := component.OnControl(ctx, Control{Action: true})
        assert.NoError(t, err)
        assert.False(t, component.processed)
    })
}

Testing Error Handling

Error ports should emit the canonical module.ErrorMessage (built with module.NewError), and transient failures should be marked with module.Retryable:

func TestComponent_ErrorHandling(t *testing.T) {
    component := &Processor{}

    t.Run("handles invalid input", func(t *testing.T) {
        res := component.Handle(context.Background(), nil, "input", "invalid")
        assert.Error(t, res.Err())
    })

    t.Run("routes to error port", func(t *testing.T) {
        var errorOutput module.ErrorMessage
        handler := module.Handler(func(ctx context.Context, port string, msg any) module.Result {
            if port == "error" {
                errorOutput = msg.(module.ErrorMessage)
            }
            return module.Ok(nil)
        })

        res := component.Handle(context.Background(), handler, "input", Input{
            Data: "invalid-data",
        })

        assert.NoError(t, res.Err()) // Error handled via port
        assert.Contains(t, errorOutput.Error, "invalid")
        assert.False(t, errorOutput.Retryable) // validation errors are permanent
    })
}

Testing Context Cancellation

func TestComponent_Cancellation(t *testing.T) {
    component := &LongProcessor{}

    ctx, cancel := context.WithCancel(context.Background())

    var completed bool
    handler := module.Handler(func(ctx context.Context, port string, msg any) module.Result {
        completed = true
        return module.Ok(nil)
    })

    go func() {
        time.Sleep(50 * time.Millisecond)
        cancel()
    }()

    res := component.Handle(ctx, handler, "input", Input{
        Items: make([]string, 1000), // Large input
    })

    assert.ErrorIs(t, res.Err(), context.Canceled)
    assert.False(t, completed)
}

Testing Reconciliation

Reconcile is a capability interface too — module.ReconcileHandler.OnReconcile(ctx, node):

func TestComponent_Reconcile(t *testing.T) {
    component := &Server{}

    node := v1alpha1.TinyNode{
        ObjectMeta: metav1.ObjectMeta{
            Name: "server-abc123",
        },
        Status: v1alpha1.TinyNodeStatus{
            Metadata: map[string]string{
                "http-server-port": "8080",
            },
        },
    }

    err := component.OnReconcile(context.Background(), node)
    assert.NoError(t, err)

    // Verify component state
    assert.Equal(t, "server-abc123", component.nodeName)
    assert.Equal(t, 8080, component.currentPort)
}

Integration Testing

Simulating the Framework Lifecycle

The SDK's scheduler lives in an internal/ package, so module tests cannot drive it directly. Instead, replicate the framework's dispatch order in a helper — the same order the scheduler enforces on a fresh runner:

// Lifecycle order: OnIdentity → OnClient → OnNATS → OnState →
// OnReconcile → OnSettings, then business messages via Handle.
func startComponent(t *testing.T, c module.Component, node v1alpha1.TinyNode, settings any) {
    t.Helper()
    ctx := context.Background()

    if h, ok := c.(module.IdentityAware); ok {
        h.OnIdentity(v1alpha1.NodeIdentity{NodeName: node.Name, Namespace: node.Namespace})
    }
    if h, ok := c.(module.ReconcileHandler); ok {
        assert.NoError(t, h.OnReconcile(ctx, node))
    }
    if h, ok := c.(module.SettingsHandler); ok {
        assert.NoError(t, h.OnSettings(ctx, settings))
    }
}

func TestComponent_Integration(t *testing.T) {
    component := &MyComponent{}
    startComponent(t, component, v1alpha1.TinyNode{}, Settings{Enabled: true})

    handler := module.Handler(func(ctx context.Context, port string, msg any) module.Result {
        return module.Ok(nil)
    })

    res := component.Handle(context.Background(), handler, "input", Input{Value: "test"})
    assert.NoError(t, res.Err())
}

Testing Edge Evaluation

Edge configurations are evaluated with the SDK's expression evaluator (pkg/evaluator, backed by ajson). It takes raw JSON and a callback that resolves each {{expression}}:

import "github.com/tiny-systems/module/pkg/evaluator"

func TestEdge_DataTransformation(t *testing.T) {
    // Resolver: evaluates the expression against the upstream data
    eval := evaluator.NewEvaluator(func(expression string) (any, error) {
        data := []byte(`{"firstName":"John","lastName":"Doe","name":"john"}`)
        root, err := ajson.Unmarshal(data)
        if err != nil {
            return nil, err
        }
        nodes, err := ajson.Eval(root, expression)
        if err != nil {
            return nil, err
        }
        return nodes.Unpack()
    })

    result, err := eval.Eval([]byte(`{
        "name":  "{{$.firstName + \" \" + $.lastName}}",
        "upper": "{{upper($.name)}}"
    }`))
    assert.NoError(t, err)

    resultMap := result.(map[string]any)
    assert.Equal(t, "John Doe", resultMap["name"])
    assert.Equal(t, "JOHN", resultMap["upper"])
}

Note there are no JavaScript methods (.toUpperCase(), .length) in expressions — use the built-in functions (upper(), length(), …).

Table-Driven Tests

func TestTransformer_Handle(t *testing.T) {
    tests := []struct {
        name     string
        input    Input
        expected Output
        hasError bool
    }{
        {
            name:     "simple text",
            input:    Input{Text: "hello"},
            expected: Output{Text: "HELLO"},
        },
        {
            name:     "empty text",
            input:    Input{Text: ""},
            expected: Output{Text: ""},
        },
        {
            name:     "special characters",
            input:    Input{Text: "hello-world_123"},
            expected: Output{Text: "HELLO-WORLD_123"},
        },
        {
            name:     "unicode",
            input:    Input{Text: "héllo"},
            expected: Output{Text: "HÉLLO"},
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            component := &Uppercaser{}

            var result Output
            handler := module.Handler(func(ctx context.Context, port string, msg any) module.Result {
                result = msg.(Output)
                return module.Ok(nil)
            })

            res := component.Handle(context.Background(), handler, "input", tt.input)

            if tt.hasError {
                assert.Error(t, res.Err())
            } else {
                assert.NoError(t, res.Err())
                assert.Equal(t, tt.expected, result)
            }
        })
    }
}

Benchmarking

func BenchmarkComponent_Handle(b *testing.B) {
    component := &Processor{}

    // Setup
    _ = component.OnSettings(context.Background(), Settings{})

    handler := module.Handler(func(ctx context.Context, port string, msg any) module.Result {
        return module.Ok(nil)
    })

    input := Input{Data: "benchmark data"}

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        component.Handle(context.Background(), handler, "input", input)
    }
}

func BenchmarkComponent_Parallel(b *testing.B) {
    component := &Processor{}
    _ = component.OnSettings(context.Background(), Settings{})

    handler := module.Handler(func(ctx context.Context, port string, msg any) module.Result {
        return module.Ok(nil)
    })

    b.RunParallel(func(pb *testing.PB) {
        input := Input{Data: "parallel data"}
        for pb.Next() {
            component.Handle(context.Background(), handler, "input", input)
        }
    })
}

Test Utilities

Helper Functions

// testutil/helpers.go

func NewTestHandler() (*TestHandler, func() []TestCall) {
    h := &TestHandler{}
    return h, func() []TestCall { return h.Calls }
}

type TestCall struct {
    Port string
    Msg  any
}

type TestHandler struct {
    Calls []TestCall
    Err   error
}

func (h *TestHandler) Handle(ctx context.Context, port string, msg any) module.Result {
    h.Calls = append(h.Calls, TestCall{Port: port, Msg: msg})
    return module.Fail(h.Err) // nil error yields a successful zero Result
}

func LeaderContext() context.Context {
    return utils.WithLeader(context.Background(), true)
}

func ReaderContext() context.Context {
    return utils.WithLeader(context.Background(), false)
}

Usage

func TestWithHelpers(t *testing.T) {
    component := &MyComponent{}
    handler, getCalls := testutil.NewTestHandler()

    ctx := testutil.LeaderContext()
    component.Handle(ctx, handler.Handle, "input", Input{})

    calls := getCalls()
    assert.Len(t, calls, 1)
}

Best Practices

1. Test All Ports

func TestComponent_AllPorts(t *testing.T) {
    t.Run("settings", testSettings)
    t.Run("control", testControl)
    t.Run("reconcile", testReconcile)
    t.Run("input", testInput)
    t.Run("unknown port", testUnknownPort)
}

2. Test State Transitions

func TestComponent_StateTransitions(t *testing.T) {
    component := &StatefulComponent{}

    // Initial state
    assert.Equal(t, StateIdle, component.state)

    // Start (control messages dispatch via OnControl)
    _ = component.OnControl(ctx, Control{Start: true})
    assert.Equal(t, StateRunning, component.state)

    // Stop
    _ = component.OnControl(ctx, Control{Stop: true})
    assert.Equal(t, StateStopped, component.state)
}

3. Test Concurrent Access

func TestComponent_Concurrent(t *testing.T) {
    component := &ThreadSafeComponent{}
    handler := module.Handler(func(ctx context.Context, port string, msg any) module.Result {
        return module.Ok(nil)
    })

    var wg sync.WaitGroup
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            component.Handle(context.Background(), handler, "input", Input{ID: i})
        }(i)
    }
    wg.Wait()
}

Next Steps