Test Workflows
ProAuthor and drive a Pro Workflow through the harness exactly as production does, advance Virtual Time, then assert the derived Workflow status and the member graph through the Monitor API.
A Pro Workflow is the user-facing grouping and identity over a set of jobs wired together by Dependency edges: a name, a sortable id, a graph, and lifecycle operations over that graph. Testing one is not a new kind of test. You build the graph and enqueue it through the harness's Client, advance Virtual Time to run it, and assert through the harness's Monitor, the same moves you already use for a single job in Test Behavior Over Time. Two things are specific to a Workflow test: Workflow status is never stored, so you assert it as a derived value, and the member graph is its own read surface you can assert against directly.
Workflows are a BackWave Pro feature. The authoring and read methods this page uses are extension methods that light up on the base BackWaveClient and BackWaveMonitor the moment your test project references the BackWave.Pro package. There is no AddBackWavePro call and no extra registration for them to work in a test: referencing the package is the whole boundary. The license state governs the Pro license-warning behavior in a running host, never whether these methods run.
The harness surface a Workflow test touches#
BackWaveHarness exposes no Workflow-specific members. The Pro Workflow API is entirely extension methods on the base types, so a Workflow test only ever touches two of the harness's properties:
| Property | Type | Role in a Workflow test |
|---|---|---|
harness.Client | BackWaveClient | Carries Workflow(...) and the builder's EnqueueAsync, the authoring surface |
harness.Monitor | BackWaveMonitor | Carries GetWorkflowAsync and ListWorkflowsAsync, the assertion surface |
harness.Store is still there as the low-level escape hatch it is for any test, but you do not need it here. The ergonomic path to build and enqueue a Workflow from test code runs entirely through harness.Client.
Build and enqueue through the Client#
You author a Workflow with the fluent TypedWorkflowBuilder returned by Workflow(...), chain each step by its .NET type, and terminate the chain with EnqueueAsync. Each step is an ordinary [Job] payload record wearing the IWorkflowStep marker; a linear .Then(step) depends on the current frontier, and a fan-in step names its upstreams with after: [typeof(...)].
var harness = new BackWaveHarness(BackWaveJobs.CreateRegistry(), services);
var workflowId = await harness.Client.Workflow("checkout")
.Then(new ChargeCard(orderId))
.Then(new SendReceipt(orderId)) // depends on the frontier (ChargeCard)
.EnqueueAsync();.EnqueueAsync() validates the graph client-side before the store is ever touched: it rejects a cycle, an unresolved or ambiguous after: type, a duplicate step identity, and an empty graph, throwing InvalidWorkflowException in each case. It then writes every member job plus the Workflow record atomically and returns the Workflow's Guid. The builder assigns each member's Guid automatically and exposes the workflow's own id as WorkflowId before you enqueue.
.Then's mode argument controls how a member's parent edges release it, using the same two Dependency reaction modes as everywhere else: DependencyMode.OnSuccess (the default, where a Dead-Lettered parent cancels the child) and DependencyMode.OnAnyTerminal (where the child still runs once the parent reaches any terminal state). This is exactly the lever that decides whether a failing member drags a dependent into Cancelled, which in turn feeds the status projection below. See Dependencies for the full mode semantics.
Advance Virtual Time, then assert the derived status#
Time only moves when you advance it. Drive the Workflow the same way you drive any job graph, with AdvanceAsync or RunDueAsync, which run every member due now and cascade the dependency releases and cancellations that follow.
await harness.AdvanceAsync(TimeSpan.Zero); // drain everything due now, including the dependency cascade
var view = await harness.Monitor.GetWorkflowAsync(workflowId);
Assert.Equal(WorkflowStatus.Succeeded, view!.Status);GetWorkflowAsync returns a WorkflowView, or null when no Workflow with that id exists. The Status on it is the value a Workflow test asserts, and the one thing to internalize is that it is never a stored field. WorkflowStatus is a pure projection recomputed on every read from the members' current JobState, following a fixed precedence, first match wins:
| Status | Condition |
|---|---|
Running | Any member is still non-terminal |
Failed | All members terminal, and at least one is Dead-Lettered or Quarantined |
Cancelled | All members terminal, none failed, and at least one is Cancelled |
Succeeded | Every member Succeeded (including the vacuous empty case) |
Because failure dominates a Succeeded sibling, a Workflow whose charge member Dead-Letters while receipt was set to run only OnSuccess reads Failed, not a mix. And because the status is re-derived on every read and never cached, it can legitimately move backward: a fully-drained Succeeded Workflow reopens to Running the moment you append new live members to it.
One projection result trips up conditional-workflow tests specifically. If the graph you built contains an .If gate, the gate cancels the arm it did not take, so a member ends Cancelled on every healthy run and the whole-workflow Status reads Cancelled even though every step that ran Succeeded. Do not assert Succeeded on a conditional workflow; assert the member states you expect instead, as the next section shows.
Assert the member graph#
The WorkflowView also carries the graph itself, so a test can assert more than the aggregate status: who ran, in what state, and what the graph actually looked like after you built it.
view.Membersis a list ofJobSnapshot, each with itsWireNameandState, so you assert a member's outcome directly off the graph read (never raw payload bytes).view.Edgesis the list of fixed structuralWorkflowEdge(Parent, Child)pairs, recorded at enqueue and immutable for the Workflow's life.
Because members carry auto-assigned ids rather than authored names, address them by their Wire Name off the view.
var view = await harness.Monitor.GetWorkflowAsync(workflowId);
Assert.Equal(2, view!.Members.Count);
Assert.Contains(
view.Members,
m => m.WireName == "send-receipt" && m.State == JobState.Succeeded);
var chargeId = view.Members.Single(m => m.WireName == "charge-card").JobId;
var receiptId = view.Members.Single(m => m.WireName == "send-receipt").JobId;
Assert.Contains(new WorkflowEdge(chargeId, receiptId), view.Edges);Resolving a member's JobId from its Wire Name lets an edge assertion address members without capturing ids out of band. The structural WorkflowEdge list is distinct from the live gating edges that resolve away as parents terminate; it is the graph's fixed shape, which is exactly what a member-graph assertion should check.
To list every Workflow the harness holds, harness.Monitor.ListWorkflowsAsync() returns a lighter-weight WorkflowSnapshot per Workflow, oldest first, carrying the id, Name, CreatedAt, derived Status, MemberCount, and RestartedFrom, but no Members or Edges. Reach for GetWorkflowAsync when a test needs the graph.
Make the failure path deterministic#
To assert Failed or Cancelled you need a member to fail on purpose, not by chance. Inject a fake dependency through the DI IServiceProvider you hand the harness, and let the test flip it before enqueuing. Pair that with a one-attempt RetryPolicy (MaxAttempts = 1) on BackWaveHarnessOptions so a single AdvanceAsync(TimeSpan.Zero) drives the failing member straight to Dead-Lettered and its OnSuccess dependent to Cancelled in one pass.
public sealed class ChargeSwitch
{
public bool Fails { get; set; }
}
public sealed class ChargeCardHandler(ChargeSwitch charge) : IJobHandler<ChargeCard>
{
public Task HandleAsync(ChargeCard job, JobContext context, CancellationToken cancellationToken)
=> charge.Fails
? throw new InvalidOperationException("card declined")
: Task.CompletedTask;
}With ChargeSwitch.Fails set to true before enqueue, the built Workflow above drains to charge Dead-Lettered and receipt Cancelled, and view.Status reads Failed. This is the standard fake-injection pattern from Assert on What Happened, applied to a Workflow member.
Unit-test the precedence table with no harness#
Because the status is a pure function of member states, you can exercise the whole precedence table with plain JobState arrays, no harness, store, or clock involved. WorkflowStatusProjection.Project is the standalone projection behind WorkflowView.Status.
Assert.Equal(WorkflowStatus.Running, WorkflowStatusProjection.Project([JobState.Leased, JobState.DeadLettered]));
Assert.Equal(WorkflowStatus.Failed, WorkflowStatusProjection.Project([JobState.Succeeded, JobState.DeadLettered]));
Assert.Equal(WorkflowStatus.Cancelled, WorkflowStatusProjection.Project([JobState.Succeeded, JobState.Cancelled]));
Assert.Equal(WorkflowStatus.Succeeded, WorkflowStatusProjection.Project([JobState.Succeeded, JobState.Succeeded]));This is the cheapest way to pin the precedence rules (non-terminal beats failure, failure beats cancel, cancel beats succeeded, and an empty set reads Succeeded) as a focused unit test, leaving the harness-driven tests to prove the graph actually reaches those member states under Virtual Time.
Asserting a rejected build#
The builder validates the graph before the store is ever touched, so graph-shape problems, a cycle, an unresolved or ambiguous after: type, a duplicate step identity, an OnSuccess join spanning both arms of an .If, or an empty graph, throw InvalidWorkflowException synchronously at Build(). A test that means to assert one of these wraps the Build() call.
Assert.Throws<InvalidWorkflowException>(() =>
harness.Client.Workflow("bad")
.Then(new ChargeCard(orderId))
.Then(new ChargeCard(orderId)) // duplicate step identity
.Build());Store-level rejections (a duplicate Workflow id, a containment violation, an oversized payload, and the rest) are turned into a thrown InvalidOperationException from EnqueueAsync instead, so a test targeting one of those awaits the enqueue with Assert.ThrowsAsync<InvalidOperationException>.
Testing a restart#
Recovery is a full redo, not resume-from-failure: re-run the same graph from the start. You test it exactly as you tested the original: build the same shape again and enqueue it, advance, and assert. For the shape-preserving helper, Build() returns a WorkflowDefinition and WorkflowDefinition.RestartAsNew() mints a fresh definition with new job identities and a RestartedFrom pointing at the original id, which a lineage assertion on the new WorkflowView can check.
Where to go next#
- Workflows: the Workflow concept, its graph, and its lifecycle operations.
- Workflows API: the full Pro authoring and read surface, member by member.
- Build a Workflow: authoring a graph with the typed builder in production code.
- Dependencies: the edge semantics and the two reaction modes the status projection depends on.
- Test Behavior Over Time: advancing the clock and the harness fundamentals this page builds on.
- Assert on What Happened: fake injection and Monitor-based assertions applied to any job.
Found a problem on this page? Report an issue