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¶
- Overview
- TestSupport -- Creating Test Contexts and ProducerTemplate
- Mock Component -- mock:// Endpoints, Expectations, Assertions
- Unit Testing Workflows
- Integration Testing
- Testing with direct://
- Testing Agents -- Mocking LLM Responses
- Testing Error Handling
- Declarative YAML/JSON Test Cases
- Testing Agents and Tools
bin/livetest-- Real-LLM Integration Runner- Best Practices
- 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:
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:
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:
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.bodyorexpect.headersis set, the pattern automatically becomesInOut. - 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¶
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 +geminialias). - Smoke — one round-trip per hosted provider (
openai,anthropic,groq,together,fireworks,deepinfra,xai,cohere,vertex, local-qwen3). - Reasoning / finish-reason —
Message.FinishReasonpopulated on OpenAI; populated on Anthropic streams; Qwen3 emitsreasoning_content; OpenAI o3-mini acceptsreasoning_effort. - Streaming — Groq
CollectMessageover a real SSE response. - Agent flows — ReAct with tool calling, budget caps, runLoop completion convention, runSequential sub-agents, runParallel
FailFastsibling cancellation,OnEventstream capture,SetMemoryrace under concurrentMemory()reads,DurationMSJSON is milliseconds. - DSL round-trips —
reactagent loaded from YAML;model_middleware: [logging]on a real OpenAI call;memory_middleware: [logging];budgetmax_tool_callsblocks via YAML;parallel_error_policy: fail_fastwith a real LLM + bad model;qwenprovider +base_urloverrides 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: denyblockingexec_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()¶
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¶
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:
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:
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 |