testutil

package
v0.4.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 28, 2026 License: EUPL-1.2 Imports: 0 Imported by: 0

Documentation

Overview

Package testutil provides shared test infrastructure for AWF CLI tests.

This package centralizes common mocks, builders, assertions, and fixtures to reduce test boilerplate and ensure consistent test patterns across the codebase. The testutil package is the result of C007 Test Infrastructure Modernization, which consolidated 23 duplicate mock types and reduced test setup from 30+ lines to 2-3 lines.

Architecture

The testutil package follows hexagonal architecture principles:

  • Mocks implement domain port interfaces (WorkflowRepository, StateStore, CommandExecutor, Logger)
  • All mocks are thread-safe with sync.Mutex protection for parallel test execution
  • Builders use fluent API pattern with sensible defaults and With*() chaining methods
  • Assertion helpers provide domain-specific validation with clear error messages
  • Workflow fixtures generate valid test workflows for common patterns

Thread Safety

All mocks in this package are thread-safe and can be used in parallel tests. State-collecting mocks use sync.Mutex or sync.RWMutex for concurrent access protection. This enables running tests with `go test -race -parallel=4` without data races.

Components

## Mocks (mocks.go)

Thread-safe implementations of domain port interfaces:

  • MockWorkflowRepository: In-memory workflow storage with configurable errors
  • MockStateStore: In-memory state persistence with configurable errors
  • MockCommandExecutor: Command execution simulator with call recording
  • MockLogger: Log message capture with level filtering
  • MockHistoryStore: Execution history tracking for testing

## Builders (builders.go)

Fluent builders for constructing test objects:

  • ExecutionServiceBuilder: Build ExecutionService with sensible defaults
  • WorkflowBuilder: Construct valid workflows with progressive configuration
  • StepBuilder: Create workflow steps of any type (command, parallel, terminal)

## Fixtures (fixtures.go)

Workflow factory functions for common test patterns:

  • SimpleWorkflow: Single-step workflow for basic testing
  • LinearWorkflow: Sequential multi-step workflow with transitions
  • ParallelWorkflow: Concurrent execution with configurable strategy
  • LoopWorkflow: Iterative workflows with for_each or while loops
  • ConversationWorkflow: Agent-based workflows with conversation patterns

Usage Examples

## Basic Mock Usage

Create thread-safe mocks for testing:

repo := testutil.NewMockWorkflowRepository()
repo.AddWorkflow("test", workflow)

store := testutil.NewMockStateStore()
executor := testutil.NewMockCommandExecutor()
executor.SetCommandResult("", &ports.CommandResult{Stdout: "success", ExitCode: 0})

logger := testutil.NewMockLogger()

## Fluent Builder Pattern

Build ExecutionService with minimal code (reduces 30+ lines to 2-3 lines):

// Basic service with defaults
svc := testutil.NewExecutionServiceBuilder().Build(t)

// Customized service with specific mocks
svc := testutil.NewExecutionServiceBuilder().
    WithLogger(logger).
    WithExecutor(executor).
    WithStateStore(store).
    Build(t)

// Service with workflow repository
repo := testutil.NewMockWorkflowRepository()
repo.AddWorkflow("test", workflow)
svc := testutil.NewExecutionServiceBuilder().
    WithWorkflowRepository(repo).
    Build(t)

## Workflow Construction

Build workflows progressively with fluent API:

// Simple workflow
wf := testutil.NewWorkflowBuilder().
    WithName("test-workflow").
    WithInitial("start").
    WithStep(testutil.NewCommandStep("start", "echo hello")).
    Build()

// Linear workflow with transitions
wf := testutil.NewWorkflowBuilder().
    WithName("linear").
    WithInitial("step1").
    WithStep(testutil.NewCommandStep("step1", "echo one").
        WithOnSuccess("step2")).
    WithStep(testutil.NewCommandStep("step2", "echo two").
        WithOnSuccess("end")).
    WithStep(testutil.NewTerminalStep("end")).
    Build()

// Parallel workflow with strategy
wf := testutil.NewWorkflowBuilder().
    WithName("parallel").
    WithInitial("parallel").
    WithStep(testutil.NewParallelStep("parallel", []string{"branch1", "branch2"}).
        WithStrategy("all_succeed").
        WithOnSuccess("end")).
    WithStep(testutil.NewCommandStep("branch1", "echo one")).
    WithStep(testutil.NewCommandStep("branch2", "echo two")).
    WithStep(testutil.NewTerminalStep("end")).
    Build()

## Workflow Fixtures

Use pre-built fixtures for common patterns:

// Simple single-step workflow
wf := testutil.SimpleWorkflow("test-name")

// Linear 3-step workflow
wf := testutil.LinearWorkflow("linear-name", 3)

// Parallel workflow with 2 branches and all_succeed strategy
wf := testutil.ParallelWorkflow("parallel-name", 2, "all_succeed")

// For-each loop over items
wf := testutil.LoopWorkflow("loop-name", "for_each", []string{"a", "b", "c"})

// While loop with condition
wf := testutil.LoopWorkflow("while-name", "while", "{{states.check.output}} == 'continue'")

// Conversation workflow with agent
wf := testutil.ConversationWorkflow("chat-name", "claude-sonnet", "openai")

## Complete Test Example

Typical test function using testutil (reduces LOC by 15%+):

func TestWorkflowExecution(t *testing.T) {
    // Arrange: Build service with test fixtures
    executor := testutil.NewMockCommandExecutor()
    executor.SetCommandResult("", &ports.CommandResult{Stdout: "success", ExitCode: 0})

    workflow := testutil.SimpleWorkflow("test")

    svc := testutil.NewExecutionServiceBuilder().
        WithExecutor(executor).
        Build(t)

    // Act: Execute workflow
    ctx, err := svc.Execute(context.Background(), workflow, nil)

    // Assert: Verify results
    require.NoError(t, err)
    assert.Equal(t, "end", ctx.CurrentState)
    assert.Equal(t, "success", ctx.States["start"].Output)
}

Migration Guide

## Replacing os.Setenv with t.Setenv

Before (thread-unsafe):

os.Setenv("AWF_WORKFLOWS_PATH", "/tmp/workflows")
defer os.Unsetenv("AWF_WORKFLOWS_PATH")

After (thread-safe):

t.Setenv("AWF_WORKFLOWS_PATH", "/tmp/workflows")

The t.Setenv method automatically restores the original value after the test, preventing test pollution and enabling safe parallel execution.

## Replacing Manual Mocks

Before (duplicated mock):

type mockExecutor struct {
    result string
    err    error
}

func (m *mockExecutor) Execute(ctx context.Context, cmd ports.Command) (*operation.Result, error) {
    if m.err != nil {
        return nil, m.err
    }
    return &operation.Result{Output: m.result}, nil
}

executor := &mockExecutor{result: "success"}

After (testutil mock):

executor := testutil.NewMockCommandExecutor()
executor.SetCommandResult("", &ports.CommandResult{Stdout: "success", ExitCode: 0})

## Replacing Manual Service Setup

Before (30+ lines):

logger := &mockLogger{messages: make([]string, 0)}
executor := &mockExecutor{result: "success"}
store := &mockStateStore{states: make(map[string]*workflow.ExecutionState)}
repo := &mockRepository{workflows: make(map[string]*workflow.Workflow)}
repo.workflows["test"] = &workflow.Workflow{
    Name:    "test",
    Initial: "start",
    Steps: map[string]*workflow.Step{
        "start": {Name: "start", Type: "terminal"},
    },
}

svc := application.NewExecutionService(logger, executor, store, repo)

After (2-3 lines):

executor := testutil.NewMockCommandExecutor()
executor.SetCommandResult("", &ports.CommandResult{Stdout: "success", ExitCode: 0})
svc := testutil.NewExecutionServiceBuilder().
    WithExecutor(executor).
    Build(t)

Design Principles

## ADR-005: Specialized Mock Locality

Keep specialized mocks colocated with tests unless reused 3+ times. Only general-purpose mocks belong in testutil.

Examples of specialized mocks that stay in test files:

  • retryCountingExecutor: Tracks retry attempts for specific retry logic tests
  • timeoutExecutor: Simulates command timeouts for timeout handling tests
  • errorMockExecutor: Returns specific error types for error handling tests

When to move a mock to testutil:

  • Used in 3+ test files across different packages
  • Implements a standard port interface with general-purpose behavior
  • Provides thread-safe state collection needed by multiple tests

## Sensible Defaults

All builders provide valid defaults for fields:

  • ExecutionServiceBuilder: Creates fully functional service with mock dependencies
  • WorkflowBuilder: Generates valid workflows with required fields populated
  • StepBuilder: Produces steps that pass validation

Progressive configuration via With*() methods allows customization only where needed.

## Thread Safety First

All mocks use sync.Mutex or sync.RWMutex from day one:

  • MockWorkflowRepository: sync.RWMutex for concurrent read/write operations
  • MockStateStore: sync.RWMutex for state access patterns
  • MockCommandExecutor: sync.Mutex for call recording
  • MockLogger: sync.Mutex for message capture

Validated with `go test -race ./...` to ensure data race freedom.

Performance Considerations

Builders add minimal allocation overhead:

  • Acceptable for test code (not production)
  • Reduces overall test complexity despite slight memory cost
  • Benchmark critical paths if performance becomes concern

Mock call recording uses slices with mutex protection:

  • Efficient for typical test workloads (< 1000 calls)
  • Call Clear() between test cases to reset state

See also:

  • .specify/implementation/C007/spec-content.md: Full specification
  • .specify/implementation/C007/plan.md: Implementation plan and ADRs
  • .specify/implementation/C007/tasks.md: Task breakdown and checklist

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL