# Test Workflows

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](/docs/testing/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](/licensing) 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(...)]`.

```csharp title="CheckoutWorkflowTests.cs" {3-6}
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](/docs/core-concepts/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.

```csharp title="CheckoutWorkflowTests.cs" {3}
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.Members` is a list of `JobSnapshot`, each with its `WireName` and `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.

Because members carry auto-assigned ids rather than authored names, address them by their Wire Name off the view.

```csharp title="CheckoutWorkflowTests.cs" {3,5,8}
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.

```csharp title="WorkflowFailureTests.cs" {10}
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](/docs/testing/asserting-execution), 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`.

```csharp title="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 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.

```csharp title="WorkflowRejectionTests.cs"
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](/docs/core-concepts/workflows): the Workflow concept, its graph, and its lifecycle operations.
- [Workflows API](/docs/reference/workflows-api): the full Pro authoring and read surface, member by member.
- [Build a Workflow](/docs/guides/build-a-workflow): authoring a graph with the typed builder in production code.
- [Dependencies](/docs/core-concepts/dependencies): the edge semantics and the two reaction modes the status projection depends on.
- [Test Behavior Over Time](/docs/testing/behavior-over-time): advancing the clock and the harness fundamentals this page builds on.
- [Assert on What Happened](/docs/testing/asserting-execution): fake injection and Monitor-based assertions applied to any job.
