Testing
For developers writing tests for their own workflows. It covers the in-process harness Dwarf ships so your test suite can run real flows with no database and no transport.
Dwarf ships an in-process test harness so you can exercise real workflows — scheduling, fan-out, subgraphs, interrupts, retries — with no database to set up and no transport to stand up.
The Two Pieces
engine.NewEngineUnderTest(name)constructs an engine wired for testing: an isolated SQLite in-memory database (per name, and per shard for multi-shard tests), auto-dropped, with migrations run atStartup— no DSN. It takes a plain name rather than atesting.TB, so a harness with no*testing.Tto hand over reaches it the same way a Go test does; passt.Name()from a test. You callStartup(ctx)yourself, so anySet*configuration goes in between, and you own teardown:defer eng.Shutdown(ctx). The name is the isolation key — give several engines the same name to share one database, or distinct names to isolate them. It logs to stderr at Error for CI clues (DWARF_TEST_LOG_LEVELoverrides —info/debugfor the flow-status play-by-play,silent/offto quiet it); a benchmark or fuzz target silences it outright withSetLogger(slog.New(slog.DiscardHandler)), since per-iteration logging would swamp the measurement.hosts.Muxis an in-memory implementation of theHostinterface. Register graphs and task functions on it, then register it withSetHost(mux); it implementsLoadGraphandExecuteTaskfrom those registrations.
func TestCheckout(t *testing.T) {
ctx := context.Background()
mux := hosts.NewMux()
g := workflow.NewGraph("Checkout")
g.SetURL("Reserve", "inventory.reserve")
g.SetURL("Charge", "billing.charge")
g.AddTransition("Reserve", "Charge")
g.AddTransition("Charge", workflow.End)
mux.HandleGraph("checkout", g)
mux.HandleTask("inventory.reserve", func(ctx context.Context, f *workflow.Flow) error {
f.SetBool("reserved", true)
return nil
})
mux.HandleTask("billing.charge", func(ctx context.Context, f *workflow.Flow) error {
f.SetString("receipt", "r-123")
return nil
})
eng := dwarf.NewEngineUnderTest(t.Name())
defer eng.Shutdown(ctx)
eng.SetHost(mux)
eng.Startup(ctx)
_, out, err := eng.Run(ctx, "checkout", map[string]any{"sku": "ABC"}, nil)
if err != nil {
t.Fatal(err)
}
if out.Status != workflow.StatusCompleted {
t.Fatalf("status %s", out.Status)
}
if receipt := out.State.GetString("receipt"); receipt != "r-123" {
t.Errorf("receipt %q", receipt)
}
}Mux.HandleGraph(workflowURL, graph) registers a graph under the URL you pass to Create or Run;
HandleTask(taskURL, handler) registers a task under the URL bound with SetURL. The handler signature is the same func(ctx, *workflow.Flow) error
you write in production.
Configuring the Test Engine
Apply any Set* settings between NewEngineUnderTest and Startup — workers, shards, time budget, the
priority range:
eng := dwarf.NewEngineUnderTest(t.Name())
defer eng.Shutdown(ctx)
eng.SetHost(mux)
eng.SetWorkers(1) // serialize dispatch to assert ordering
for i := 1; i <= 4; i++ {
eng.SetShard(engine.ShardSpec{Index: i, DSN: ""}) // each shard gets its own in-memory database
}
eng.Startup(ctx)SetWorkers(1) is a common trick for deterministically asserting dispatch order (e.g. fairness or
priority tests).
A closed flow reaches the archive about a minute after it closes, and only then does ListClosed show it or
Purge remove it. A test that waits for a flow to close should Await it rather than poll ListClosed, and read
its outcome with Snapshot or Await, which serve a closed flow at once.
Testing the Harder Paths
The harness drives every engine feature. A few patterns:
Interrupts / human-in-the-loop. A task calls Interrupt; assert the flow parks, then Resume:
flowKey, _ := eng.Create(ctx, "approval", state, nil)
out, _ := eng.Await(ctx, flowKey)
if out.Status != workflow.StatusInterrupted {
t.Fatalf("status %s", out.Status)
}
// out.InterruptPayload holds what the task asked for
eng.Resume(ctx, flowKey, map[string]any{"approved": true})
out, _ = eng.Await(ctx, flowKey)
if out.Status != workflow.StatusCompleted {
t.Fatalf("status %s", out.Status)
}Transient failures. A task that returns nil after arming flow.Retry re-runs after backoff; assert
the eventual outcome once the task stops failing:
mux.HandleTask("flaky", func(ctx context.Context, f *workflow.Flow) error {
if firstFewCalls() {
if f.Retry(time.Millisecond, 2.0, 10*time.Millisecond, time.Minute) {
return nil
}
}
return nil
})Because hosts.Mux returns synchronously, it produces a far tighter, more adversarial timing environment
than a real network — which makes it excellent at surfacing concurrency bugs in retries, fan-in, and
crash recovery.
Inspecting execution. eng.History(ctx, flowKey) returns the full step record (including nested
subgraph history); workflow.NewFlowRenderer(steps).Render() turns it into a Mermaid diagram of the execution DAG
for eyeballing what ran in what order.
Multi-Replica Tests
To test cross-replica behavior, stand up two engines against the same test database. That is all a fleet is — they coordinate through the database:
mux1, mux2 := hosts.NewMux(), hosts.NewMux()
// register the same graphs/tasks on both...
eng1 := engine.NewEngineUnderTest(t.Name()) // one name → one shared isolated database
defer eng1.Shutdown(ctx)
eng1.SetHost(mux1)
eng2 := engine.NewEngineUnderTest(t.Name())
defer eng2.Shutdown(ctx)
eng2.SetHost(mux2)
eng1.Startup(ctx)
eng2.Startup(ctx)Both engines share one isolated database because they were given the same name — no explicit DSN needed. Note
that sharing a database is exactly what makes them count each other as peers, so two engines meant to be
separate deployments need distinct names. This is how the engine’s own cross-replica Await and step-recovery tests
are written — see the fixtures package in the repository for worked examples.
Where Examples Live
The repository’s fixture suite contains around a hundred end-to-end workflow tests built exactly this way — from
basicflow up through subgraphflow, dynamicfanoutflow, and fairnessflow. They’re the canonical,
runnable reference for every feature.
Next: Patterns.