Testing guide
Eventboat’s testing philosophy: every reliability property is a named,
retrievable test; every externally visible surface (help screens, plugin
schemas) is pinned by a golden file; and anything that claims to work
against the real world (Kafka, long runs) is proven against the real thing
in CI. The full suite is go test ./...; CI runs it with -race.
The test taxonomy
| Layer | Where | What it proves |
|---|---|---|
| Unit tests | beside the code (*_test.go per package) | package-level behavior: loader rules, schema generation, host evaluation, store semantics |
| Engine invariants | internal/engine/invariants_test.go | the eight §6.2 reliability invariants, one test each |
| Engine behavior | internal/engine/*_test.go | delivery, fan-out, split, transform plugins, wasm, CESQL, admission pooling |
| Persistence/recovery | internal/engine/commit_persist_test.go, retention_test.go, internal/store | flush ordering, retention bounds, crash replay |
| Golden tests | internal/cli/testdata/help/, internal/registry/builtin/testdata/schemas/ | CLI help screens; generated plugin JSON Schemas |
| Contract tests | internal/testrun + examples/*/tests/ | pipeline behavior through the real engine via suite YAML |
| Integration | internal/inttests/kafka, internal/inttests/soak | real broker roundtrips; long-run stability (env-gated, need Docker) |
| Benchmarks | internal/engine, internal/lang/*, internal/wasmhost | performance gates and the Starlark-vs-WASM comparison |
| Acceptance | internal/cli/examples_test.go, acceptance_test.go (root), internal/rpcplugin/acceptance_test.go, internal/cli/agent_loop_test.go | every example verifies and passes its suites; the custom-build binary links pkg/plugin plugins and runs them; the third-party gRPC plugin builds and runs; the full agent loop over MCP stdio |
The eight engine invariants
internal/engine/invariants_test.go pins redesign-v3.md §6.2 — these names
are load-bearing; do not rename them:
TestInvariant_SpoolBeforeVisible— a failing spool append refuses the message; it never reaches a sink.TestInvariant_CheckpointAdvancesOnlyAfterCommit— the checkpoint trails committed messages.TestInvariant_Kill9ReplayReplaysAllUncommitted— crash recovery replays a superset of the uncommitted set.TestInvariant_DeadLetterWriteFailureBlocksCommit— a failing dead-letter write never drops the message.TestInvariant_RequiredFalseFailureDoesNotBlockSiblingBranches— optional edge failure isolates to its branch.TestInvariant_RedeliveryKeepsMessageIdStable— duplicate delivery keepsmeta.message_id, so idempotent sinks can dedup.TestInvariant_CursorWatermarkNeverExceedsCommitted— pull-source watermarks only advance over committed messages.TestInvariant_BranchIsolation— fan-out siblings share the underlyingmsg.Decoded/msg.Metamaps, so a transform mutating them in place (the script binding’s lazyremove(), pre-fix) must never be able to race a sibling branch’s encode: the copy-on-write binding isolates the branches, and the process must survive a wide-map fan-out under-race.
The -race policy: the engine and store packages must stay race-clean —
CI runs go test -race ./... on every push. If you add concurrency to either
package, run the race detector locally first; a data race here is a correctness
bug in the durability model, not a hygiene issue.
Golden tests
Three golden surfaces, each with its own regenerate flag:
- CLI help snapshots:
internal/cli/testdata/help/*.txtpin every verb’s help screen. After changing a flag or usage string:go test ./internal/cli -updateand commit the diff. - Plugin schema goldens:
internal/registry/builtin/testdata/schemas/*.jsonpin the JSON Schema generated from every builtin config struct. After changing a config struct:go test ./internal/registry/builtin -update-schemas. The test failure text tells you the flag. - Protobuf descriptor golden:
internal/registry/builtin/testdata/example.descr, regenerated with-update-descr(the protobuf codec test’s fixture; no protoc involved).
A golden diff in a PR must be reviewable line by line — that is the point.
Integration tests (need Docker)
Both suites skip silently without their env var; CI sets it:
EVENTBOAT_SOAK_DURATION bounds the soak run (CI default 25m; nightly plus
manual dispatch via .github/workflows/soak.yml). The wasm tests optionally
honor EVENTBOAT_WASM_GUEST — CI rebuilds the guest from source and points
the env at the fresh artifact; locally the committed .wasm under
internal/wasmhost/testdata/ is used, so plain go test ./... needs no
wasm toolchain.
Benchmarks
scripts/bench-gate.sh is a loose order-of-magnitude gate, not a noise
gate — limits sit ~5–15x above the reference dev machine (i5-14600KF) to
absorb shared-runner variance:
| Benchmark | Package | Gate |
|---|---|---|
BenchmarkPredicateEval | internal/lang/celhost | ≤ 5000 ns/op |
BenchmarkSimpleScript | internal/lang/starhost | ≤ 20000 ns/op |
BenchmarkContainerReadOnly | internal/lang/starhost | ≤ 20000 ns/op |
BenchmarkCommitThroughput/mem | internal/engine | ≤ 100000 ns/op (the 100µs gate) |
It covers the per-message hot paths (predicate eval, script exec, commit
throughput against the memory store) and deliberately does not cover
WASM (runner variance too high — BenchmarkHeavyTransform in
internal/wasmhost stays informational) or SQLite-backed throughput. What a
gate failure means: an algorithmic regression landed; profile before
optimizing.
testkit and testrun
internal/testkit provides the deterministic primitives engine and contract
tests build on:
ManualSource— a source whoseEmitblocks until the engine accepted the message (spooled + dispatched), making tests deterministic;Commitrecords the cursor watermark like a real pull source would.CaptureSink/Recorder— capture delivered messages;DiscardSink;FlakySink— fault-inject sink writes by attempt count.StoreWrapper— hookAppendSpool/WriteDeadLetter/SetCheckpointto inject store faults.FixedClock/CounterID— frozen clock and deterministic IDs.RegisterFakeTransform/TransformFunc— register a function as a transform plugin with an accept-anything schema (see Plugins).
internal/testrun is the contract-test runner: it builds the pipeline IR,
runs each case against a real engine with an ephemeral memory store,
fixed clock, capture-wrapped sinks (the plugin sink never touches the real
world), injects at the named node, waits for commit, and evaluates
expectations (in-order subset matching on payload.*/meta.* dotted paths,
DLQ count/reason). Job pipelines run with sources disabled so injections at
the pull node are deterministic.
The suite format
The project convention is <root>/pipeline.yaml + <root>/tests/<suite>.yaml
(+ tests/fixtures/); the suite references its pipeline as
pipeline: ../pipeline.yaml (path resolution is contained within the suite
root — an escaping path is an error):
eventboat test <path> consumes it: a file recurses (.yaml files without
a top-level suite: key are skipped and counted; unreadable/unparseable
YAML is a hard error, ruling D3). Exit codes: failing cases → 1; a directory
with no valid suites but broken files → 2 (ruling D4).
Practical recipes
Run the fast loop (seconds, no Docker):
Run everything locally before a PR:
Add a regression test for a delivery bug: reproduce with the smallest
pipeline YAML (see invYAML in invariants_test.go for the minimal
shape), drive it with testkit.ManualSource + CaptureSink +
StoreWrapper fault hooks, assert on Recorder.Captured() or the store’s
dead letters. If it touches commit/checkpoint ordering, check whether one of
the eight invariants already covers the class — extend that test’s scenario
instead of asserting something weaker.
Add a contract test for a user-visible pipeline behavior: write the
suite under examples/<pipeline>/tests/, fixtures under tests/fixtures/,
and rely on the CI examples gate (internal/cli/examples_test.go) to keep
it green.