Test Workflows

Pro

Author 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 worth spelling out: 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:

PropertyTypeRole in a Workflow test
harness.ClientBackWaveClientCarries BuildWorkflow and EnqueueWorkflowAsync, the authoring surface
harness.MonitorBackWaveMonitorCarries 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 WorkflowBuilder returned by BuildWorkflow, add each member with a name and its parents, then hand the built definition to EnqueueWorkflowAsync. The name you give a member is both the handle other members depend on and the label the member carries in the graph view.

CheckoutWorkflowTests.cs
var harness = new BackWaveHarness(BackWaveJobs.CreateRegistry(), services);
 
var workflow = harness.Client.BuildWorkflow("checkout");
workflow.AddJob("charge", new ChargeCard(orderId));
workflow.AddJob("receipt", new SendReceipt(orderId), dependsOn: ["charge"]);
 
var workflowId = await harness.Client.EnqueueWorkflowAsync(workflow.Build());

Build() validates the graph client-side before the store is ever touched: it rejects a cycle, a dependsOn name that resolves to no member, a self-dependency, and an empty graph, throwing InvalidWorkflowException in each case. EnqueueWorkflowAsync then writes every member job plus the Workflow record atomically. Each member's Guid is assigned the instant you call AddJob, with no store round-trip, so you can capture ids by name with GetJobId for assertions before you have even enqueued.

AddJob'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.

CheckoutWorkflowTests.cs
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:

StatusCondition
RunningAny member is still non-terminal
FailedAll members terminal, and at least one is Dead-Lettered or Quarantined
CancelledAll members terminal, none failed, and at least one is Cancelled
SucceededEvery 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.

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.Members is a list of JobSnapshot, each with its State, so you assert a member's outcome directly off the graph read (never raw payload bytes).
  • view.Edges is the list of fixed structural WorkflowEdge(Parent, Child) pairs, recorded at enqueue and immutable for the Workflow's life.
CheckoutWorkflowTests.cs
var view = await harness.Monitor.GetWorkflowAsync(workflowId);
 
Assert.Equal(2, view!.Members.Count);
Assert.Contains(
    view.Members,
    m => m.JobId == workflow.GetJobId("receipt") && m.State == JobState.Succeeded);
Assert.Contains(
    new WorkflowEdge(workflow.GetJobId("charge"), workflow.GetJobId("receipt")),
    view.Edges);

GetJobId("receipt") resolves the id assigned to the node you added under that name, so assertions address members by their authored names instead of ids you captured 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.

WorkflowFailureTests.cs
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.

WorkflowStatusTests.cs
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 enqueue#

EnqueueWorkflowAsync does not hand a result enum back to the caller. Any outcome other than a clean accept (a duplicate Workflow id, a containment violation, an oversized payload, and the rest) is turned into a thrown InvalidOperationException. A test that means to assert a rejection wraps the call rather than inspecting a return value.

WorkflowRejectionTests.cs
await Assert.ThrowsAsync<InvalidOperationException>(
    () => harness.Client.EnqueueWorkflowAsync(duplicate.Build()).AsTask());

Graph-shape problems (cycles, dangling dependsOn names, an empty graph) surface earlier and differently: Build() throws InvalidWorkflowException before the store is touched at all, so those you assert on the Build() call, not the enqueue.

Testing a restart#

A Workflow Restart re-instantiates a Workflow's definition (every member's Wire Name, payload, Queue, and Dependency edges) as a brand-new Workflow with fresh job identities, optionally linked to the original by lineage. It always re-runs the whole graph from the start; it is not resume-from-failure. You test it exactly as you tested the original: produce the restarted definition, enqueue it, advance, and assert. The new WorkflowView carries a RestartedFrom pointing at the original id, which a lineage assertion 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 WorkflowBuilder 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.