Skip to content

Testing Guide

Test your AI agents, RAG pipelines, and integration workflows with confidence. Helix ships with a dedicated pkg/testing package and a mock component that together enable thorough unit and integration testing of workflows, processors, agents, and error handling logic.


Table of Contents

  1. Overview
  2. TestSupport -- Creating Test Contexts and ProducerTemplate
  3. Mock Component -- mock:// Endpoints, Expectations, Assertions
  4. Unit Testing Workflows
  5. Integration Testing
  6. Testing with direct://
  7. Testing Agents -- Mocking LLM Responses
  8. Testing Error Handling
  9. Declarative YAML/JSON Test Cases
  10. Testing Agents and Tools
  11. bin/livetest -- Real-LLM Integration Runner
  12. Best Practices
  13. Testing Built-in Functions

1. Overview

Helix provides the following testing infrastructure:

Package / Type Purpose
pkg/testing.TestSupport Pre-configured test harness with mock, direct, and seda components
pkg/testing.ProducerTemplate Convenient API for sending exchanges into endpoints
pkg/testing.AdviceWith Modify workflows at test time (replace endpoints, weave processors)
pkg/testing.NotifyBuilder / Notifier Async test synchronization -- wait for exchanges to flow
pkg/testing.RouteCoverage Track which route processors are exercised during tests
pkg/component/mock Mock endpoints that record exchanges and assert expectations

Import the testing package with an alias to avoid colliding with the standard library:

import (
    "testing"
    "time"

    helixtesting "github.com/arvenza/helix/pkg/testing"
)

2. TestSupport -- Creating Test Contexts and ProducerTemplate

TestSupport is the primary entry point for writing route tests. It creates a HelixContext pre-loaded with mock, direct, and seda components, and exposes a ProducerTemplate for sending test messages.

Basic Usage

func TestMyRoute(t *testing.T) {
    ts := helixtesting.NewTestSupport()

    // Add your route to the context.
    ts.Context.AddWorkflow(&engine.Workflow{
        ID:      "my-workflow",
        FromURI: "direct://start",
        Processors: []engine.Processor{
            engine.ProcessorFunc(func(ctx context.Context, ex *exchange.Exchange) error {
                body := ex.In().BodyAsString()
                ex.In().SetBody("processed: " + body)
                return nil
            }),
            // Send to a mock endpoint for verification.
            mustProducer(ts.Context, "mock://result"),
        },
    })

    // Start the context (starts all workflows).
    ts.Start(t)
    defer ts.Stop()

    // Set expectations on the mock endpoint.
    result := ts.GetMockEndpoint("result")
    result.ExpectedMessageCount(1)

    // Send a test message.
    if err := ts.Producer.SendBody("direct://start", "hello"); err != nil {
        t.Fatal(err)
    }

    // Assert expectations.
    if err := result.AssertIsSatisfied(5 * time.Second); err != nil {
        t.Fatal(err)
    }
}

TestSupport Fields

Field Type Description
Context *engine.HelixContext The underlying Helix context for route configuration
Producer *ProducerTemplate Send exchanges to any endpoint by URI
Mock *mock.Component Direct access to the mock component

ProducerTemplate Methods

The ProducerTemplate resolves endpoint URIs, creates producers, and sends exchanges automatically.

Fire-and-forget (InOnly):

// Send a body to an endpoint.
err := ts.Producer.SendBody("direct://start", "hello")

// Send a body with headers.
err := ts.Producer.SendBodyAndHeaders("direct://start", "hello", map[string]any{
    "Content-Type": "application/json",
    "X-Correlation": "abc-123",
})

Request-reply (InOut):

// Send a request and receive a response body.
result, err := ts.Producer.RequestBody("direct://start", "ping")
// result contains the Out body (or In body if no Out is set).

// Send with headers and receive a response.
result, err := ts.Producer.RequestBodyAndHeaders("direct://start", requestData, headers)

3. Mock Component -- mock:// Endpoints, Expectations, Assertions

The mock component (pkg/component/mock) provides test endpoints that record every exchange they receive and support rich assertions.

Creating Mock Endpoints

When using TestSupport, retrieve mock endpoints by name:

ts := helixtesting.NewTestSupport()
mockEp := ts.GetMockEndpoint("result")

Or create them directly:

comp := mock.New()
ep, err := comp.CreateEndpoint("mock://result", "result", nil)
mockEp := ep.(*mock.Endpoint)

Creating an endpoint with the same name twice returns the same instance.

Message Count Expectations

// Expect exactly N messages.
mockEp.ExpectedMessageCount(3)

// Expect at least N messages.
mockEp.SetMinimumExpectedMessageCount(2)

Body Expectations

// Expect specific bodies in exact order.
mockEp.ExpectedBodiesReceived("alpha", "beta", "gamma")

// Expect specific bodies in any order.
mockEp.ExpectedBodiesReceivedInAnyOrder("gamma", "alpha", "beta")

Both methods also set the expected message count automatically.

Header Expectations

// Every received message must have this header value.
mockEp.ExpectedHeaderReceived("X-Source", "test")

// Expect specific header values across messages, in any order.
mockEp.ExpectedHeaderValuesReceivedInAnyOrder("X-ID", "c", "a", "b")

Custom Predicate Expectations

mockEp.Expects(func(index int, ex *exchange.Exchange) error {
    body := ex.In().BodyAsString()
    if len(body) == 0 {
        return fmt.Errorf("expected non-empty body at index %d", index)
    }
    return nil
})

Ordering Expectations

Ordering expectations use an engine.Expression to extract a comparable value from each exchange:

// Verify ascending order.
mockEp.ExpectsAscending(myExpression)

// Verify descending order.
mockEp.ExpectsDescending(myExpression)

// Verify no duplicate values.
mockEp.ExpectsNoDuplicates(myExpression)

Reply Configuration (for InOut patterns)

// Set a reply body on every received exchange.
mockEp.ReturnReplyBody("reply-data")

// Set reply headers.
mockEp.ReturnReplyHeader("X-Reply", "yes")

WhenAnyExchangeReceived

Run a processor whenever an exchange arrives at the mock:

mockEp.WhenAnyExchangeReceived(engine.ProcessorFunc(
    func(ctx context.Context, ex *exchange.Exchange) error {
        ex.In().SetBody("transformed-" + ex.In().BodyAsString())
        return nil
    },
))

Asserting and Inspecting

// Wait up to the given duration for all expectations to be satisfied.
err := mockEp.AssertIsSatisfied(5 * time.Second)

// Override the default wait time (5 seconds).
mockEp.SetResultWaitTime(10 * time.Second)

// Query received state.
count := mockEp.ReceivedCount()
exchanges := mockEp.Received() // returns []*exchange.Exchange

// Reset for reuse between test phases.
mockEp.Reset()

AssertIsSatisfied checks all configured expectations: count, bodies, headers, predicates, and ordering. On failure it returns a detailed error listing every failed assertion.


4. Unit Testing Workflows

AdviceWith -- Modifying Routes at Test Time

AdviceWith lets you alter a workflow definition before the context starts, without changing production code. This is the primary tool for isolating routes under test.

Replace the source endpoint:

ts := helixtesting.NewTestSupport()

ts.Context.AddRoute(productionRoute) // FromURI: "kafka://orders"

// Replace Kafka with direct:// for testing.
advice := ts.AdviceWith("my-workflow")
err := advice.ReplaceFromWith("direct://test-input")

Replace To endpoints with mocks:

// Replace any endpoint matching "log" or "http" with a mock.
err := advice.MockEndpoints("log", "http")

Pattern matching supports scheme prefixes ("log" matches "log://info"), full URI prefixes, and wildcards ("log://*").

Intercept sends to an endpoint:

err := advice.InterceptSendToEndpoint("log", engine.ProcessorFunc(
    func(ctx context.Context, ex *exchange.Exchange) error {
        // Inspect or modify before the original send.
        log.Printf("intercepted: %v", ex.In().Body())
        return nil
    },
))

The interceptor runs before the original processor; both execute in sequence.

Weave processors into the pipeline:

// Add a processor at the beginning.
advice.WeaveAddFirst(myValidator)

// Add a processor at the end.
advice.WeaveAddLast(myAuditor)

// Insert before/after/replace a specific step by ID.
advice.WeaveByID("validate-step").Before(myPreProcessor)
advice.WeaveByID("validate-step").After(myPostProcessor)
advice.WeaveByID("validate-step").Replace(myNewValidator)
advice.WeaveByID("validate-step").Remove()

TaggedProcessor

To use WeaveByID, tag your processors:

tagged := helixtesting.NewTaggedProcessor("validate-step",
    engine.ProcessorFunc(func(ctx context.Context, ex *exchange.Exchange) error {
        // validation logic
        return nil
    }),
)

workflow := &engine.Workflow{
    ID:         "my-workflow",
    FromURI:    "direct://start",
    Processors: []engine.Processor{tagged, otherProc},
}

Complete Unit Test Example

func TestOrderProcessing(t *testing.T) {
    ts := helixtesting.NewTestSupport()

    ts.Context.AddWorkflow(&engine.Workflow{
        ID:      "order-route",
        FromURI: "direct://orders",
        Processors: []engine.Processor{
            helixtesting.NewTaggedProcessor("validate",
                engine.ProcessorFunc(func(ctx context.Context, ex *exchange.Exchange) error {
                    // Validate order
                    return nil
                }),
            ),
            helixtesting.NewTaggedProcessor("enrich",
                engine.ProcessorFunc(func(ctx context.Context, ex *exchange.Exchange) error {
                    ex.In().SetHeader("enriched", true)
                    return nil
                }),
            ),
            mustProducer(ts.Context, "mock://result"),
        },
    })

    ts.Start(t)
    defer ts.Stop()

    result := ts.GetMockEndpoint("result")
    result.ExpectedMessageCount(1)
    result.ExpectedHeaderReceived("enriched", true)

    ts.Producer.SendBody("direct://orders", `{"item":"widget","qty":5}`)

    if err := result.AssertIsSatisfied(5 * time.Second); err != nil {
        t.Fatal(err)
    }
}

5. Integration Testing

NotifyBuilder -- Async Test Synchronization

For integration tests where exchanges flow asynchronously (e.g., through SEDA queues, timers, or consumers), use NotifyBuilder to block until expected conditions are met.

func TestAsyncPipeline(t *testing.T) {
    ts := helixtesting.NewTestSupport()

    // Add workflows that use seda:// (async).
    ts.Context.AddRoute(ingestRoute)   // direct://input -> seda://process
    ts.Context.AddRoute(processRoute)  // seda://process -> mock://output

    ts.Start(t)
    defer ts.Stop()

    // Wait for 3 exchanges to complete on the "process-route".
    notify := helixtesting.NewNotifyBuilder(ts.Context).
        FromRoute("process-route").
        WhenDone(3).
        Create()

    // Send 3 messages.
    for i := 0; i < 3; i++ {
        ts.Producer.SendBody("direct://input", fmt.Sprintf("msg-%d", i))
    }

    // Block until 3 exchanges are done (or timeout).
    if err := notify.Matches(10 * time.Second); err != nil {
        t.Fatal(err)
    }
}

NotifyBuilder options:

Method Description
FromRoute(id) Only count exchanges from the specified route
WereSentTo(uri) Only count exchanges sent to the specified URI
WhenDone(n) Wait for N exchanges to finish processing (counted in After)
WhenReceived(n) Wait for N exchanges to be received (counted in Before)
Filter(fn) Custom predicate to selectively count exchanges

With a custom filter:

notify := helixtesting.NewNotifyBuilder(ts.Context).
    FromRoute("my-workflow").
    WhenDone(1).
    Filter(func(ctx context.Context, ex *exchange.Exchange) (bool, error) {
        // Only count exchanges with a specific header.
        return ex.In().Header("X-Priority") == "high", nil
    }).
    Create()

RouteCoverage -- Processor Coverage Tracking

RouteCoverage tracks which processors in your workflows are exercised during testing, helping identify untested code paths.

func TestWithCoverage(t *testing.T) {
    ts := helixtesting.NewTestSupport()
    coverage := helixtesting.NewRouteCoverage()

    // Register workflows and their processor IDs.
    coverage.RegisterRoute("my-workflow", []string{"validate", "transform", "send"})

    // Attach as an interceptor so hits are recorded automatically.
    ts.Context.AddInterceptor(coverage)

    // ... add workflows, start, run tests ...

    // After tests, inspect coverage.
    report := coverage.Report()
    t.Logf("Coverage: %.1f%% (%d/%d processors)",
        report.Coverage, report.CoveredProcessors, report.TotalProcessors)

    // Print a formatted report.
    coverage.PrintReport(os.Stdout)

    // Fail if coverage is below threshold.
    if report.Coverage < 80.0 {
        t.Errorf("route coverage %.1f%% is below 80%% threshold", report.Coverage)
    }
}

The printed report looks like:

ROUTE                                       TOTAL    COVERED   COVERAGE
--------------------------------------------------------------------------
my-workflow                                        3          2      66.7%
  UNCOVERED: send
--------------------------------------------------------------------------
TOTAL (1/1 workflows)                              3          2      66.7%

6. Testing with direct://

The direct:// component is the workhorse of Helix testing. It provides synchronous, in-memory message passing with no external dependencies, making it ideal for replacing production sources and sinks.

Replacing Production Endpoints

Use AdviceWith.ReplaceFromWith to swap a production consumer (Kafka, AMQP, etc.) with direct://:

ts := helixtesting.NewTestSupport()

// Production route consumes from Kafka.
ts.Context.AddWorkflow(&engine.Workflow{
    ID:      "ingest",
    FromURI: "kafka://orders?group=processor",
    Processors: []engine.Processor{orderProcessor, mustProducer(ts.Context, "mock://output")},
})

// Replace Kafka with direct:// for testing.
advice := ts.AdviceWith("ingest")
advice.ReplaceFromWith("direct://test-orders")

ts.Start(t)
defer ts.Stop()

// Now send directly.
ts.Producer.SendBody("direct://test-orders", testOrderJSON)

Request-Reply with direct://

func TestRequestReply(t *testing.T) {
    ts := helixtesting.NewTestSupport()

    ts.Context.AddWorkflow(&engine.Workflow{
        ID:      "echo-route",
        FromURI: "direct://echo",
        Processors: []engine.Processor{
            engine.ProcessorFunc(func(ctx context.Context, ex *exchange.Exchange) error {
                out := exchange.NewMessage()
                out.SetBody("echo: " + ex.In().BodyAsString())
                ex.SetOut(out)
                return nil
            }),
        },
    })

    ts.Start(t)
    defer ts.Stop()

    result, err := ts.Producer.RequestBody("direct://echo", "ping")
    if err != nil {
        t.Fatal(err)
    }
    if result != "echo: ping" {
        t.Errorf("got %v, want %q", result, "echo: ping")
    }
}

Combining direct:// and seda://

For testing async pipelines, use direct:// as the entry point and seda:// for the internal queue, with a mock or notify for verification:

func TestAsyncTransform(t *testing.T) {
    ts := helixtesting.NewTestSupport()

    // Workflow 1: direct -> seda (enqueue).
    ts.Context.AddWorkflow(&engine.Workflow{
        ID:      "enqueue",
        FromURI: "direct://input",
        Processors: []engine.Processor{mustProducer(ts.Context, "seda://work")},
    })

    // Workflow 2: seda -> process -> mock (dequeue and process).
    ts.Context.AddWorkflow(&engine.Workflow{
        ID:      "dequeue",
        FromURI: "seda://work",
        Processors: []engine.Processor{
            engine.ProcessorFunc(func(ctx context.Context, ex *exchange.Exchange) error {
                ex.In().SetBody("done:" + ex.In().BodyAsString())
                return nil
            }),
            mustProducer(ts.Context, "mock://output"),
        },
    })

    ts.Start(t)
    defer ts.Stop()

    output := ts.GetMockEndpoint("output")
    output.ExpectedBodiesReceived("done:task-1")

    ts.Producer.SendBody("direct://input", "task-1")

    if err := output.AssertIsSatisfied(5 * time.Second); err != nil {
        t.Fatal(err)
    }
}

7. Testing Agents -- Mocking LLM Responses

The agent framework uses the llm.ChatModel interface for all LLM interactions. To test agent logic without calling real LLM APIs, implement a mock ChatModel.

The ChatModel Interface

// pkg/agent/llm/model.go
type ChatModel interface {
    Generate(ctx context.Context, messages []*Message, opts ...Option) (*Message, error)
    Stream(ctx context.Context, messages []*Message, opts ...Option) (*StreamReader, error)
    BindTools(tools []ToolDefinition) error
    Provider() string
    Model() string
}

Creating a Mock ChatModel

package mytest

import (
    "context"
    "github.com/arvenza/helix/pkg/agent/llm"
)

// MockChatModel returns predetermined responses for testing.
type MockChatModel struct {
    GenerateFn func(ctx context.Context, messages []*llm.Message, opts ...llm.Option) (*llm.Message, error)
    StreamFn   func(ctx context.Context, messages []*llm.Message, opts ...llm.Option) (*llm.StreamReader, error)
    BoundTools []llm.ToolDefinition
}

func (m *MockChatModel) Generate(ctx context.Context, messages []*llm.Message, opts ...llm.Option) (*llm.Message, error) {
    if m.GenerateFn != nil {
        return m.GenerateFn(ctx, messages, opts...)
    }
    return &llm.Message{
        Role:    llm.RoleAssistant,
        Content: "mock response",
    }, nil
}

func (m *MockChatModel) Stream(ctx context.Context, messages []*llm.Message, opts ...llm.Option) (*llm.StreamReader, error) {
    if m.StreamFn != nil {
        return m.StreamFn(ctx, messages, opts...)
    }
    ch := make(chan llm.StreamChunk, 1)
    ch <- llm.StreamChunk{Content: "mock stream", Done: true}
    close(ch)
    return llm.NewStreamReader(ch), nil
}

func (m *MockChatModel) BindTools(tools []llm.ToolDefinition) error {
    m.BoundTools = tools
    return nil
}

func (m *MockChatModel) Provider() string { return "mock" }
func (m *MockChatModel) Model() string    { return "mock-model" }

Testing with a Mock ChatModel

func TestAgentRoute(t *testing.T) {
    mockLLM := &MockChatModel{
        GenerateFn: func(ctx context.Context, msgs []*llm.Message, opts ...llm.Option) (*llm.Message, error) {
            // Inspect the prompt.
            lastMsg := msgs[len(msgs)-1]
            if lastMsg.Content == "What is 2+2?" {
                return &llm.Message{
                    Role:    llm.RoleAssistant,
                    Content: "4",
                }, nil
            }
            return &llm.Message{
                Role:    llm.RoleAssistant,
                Content: "I don't know",
            }, nil
        },
    }

    // Use mockLLM wherever your agent code accepts an llm.ChatModel.
    // For example, pass it into an agent constructor or set it on the context.
    _ = mockLLM
}

Testing Tool Calls

func TestAgentToolCall(t *testing.T) {
    callCount := 0
    mockLLM := &MockChatModel{
        GenerateFn: func(ctx context.Context, msgs []*llm.Message, opts ...llm.Option) (*llm.Message, error) {
            callCount++
            if callCount == 1 {
                // First call: model requests a tool call.
                return &llm.Message{
                    Role: llm.RoleAssistant,
                    ToolCalls: []llm.ToolCall{{
                        ID:   "call-1",
                        Type: "function",
                        Function: struct {
                            Name      string `json:"name"`
                            Arguments string `json:"arguments"`
                        }{
                            Name:      "get_weather",
                            Arguments: `{"city":"London"}`,
                        },
                    }},
                }, nil
            }
            // Second call: model produces final answer after receiving tool result.
            return &llm.Message{
                Role:    llm.RoleAssistant,
                Content: "The weather in London is sunny.",
            }, nil
        },
    }

    _ = mockLLM // wire into your agent under test
}

Testing Streaming Responses

func TestStreamingAgent(t *testing.T) {
    mockLLM := &MockChatModel{
        StreamFn: func(ctx context.Context, msgs []*llm.Message, opts ...llm.Option) (*llm.StreamReader, error) {
            ch := make(chan llm.StreamChunk, 3)
            go func() {
                defer close(ch)
                ch <- llm.StreamChunk{Content: "Hello "}
                ch <- llm.StreamChunk{Content: "world"}
                ch <- llm.StreamChunk{Content: "!", Done: true}
            }()
            return llm.NewStreamReader(ch), nil
        },
    }

    // Collect the full streamed response.
    reader, err := mockLLM.Stream(context.Background(), []*llm.Message{
        {Role: llm.RoleUser, Content: "Say hello"},
    })
    if err != nil {
        t.Fatal(err)
    }
    result, err := reader.Collect()
    if err != nil {
        t.Fatal(err)
    }
    if result != "Hello world!" {
        t.Errorf("got %q, want %q", result, "Hello world!")
    }
}

Registering a Mock Provider

If your code uses llm.NewChatModel with the provider registry, register a mock provider:

func TestWithMockProvider(t *testing.T) {
    llm.RegisterProvider("mock", func(config llm.ModelConfig) (llm.ChatModel, error) {
        return &MockChatModel{}, nil
    })

    model, err := llm.NewChatModel(llm.ModelConfig{
        Provider: "mock",
        Model:    "test",
    })
    if err != nil {
        t.Fatal(err)
    }
    // Use model in your agent test.
    _ = model
}

8. Testing Error Handling

Testing Workflow Error Handlers

Workflows support ErrorHandler definitions with dead letter channels, redelivery policies, and exception handlers. Test these by deliberately triggering errors.

func TestErrorHandling(t *testing.T) {
    ts := helixtesting.NewTestSupport()

    ts.Context.AddWorkflow(&engine.Workflow{
        ID:      "error-route",
        FromURI: "direct://input",
        Processors: []engine.Processor{
            engine.ProcessorFunc(func(ctx context.Context, ex *exchange.Exchange) error {
                return fmt.Errorf("simulated failure")
            }),
        },
    })

    ts.Start(t)
    defer ts.Stop()

    // Send a message that will trigger the error.
    err := ts.Producer.SendBody("direct://input", "bad-data")
    if err == nil {
        t.Fatal("expected error from failed route")
    }
    if !strings.Contains(err.Error(), "simulated failure") {
        t.Errorf("unexpected error message: %v", err)
    }
}

Testing with Dead Letter Channel

Use a mock endpoint as the dead letter channel to verify that failed exchanges are properly routed:

func TestDeadLetterChannel(t *testing.T) {
    ts := helixtesting.NewTestSupport()

    dlq := ts.GetMockEndpoint("dead-letter")
    dlq.ExpectedMessageCount(1)

    ts.Context.AddWorkflow(&engine.Workflow{
        ID:      "dlq-route",
        FromURI: "direct://input",
        Processors: []engine.Processor{
            engine.ProcessorFunc(func(ctx context.Context, ex *exchange.Exchange) error {
                return fmt.Errorf("permanent failure")
            }),
        },
        // Configure ErrorHandler with DeadLetterChannel: "mock://dead-letter"
    })

    ts.Start(t)
    defer ts.Stop()

    _ = ts.Producer.SendBody("direct://input", "will-fail")

    if err := dlq.AssertIsSatisfied(5 * time.Second); err != nil {
        t.Fatal(err)
    }

    // Inspect the dead-lettered exchange.
    received := dlq.Received()
    if len(received) != 1 {
        t.Fatalf("expected 1 dead-lettered exchange, got %d", len(received))
    }
}

Verifying Exchange Error State with Predicates

Use mock endpoint predicates to assert that exchanges carry the expected error information:

func TestExchangeErrorState(t *testing.T) {
    ts := helixtesting.NewTestSupport()

    output := ts.GetMockEndpoint("output")
    output.ExpectedMessageCount(1)
    output.Expects(func(index int, ex *exchange.Exchange) error {
        if ex.Exception() == nil {
            return fmt.Errorf("expected exception on exchange at index %d", index)
        }
        return nil
    })

    // Set up a workflow that catches errors and forwards them to mock://output.
    // ...
}

Testing Processor Errors in Isolation

Test individual processors without a full route:

func TestProcessorError(t *testing.T) {
    validator := engine.ProcessorFunc(func(ctx context.Context, ex *exchange.Exchange) error {
        body := ex.In().BodyAsString()
        if body == "" {
            return fmt.Errorf("validation failed: body is empty")
        }
        return nil
    })

    // Test the error case.
    ex := exchange.New(context.Background())
    ex.In().SetBody("")

    err := validator.Process(context.Background(), ex)
    if err == nil {
        t.Fatal("expected validation error")
    }
    if !strings.Contains(err.Error(), "validation failed") {
        t.Errorf("unexpected error: %v", err)
    }

    // Test the success case.
    ex2 := exchange.New(context.Background())
    ex2.In().SetBody("valid data")

    err = validator.Process(context.Background(), ex2)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
}

Testing Retry Behavior

To test that a workflow retries on transient failures, use a counter in the processor:

func TestRetryBehavior(t *testing.T) {
    ts := helixtesting.NewTestSupport()

    attempts := 0
    ts.Context.AddWorkflow(&engine.Workflow{
        ID:      "retry-route",
        FromURI: "direct://input",
        Processors: []engine.Processor{
            engine.ProcessorFunc(func(ctx context.Context, ex *exchange.Exchange) error {
                attempts++
                if attempts < 3 {
                    return fmt.Errorf("transient error (attempt %d)", attempts)
                }
                return nil
            }),
            mustProducer(ts.Context, "mock://result"),
        },
        // Configure ErrorHandler with MaxRedeliveries: 3
    })

    ts.Start(t)
    defer ts.Stop()

    result := ts.GetMockEndpoint("result")
    result.ExpectedMessageCount(1)

    ts.Producer.SendBody("direct://input", "data")

    if err := result.AssertIsSatisfied(5 * time.Second); err != nil {
        t.Fatal(err)
    }
    if attempts < 3 {
        t.Errorf("expected at least 3 attempts, got %d", attempts)
    }
}

9. Declarative YAML/JSON Test Cases

Helix supports writing test cases declaratively in YAML or JSON, without any Go code. This enables teams to test workflows using the same language they define workflows in — and run them with a single CLI command.

Running Tests

# Run tests from a specific file
helix test flow_test.yaml

# Run all *_test.yaml / *_test.json files in a directory
helix test tests/

# Run with verbose output (shows descriptions)
helix test -v flow_test.yaml

# Run tests from a file that contains both workflows and tests
helix test flow.yaml

Exit code: 0 = all pass, 1 = any failure.

Test File Structure

Test files use the tests: key at the top level:

tests:
  - id: test-happy-path
    description: "Order processing returns confirmed status"
    route: order-route
    timeout: "10s"
    input:
      uri: direct://orders
      body: '{"orderId": "123", "amount": 50}'
      headers:
        Content-Type: application/json
    expect:
      body:
        contains: '"status":"confirmed"'
      headers:
        X-Processed: "true"
      mock-endpoints:
        - name: output
          expected-count: 1

Inline Tests

Tests can be placed in the same YAML file as the routes they test:

workflows:
  - id: greeting-route
    trigger:
      uri: direct://greet
    steps:
      - set-body:
          constant: "Hello, World!"
      - set-header:
          name: X-Greeted
          constant: "true"
      - target: mock://output

tests:
  - id: test-greeting
    route: greeting-route
    input:
      uri: direct://greet
      body: "anything"
    expect:
      body:
        contains: "Hello"
      headers:
        X-Greeted: "true"
      mock-endpoints:
        - name: output
          expected-count: 1

Run with helix test flow.yaml — both workflows and tests are loaded from the same file.

Separate Test Files

Test files named *_test.yaml or *-test.yaml are automatically discovered. Helix looks for a matching route file by stripping the test suffix:

flow.yaml           ← workflows
flow_test.yaml      ← tests (auto-discovers flow.yaml)

If no matching file is found, Helix loads all non-test YAML files in the same directory.

Test Input

input:
  uri: direct://orders          # endpoint to send to
  body: '{"key": "value"}'      # message body (string)
  body-file: testdata/order.json # or load from file (relative to test file)
  headers:                       # optional exchange headers
    Content-Type: application/json
    X-Tenant: acme
  pattern: InOut                 # InOnly (default) or InOut for request-reply
  • Pattern auto-detection: If expect.body or expect.headers is set, the pattern automatically becomes InOut.
  • JSON body parsing: If the body starts with { or [, it's parsed as JSON before sending.

Body Assertions

Four assertion modes on response body or mock endpoint bodies:

expect:
  body:
    equals: "exact match"           # string equality
    contains: "substring"           # substring match
    matches: "order-\\d+-confirmed" # regex match
    starlang: |                     # Starlang expression (must return true)
      read(payload, "application/json")["status"] == "ok"

Multiple assertions can be combined — all must pass.

Header Assertions

expect:
  headers:
    X-Status: "ok"
    X-Workflow: "order-processing"

Each expected header is compared as a string against the response header value.

Error Assertions

Test that a workflow produces an error:

expect:
  error:
    occurs: true                    # expect any error
    contains: "validation failed"   # error message must contain this

If error is not set and the workflow produces an error, the test fails.

Mock Endpoint Assertions

Assert on messages received by mock:// endpoints in the route:

expect:
  mock-endpoints:
    - name: kafka-output           # mock endpoint name (from mock://kafka-output)
      expected-count: 2            # exact message count
      minimum-count: 1             # or minimum count
      expected-bodies:             # ordered body assertions
        - contains: '"orderId":"123"'
        - equals: '{"status":"done"}'
        - matches: '"timestamp":"\\d{4}-.*"'

Setup and Advice

Load additional route files or apply route modifications before testing:

tests:
  - id: test-with-setup
    route: main-route
    setup:
      workflows:                      # load additional route files
        - shared-workflows.yaml
        - helper-workflows.yaml
      advice:                      # AdviceWith modifications
        - route: main-route        # route to modify
          replace-from: direct://test-input  # replace trigger URI
          mock-endpoints:          # replace these endpoints with mocks
            - kafka
            - http
    input:
      uri: direct://test-input
      body: "test"
    expect:
      mock-endpoints:
        - name: kafka-main-route-output
          expected-count: 1

Complete Example

A flow with multiple workflows and comprehensive tests:

# order-flow.yaml
workflows:
  - id: order-ingestion
    trigger:
      uri: direct://orders
    steps:
      - choice:
          when:
            - starlang: 'headers["X-Priority"] == "high"'
              steps:
                - target: mock://priority-queue
          otherwise:
            steps:
              - target: mock://standard-queue

  - id: order-transform
    trigger:
      uri: direct://transform
    steps:
      - set-body:
          constant: '{"status":"processed"}'
      - set-header:
          name: X-Processed
          constant: "true"
      - target: mock://output
# order-flow_test.yaml
tests:
  - id: test-high-priority
    description: "High priority orders go to priority queue"
    route: order-ingestion
    input:
      uri: direct://orders
      body: '{"orderId": "1"}'
      headers:
        X-Priority: "high"
    expect:
      mock-endpoints:
        - name: priority-queue
          expected-count: 1
        - name: standard-queue
          expected-count: 0

  - id: test-standard-priority
    description: "Normal orders go to standard queue"
    route: order-ingestion
    input:
      uri: direct://orders
      body: '{"orderId": "2"}'
      headers:
        X-Priority: "normal"
    expect:
      mock-endpoints:
        - name: standard-queue
          expected-count: 1
        - name: priority-queue
          expected-count: 0

  - id: test-transform-body
    description: "Transform route sets processed status"
    route: order-transform
    input:
      uri: direct://transform
      body: "raw"
    expect:
      body:
        contains: '"status":"processed"'
      headers:
        X-Processed: "true"

  - id: test-transform-regex
    description: "Output matches JSON pattern"
    route: order-transform
    input:
      uri: direct://transform
      body: "raw"
    expect:
      body:
        matches: '\\{"status":"[a-z]+"\\}'
      mock-endpoints:
        - name: output
          expected-count: 1
$ helix test -v order-flow_test.yaml

  PASS  test-high-priority — High priority orders go to priority queue (0s)
  PASS  test-standard-priority — Normal orders go to standard queue (0s)
  PASS  test-transform-body — Transform route sets processed status (0s)
  PASS  test-transform-regex — Output matches JSON pattern (0s)
  ok  order-flow_test.yaml  4 test(s), 0 failure(s) (0s)

PASS  4 passed (0s)

YAML Test Reference

Field Type Required Description
id string yes Unique test identifier
description string no Human-readable description
workflow string yes Route ID to test
timeout string no Test timeout (default 10s)
setup.workflows string[] no Additional workflow files to load
setup.advice[].route string no Route to advise (defaults to workflow)
setup.advice[].replace-from string no Replace the workflow's trigger URI
setup.advice[].mock-endpoints string[] no URI patterns to replace with mocks
input.uri string yes Endpoint URI to send to
input.body string no Message body
input.body-file string no Load body from file
input.headers map no Exchange headers
input.pattern string no InOnly or InOut (auto-detected)
expect.body.equals string no Exact body match
expect.body.contains string no Substring match
expect.body.matches string no Regex match
expect.body.starlang string no Starlang expression (must return true)
expect.headers map no Expected header values
expect.error.occurs bool no Expect an error
expect.error.contains string no Error message substring
expect.mock-endpoints[].name string yes Mock endpoint name
expect.mock-endpoints[].expected-count int no Exact message count
expect.mock-endpoints[].minimum-count int no Minimum message count
expect.mock-endpoints[].expected-bodies list no Ordered body assertions

10. Testing Agents and Tools

Beyond mocking the LLM (covered in section 7), you can test tools and agent workflows end-to-end using ProducerTemplate:

// Test a tool via its auto-generated URI
result, err := ts.Producer.RequestBody("tool://search", map[string]any{"query": "test"})
if err != nil {
    t.Fatal(err)
}

// Test an agent route directly (with a mock LLM wired in)
resp, err := agent.Run(ctx, "What is 2+2?")
if err != nil {
    t.Fatal(err)
}
if resp != "4" {
    t.Errorf("unexpected response: %s", resp)
}

For tools, RequestBody sends an InOut exchange to the tool:// URI, which validates the input schema, runs the tool's steps, and returns the result. For agents, agent.Run executes the full reasoning loop (with the mock ChatModel returning deterministic answers).


11. bin/livetest -- Real-LLM Integration Runner

bin/livetest (built from cmd/livetest/main.go) runs every Helix-side feature against the real upstream APIs — registry plumbing, provider smoke tests, finish-reason / reasoning-effort / Qwen3 thinking, agent flows, DSL round-trips, and the full governance stack. NOT part of go test ./... because it costs money on every run and depends on remote services.

go build -o bin/livetest ./cmd/livetest
bin/livetest -keys ./keys                      # run everything that has a key
bin/livetest -keys ./keys -filter "governance:"  # one suite at a time

The keys file is tab- or space-tolerant; one line per provider with the API key as the last whitespace-delimited token. Tests with no matching key print SKIP (no key) and don't fail the suite.

What's covered

  • Provider registry — every provider is constructible via llm.NewChatModel (17 providers + gemini alias).
  • Smoke — one round-trip per hosted provider (openai, anthropic, groq, together, fireworks, deepinfra, xai, cohere, vertex, local-qwen3).
  • Reasoning / finish-reason — Message.FinishReason populated on OpenAI; populated on Anthropic streams; Qwen3 emits reasoning_content; OpenAI o3-mini accepts reasoning_effort.
  • Streaming — Groq CollectMessage over a real SSE response.
  • Agent flows — ReAct with tool calling, budget caps, runLoop completion convention, runSequential sub-agents, runParallel FailFast sibling cancellation, OnEvent stream capture, SetMemory race under concurrent Memory() reads, DurationMS JSON is milliseconds.
  • DSL round-trips — react agent loaded from YAML; model_middleware: [logging] on a real OpenAI call; memory_middleware: [logging]; budget max_tool_calls blocks via YAML; parallel_error_policy: fail_fast with a real LLM + bad model; qwen provider + base_url overrides to local qwen3.
  • Governance — hash-chained audit verifies after a mixed run; kill switch blocks a capability; policy engine Starlang predicate fires; rings deny an unassigned action; MCP scanner flags a typosquat; prompt-defense flags an injection; real LLM run with policy: deny blocking exec_shell; real LLM run via DSL where a Starlang predicate blocks.

Output format

  registry: all 18 providers register                            PASS  (12ms)
  smoke: openai                                                  PASS  (1.4s)
  smoke: anthropic                                               SKIP  (no key)
  governance: real LLM via DSL, Starlang predicate blocks        PASS  (3.1s)
  ...

summary: pass=33 fail=0 skip=5

Exit code 1 if any test failed, 0 otherwise (skipped tests don't fail the suite). Each test wraps every upstream call in a 60-second context so sticky deadlines surface "upstream froze" rather than letting a Go test hang.

When to use livetest vs. go test

  • go test ./... — unit tests, mock-LLM agent tests, processor tests, DSL parsing, every fast deterministic check. Run on every commit.
  • bin/livetest — schema / wire-shape regressions across real providers, real-LLM-through-the-stack governance, anything that exercises a remote API. Run before releases and when changing provider plumbing.

12. Best Practices

Use TestSupport for All Workflow Tests

Always start with helixtesting.NewTestSupport(). It sets up the three most commonly needed components (mock, direct, seda) and provides a clean lifecycle.

Always Defer ts.Stop()

ts.Start(t)
defer ts.Stop()

This ensures the context and all workflows are stopped even if the test panics or fails early.

Prefer direct:// as Entry Points

Use direct:// for synchronous, deterministic test inputs. Replace production sources (Kafka, AMQP, etc.) with direct:// via AdviceWith.ReplaceFromWith.

Use Mock Endpoints for Verification

Instead of inspecting log output or side effects, route to mock:// endpoints and use AssertIsSatisfied for deterministic assertions.

Set Reasonable Timeouts

mockEp.SetResultWaitTime(5 * time.Second)
err := mockEp.AssertIsSatisfied(5 * time.Second)

For fast unit tests, keep timeouts short (1-2 seconds). For integration tests with async components, allow more time (5-10 seconds).

Reset Mock Endpoints Between Phases

If a test has multiple phases, reset mock endpoints between them:

mockEp.Reset()
mockEp.ExpectedMessageCount(2)
// ... send more messages ...

Use AdviceWith Instead of Separate Test Routes

Rather than writing a separate workflow definition for tests, use AdviceWith to modify the production route. This ensures you are testing the actual route logic.

Run Tests in Parallel

Mock endpoints and TestSupport instances are independent. Use t.Parallel() freely:

func TestRouteA(t *testing.T) {
    t.Parallel()
    ts := helixtesting.NewTestSupport()
    // ...
}

func TestRouteB(t *testing.T) {
    t.Parallel()
    ts := helixtesting.NewTestSupport()
    // ...
}

Use RouteCoverage in CI

Add RouteCoverage to your integration test suites and fail the build if coverage drops below a threshold. This catches untested route branches early.

Use NotifyBuilder for Async Routes

For any route involving SEDA, timers, cron, or external consumers, use NotifyBuilder instead of sleeping:

// Bad: fragile and slow.
time.Sleep(2 * time.Second)

// Good: deterministic and fast.
notify := helixtesting.NewNotifyBuilder(ts.Context).
    FromRoute("my-async-route").
    WhenDone(1).
    Create()
err := notify.Matches(5 * time.Second)

Tag Processors for Fine-Grained Testing

Use TaggedProcessor when you need to insert, replace, or remove specific steps via WeaveByID:

tagged := helixtesting.NewTaggedProcessor("my-step", myProcessor)

Test Both Happy Path and Error Path

For every route, write at least one test for the success path and one for the error path. Use mock endpoints with Expects predicates to verify error state on failed exchanges.

Helper Function for Creating Producers

A common pattern is creating a producer from a URI to use as a processor in a route:

func mustProducer(ctx *engine.HelixContext, uri string) engine.Processor {
    ep, err := ctx.ResolveEndpoint(uri)
    if err != nil {
        panic(fmt.Sprintf("failed to resolve %q: %v", uri, err))
    }
    prod, err := ep.CreateProducer()
    if err != nil {
        panic(fmt.Sprintf("failed to create producer for %q: %v", uri, err))
    }
    return prod
}

Keep Tests Focused

Each test function should verify one behavior. Combine TestSupport, mock endpoints, and AdviceWith to isolate the specific behavior under test rather than testing entire multi-workflow pipelines in a single function.


memx Test Coverage

The memx integration is covered by a comprehensive test suite spanning unit, integration, and end-to-end levels.

Unit Tests (31 passing)

Area Tests What is Covered
pkg/rag/store/memx/ 12 Store initialization, upsert, delete, collection management, auto-discovery, database field, error handling
pkg/rag/retrieve/memx/ 10 Similarity search, hybrid search, BM25, MMR, metadata filtering, auto-discovery, raw results, database field
pkg/component/memx/ 14 Component lifecycle, producer/consumer, URI parsing, ES API + SQL modes, database field
pkg/agent/memory/vector_memx/ 10 Vector memory store/recall, embedding round-trip, TTL expiration

Note: 31 memx-specific tests are currently passing across all test suites. The unit test table above shows per-area counts that include shared test infrastructure.

Integration Tests (16 against live memx)

These tests require a running memx instance (docker run -d -p 5433:5433 -p 8080:8080 memx-server) and are skipped when the instance is unavailable.

Test Description
TestMemxESAPIConnect Connection and health check via ES API
TestMemxSQLConnect Connection via pgwire interface
TestMemxAutoDetect URL-based auto-detection of ES vs SQL mode
TestMemxBulkIngest Bulk vector insert (1000 documents)
TestMemxHybridSearch Dense + BM25 fusion search
TestMemxMetadataFilter Filtered search with metadata predicates
TestMemxSQLJoin SQL join queries across collections
TestMemxAgentMemory Agent vector memory round-trip
... 8 additional integration tests

End-to-End RAG Pipeline

The E2E test ingests a real document corpus and measures throughput:

Metric Value
Documents ingested 7 files (441KB total)
Chunks produced 1,232
Embedding throughput 1,549 embeddings/sec
Search latency (p50) 12ms
Search latency (p99) 38ms

YAML DSL Tests (7 test cases)

Declarative test cases validate memx integration through the YAML DSL:

# memx_test.yaml
tests:
  - id: memx-store-retrieve
    description: "Store and retrieve vectors via memx"
    routes_file: memx-rag.yaml
    steps:
      - send:
          uri: direct://ingest
          body: "The quick brown fox jumps over the lazy dog."
      - send:
          uri: direct://search
          body: "fox jumping"
      - assert:
          body_contains: "quick brown fox"

Large Document Test

Validates handling of substantial document sets:

  • Corpus: 441KB across 7 files (PDF, Markdown, HTML, DOCX, JSON, CSV, plain text)
  • Chunking: recursive strategy, 512 tokens, 64 overlap
  • Result: 1,232 chunks stored and retrievable with correct metadata

13. Testing Built-in Functions

The built-in function registry (fs.read, fs.write, shell.exec, web.fetch, etc.) includes 23 unit tests and 12 integration tests. These validate both the individual function behavior and the end-to-end agent-to-function pipeline.

Unit Tests (23 tests)

Unit tests for built-in functions validate each function in isolation without requiring an LLM or network access.

func TestFsRead(t *testing.T) {
    ts := helixtesting.NewTestSupport()

    // Create a temp file
    tmpFile := filepath.Join(t.TempDir(), "test.txt")
    os.WriteFile(tmpFile, []byte("hello world"), 0644)

    // Invoke the built-in function
    result, err := builtin.Call("fs.read", map[string]any{
        "path": tmpFile,
    })
    if err != nil {
        t.Fatal(err)
    }
    if result.(string) != "hello world" {
        t.Errorf("expected 'hello world', got %q", result)
    }
}

func TestFsEdit(t *testing.T) {
    ts := helixtesting.NewTestSupport()

    tmpFile := filepath.Join(t.TempDir(), "code.go")
    os.WriteFile(tmpFile, []byte("func old() {}"), 0644)

    _, err := builtin.Call("fs.edit", map[string]any{
        "path":       tmpFile,
        "old_string": "old",
        "new_string": "updated",
    })
    if err != nil {
        t.Fatal(err)
    }

    content, _ := os.ReadFile(tmpFile)
    if string(content) != "func updated() {}" {
        t.Errorf("edit failed: got %q", content)
    }
}

func TestShellExecBlocked(t *testing.T) {
    _, err := builtin.Call("shell.exec", map[string]any{
        "command": "rm -rf /",
    })
    if err == nil {
        t.Fatal("expected blocked command error")
    }
}

func TestShellExecTimeout(t *testing.T) {
    _, err := builtin.Call("shell.exec", map[string]any{
        "command": "sleep 120",
        "timeout": "1s",
    })
    if err == nil {
        t.Fatal("expected timeout error")
    }
}

func TestWebFetchTruncation(t *testing.T) {
    // Start a test server that returns a large response
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Write(bytes.Repeat([]byte("x"), 100_000))
    }))
    defer srv.Close()

    result, err := builtin.Call("web.fetch", map[string]any{
        "url": srv.URL,
    })
    if err != nil {
        t.Fatal(err)
    }
    if len(result.(string)) > 30720 {
        t.Errorf("expected truncation to 30KB, got %d bytes", len(result.(string)))
    }
}

func TestDataJsonParse(t *testing.T) {
    result, err := builtin.Call("data.json_parse", map[string]any{
        "json": `{"users": [{"name": "Alice"}]}`,
        "path": "users.0.name",
    })
    if err != nil {
        t.Fatal(err)
    }
    if result != "Alice" {
        t.Errorf("expected Alice, got %v", result)
    }
}

func TestMathEval(t *testing.T) {
    result, err := builtin.Call("math.eval", map[string]any{
        "expression": "(2 + 3) * 4 - 1",
    })
    if err != nil {
        t.Fatal(err)
    }
    if result != float64(19) {
        t.Errorf("expected 19, got %v", result)
    }
}

Coverage by function category:

Category Functions Tests
File System fs.read, fs.write, fs.edit, fs.list, fs.glob, fs.grep, fs.mkdir, fs.delete 10
Shell shell.exec 4 (success, timeout, blocked, working dir)
Web web.fetch 3 (success, truncation, HTML stripping)
Data data.json_parse, data.json_format, data.csv_parse 4
Math math.eval 2

Integration Tests (12 tests)

Integration tests validate that an agent can discover, invoke, and receive results from built-in functions through the full agent pipeline.

func TestAgentWithBuiltinFsRead(t *testing.T) {
    ts := helixtesting.NewTestSupport()

    // Write a test file
    tmpDir := t.TempDir()
    os.WriteFile(filepath.Join(tmpDir, "data.txt"), []byte("secret info"), 0644)

    // Define an agent with fs.read tool
    agentDef := &agent.AgentConfig{
        ID:           "test-agent",
        Type:         "react",
        SystemPrompt: "Read the file when asked.",
        Tools: []agent.ToolConfig{
            {
                Name:        "read-file",
                Type:        "function",
                Function:    "fs.read",
                Description: "Read a file",
            },
        },
    }

    // Run the agent with a mock LLM that always calls fs.read
    mockLLM := helixtesting.NewMockLLM()
    mockLLM.OnMessage("read data.txt").CallTool("read-file", map[string]any{
        "path": filepath.Join(tmpDir, "data.txt"),
    }).ThenRespond("The file contains: secret info")

    result := ts.RunAgent(agentDef, mockLLM, "read data.txt")
    if !strings.Contains(result, "secret info") {
        t.Errorf("expected agent to read file, got: %s", result)
    }
}

Integration test matrix:

Test Description
TestAgentWithBuiltinFsRead Agent reads a file via fs.read
TestAgentWithBuiltinFsWrite Agent creates a new file via fs.write
TestAgentWithBuiltinFsEdit Agent edits an existing file via fs.edit
TestAgentWithBuiltinFsGlob Agent finds files via fs.glob
TestAgentWithBuiltinFsGrep Agent searches file contents via fs.grep
TestAgentWithBuiltinShellExec Agent runs a command via shell.exec
TestAgentWithBuiltinShellExecSandbox Blocked commands return an error to the agent
TestAgentWithBuiltinWebFetch Agent fetches a URL and processes the text
TestAgentWithBuiltinJsonParse Agent parses JSON with path extraction
TestAgentWithBuiltinMathEval Agent evaluates a math expression
TestAgentMultiToolChain Agent chains multiple built-in tools in sequence
TestSmartMemoryWithBuiltinTools SmartMemory compresses large tool results correctly