Test the request to start work separately from the work itself. A signup handler test should prove that it submits onboarding for the right user, without sending mail. A workflow test should exercise the process’s decisions. Replay and native retention need an engine test: neither a fake nor memory executes durable history.
The following are fragments inside an existing Test... function with
t *testing.T. They use arkoren.dev/core/service/workflow and the
onboarding definitions. Use the
confirmation-aware body for timeout
and signal tests.
Submission Tests
Create a fake driver and pass its ordinary client to the application code under test:
import "arkoren.dev/core/service/workflow/workflowtest"
fake := workflowtest.New()
client := workflow.NewClient(fake)
Here signup is your application function: it accepts a client, persists or
validates signup according to your application, and starts Onboard. Exercise it,
then assert the typed payload:
if err := signup(t.Context(), client, User{ID: 42}); err != nil {
t.Fatal(err)
}
fake.AssertStarted(t, Onboard, func(user User) bool {
return user.ID == 42
})
The assertion proves submission of that input, not that onboarding executed or the signup/database handoff was atomic. The fake never calls workflow or step bodies. Every workflow name is considered registered, so validate your real registry in separate tests. Runs stay Running until you call Complete, Fail, or Cancel.
Use the fake’s assertions for no starts, start counts, signals, and cancellation when those are the behavior under test. Failed driver calls are not recorded.
Scripted Results
If application code waits for a result, script the outcome before calling it:
fake.Complete(Onboard.Name(), "user-42", "confirmed")
Completing a run before Start stores a script; the later start returns a finished
run. Completing an already-started run releases its waiters. A repeated Start
still returns workflow.ErrAlreadyStarted with the existing handle.
To exercise an application error branch, script a stable error kind:
var ErrInvitationRevoked = workflow.NewError("onboarding.revoked", "invitation revoked")
fake.Fail(Onboard.Name(), "user-42", ErrInvitationRevoked)
Use a fresh fake or a separate run ID for this alternative. Fail uses the same
wire representation as real workflow failures, so arbitrary worker-side Go types
do not survive. Application code should match the kind with errors.Is.
fake.FailNext(err) fails the next driver operation; fake.FailWith(err) fails
every subsequent operation until cleared with nil. These model command/lookup
failures, not a failure returned by the workflow body. Distinguish the two in
your assertions.
Workflow Decisions
Use memory to execute the real workflow function, replacing its external dependencies with fakes. For onboarding, define SendWelcome against a fake mail sender so this test does not send real mail. Its handler should return promptly and honor cancellation.
Inside your test, synctest advances the process-local clock when all work is
blocked. The three-day confirmation deadline needs no wall-clock wait:
import (
"context"
"testing"
"testing/synctest"
"time"
"arkoren.dev/core/service/workflow/memoryworkflow"
)
synctest.Test(t, func(t *testing.T) {
registry, err := workflow.Handlers(Onboard, SendWelcome)
if err != nil {
t.Fatal(err)
}
driver := memoryworkflow.New(registry)
t.Cleanup(func() {
cleanup, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := driver.Stop(cleanup); err != nil {
t.Error(err)
}
})
client := workflow.NewClient(driver)
run, err := client.Start(t.Context(), Onboard, "user-42", User{ID: 42})
if err != nil {
t.Fatal(err)
}
out, err := run.Result(t.Context())
if err != nil || out != "expired" {
t.Fatalf("Result = %q, %v; want expired", out, err)
}
})
No confirmation is sent, so the wait expires and the workflow returns
"expired". To test confirmation instead, send EmailConfirmed after Start and
before Result, then assert that token. Add retry tests only for decisions that
belong to your workflow or step policy, with explicit counts on the fake service.
See Controlling Time.
Cleanup deadlines bound the Stop caller’s wait, not the lifetime of code that ignores cancellation. Workflow bodies and fake services must cooperate, and application-owned background work must be joined. A synctest bubble also cannot finish while its goroutines remain blocked indefinitely.
Engine Regressions
Memory never replays the workflow function, so a passing memory test cannot establish determinism. Review coordination code for clock, random, I/O, goroutine, and native-channel use, then test representative retained histories against the actual engine when deploying changes.
Match integration tests to the deployed server version and feature profile. Native retry precision/caps, delayed or queued cancellation retention, endpoint registration, startup rollback, and shutdown cleanup are distinct boundaries. A healthy native API proves none of them. The driver comparison identifies Temporal policy rejections and Restate’s operator-managed retention profile.
For joined children, wait until the engine acknowledges the join before ending the parent. Seeing the parent start does not prove it joined. Verify that an existing run finishes with its original input and without duplicate work, while a newly created child stops with its owner. Unsupported Child must reject before submission and leave independent runs untouched.
Driver Conformance
If your application supplies a custom driver, use workflowtest.Run to exercise
the portable contract. Build the suite’s registry inside the factory, not from
your onboarding definitions:
workflowtest.Run(t, func(t *testing.T) workflow.Driver {
driver := memoryworkflow.New(workflowtest.Registry(t))
t.Cleanup(func() {
cleanup, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := driver.Stop(cleanup); err != nil {
t.Error(err)
}
})
return driver
}, workflowtest.WithSynctest())
WithSynctest creates a driver inside each separate bubble and runs cases sequentially; cleanup registered on the supplied test runs before the bubble ends. Never use it for an external engine.
Without that option, the factory runs once with the parent test, and parallel
subtests share the driver and its dependencies. All shared fixtures must support
concurrent use. Register cleanup on the factory’s t so it runs after those
subtests; a deferred stop in the parent function runs too early.
Declare unsupported operations with WithoutChild, WithoutSpawn, or
WithoutImmediateStepRetries when applicable. These options assert rejection
before submission or affected-handler execution rather than silently skipping
the contract. Keep every suite definition registered. Conformance complements,
not replaces, deployed-engine replay and regression tests. See
Driver Conformance Suites and
Integration Tests.
For fake assertions and suite options, see the
workflowtest API reference.