Skip to content

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:

go
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 := func(ctx context.Context, port string, msg any) error {
        outputs = append(outputs, struct {
            port string
            msg  any
        }{port, msg})
        return nil
    }

    // Test input handling
    err := component.Handle(context.Background(), handler, "input", Input{
        Text: "hello world",
    })

    assert.NoError(t, 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

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

    // Apply settings
    settings := Settings{
        Timeout: 5000,
        Retries: 3,
    }

    err := component.Handle(context.Background(), nil, v1alpha1.SettingsPort, settings)
    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

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

func (m *mockHandler) Handle(ctx context.Context, port string, msg any) error {
    m.calls = append(m.calls, struct {
        port string
        msg  any
    }{port, msg})
    return m.returnErr
}

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

    // Set up settings first
    component.Handle(context.Background(), mock.Handle, v1alpha1.SettingsPort, Settings{
        Routes: []string{"route_a", "route_b"},
    })

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

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

Testing Control Ports

Testing Button Clicks

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

    // Apply settings
    ticker.Handle(context.Background(), nil, v1alpha1.SettingsPort, Settings{
        Delay: 100,
    })

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

    // Start ticker
    var started bool
    handler := func(ctx context.Context, port string, msg any) error {
        if port == "output" {
            started = true
        }
        return nil
    }

    go func() {
        ticker.Handle(ctx, handler, v1alpha1.ControlPort, Control{Start: true})
    }()

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

    // Stop ticker
    ticker.Handle(ctx, handler, v1alpha1.ControlPort, Control{Stop: true})
}

Testing Leader-Only Behavior

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

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

        var processed bool
        handler := func(ctx context.Context, port string, msg any) error {
            processed = true
            return nil
        }

        err := component.Handle(ctx, handler, v1alpha1.ControlPort, Control{Action: true})
        assert.NoError(t, err)
        assert.True(t, processed)
    })

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

        var processed bool
        handler := func(ctx context.Context, port string, msg any) error {
            processed = true
            return nil
        }

        err := component.Handle(ctx, handler, v1alpha1.ControlPort, Control{Action: true})
        assert.NoError(t, err)
        assert.False(t, processed)
    })
}

Testing Error Handling

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

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

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

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

        assert.NoError(t, err) // Error handled via port
        assert.NotNil(t, errorOutput)
        assert.Contains(t, errorOutput.Error, "invalid")
    })
}

Testing Context Cancellation

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

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

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

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

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

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

Testing Reconciliation

go
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",
            },
        },
    }

    handler := func(ctx context.Context, port string, msg any) error {
        return nil
    }

    err := component.Handle(context.Background(), handler, v1alpha1.ReconcilePort, node)
    assert.NoError(t, err)

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

Integration Testing

Testing with Real Scheduler

go
func TestComponent_Integration(t *testing.T) {
    // Create a test scheduler
    scheduler := runner.NewScheduler()

    // Register test component
    component := &MyComponent{}
    scheduler.Register("test-node", component)

    // Apply settings
    scheduler.Handle(context.Background(), runner.Msg{
        To:   "test-node._settings",
        Data: Settings{Enabled: true},
    })

    // Send test message
    result, err := scheduler.Handle(context.Background(), runner.Msg{
        To:   "test-node.input",
        Data: Input{Value: "test"},
    })

    assert.NoError(t, err)
    assert.NotNil(t, result)
}

Testing Edge Evaluation

go
func TestEdge_DataTransformation(t *testing.T) {
    evaluator := expression.NewEvaluator()

    template := map[string]any{
        "name":  "{{$.firstName + ' ' + $.lastName}}",
        "upper": "{{$.name.toUpperCase()}}",
    }

    source := map[string]any{
        "firstName": "John",
        "lastName":  "Doe",
        "name":      "john",
    }

    result, err := evaluator.Evaluate(template, source)
    assert.NoError(t, err)

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

Table-Driven Tests

go
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 := func(ctx context.Context, port string, msg any) error {
                result = msg.(Output)
                return nil
            }

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

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

Benchmarking

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

    // Setup
    component.Handle(context.Background(), nil, v1alpha1.SettingsPort, Settings{})

    handler := func(ctx context.Context, port string, msg any) error {
        return 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.Handle(context.Background(), nil, v1alpha1.SettingsPort, Settings{})

    handler := func(ctx context.Context, port string, msg any) error {
        return 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

go
// 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) error {
    h.Calls = append(h.Calls, TestCall{Port: port, Msg: msg})
    return h.Err
}

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

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

Usage

go
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

go
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

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

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

    // Start
    component.Handle(ctx, handler, v1alpha1.ControlPort, Control{Start: true})
    assert.Equal(t, StateRunning, component.state)

    // Stop
    component.Handle(ctx, handler, v1alpha1.ControlPort, Control{Stop: true})
    assert.Equal(t, StateStopped, component.state)
}

3. Test Concurrent Access

go
func TestComponent_Concurrent(t *testing.T) {
    component := &ThreadSafeComponent{}
    handler := func(ctx context.Context, port string, msg any) error { return 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

Build flow-based applications on Kubernetes