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
- Component Patterns - Patterns to test
- Debugging - Debug failing tests
- Observability - Monitor in production