Skip to Content
DocumentationBuildTesting Methodology

Testing Methodology

Testing documents the tools — runWorkflow, replay fixtures, scripted answers, parkView(). This page documents the method: how to organize those tools so a workflow’s test suite is a faithful, checkable translation of its acceptance criteria. This matters doubly when the workflow author is an AI agent: for an agent, tests are perception — the suite is how it knows it is done, and how you know it understood the task. Everything here is convention over plain vitest; there is no scenario DSL to learn.

Criteria become scenarios, scenarios become tests

Start from the acceptance criteria — stated ones, plus the unstated ones every task carries (error paths, rejection paths, the branch nobody mentioned). Give each a short ID and expand it into scenarios: happy path, every branch, every park, every failure mode. Each scenario is one it() whose name starts with the criterion ID:

describe('ExpenseApprovalWorkflow — acceptance', () => { it('C1: expenses under €100 auto-approve without human interaction', async () => { const run = await runWorkflow( ExpenseApprovalWorkflow, { amount: 40 }, { fixture: rec('small-expense.json'), }, ); expect(run.status).toBe('completed'); expect(run.path).not.toContain('managerDecision'); // never parked expect(run.result).toMatchObject({ approved: true, auto: true }); }); it('C2: expenses of €100+ park for the manager, amount visible', async () => { const run = await runWorkflow( ExpenseApprovalWorkflow, { amount: 250 }, { fixture: rec('large-expense.json'), }, ); expect(run.status).toBe('waiting'); const view = run.parkView(); expect(view?.widget).toBe('confirm-prompt'); expect(view?.content).toMatchObject({ question: expect.stringContaining('€250') }); }); it("C3: rejection records the manager's reason", async () => { const run = await runWorkflow( ExpenseApprovalWorkflow, { amount: 250 }, { fixture: rec('large-expense.json'), answers: { managerDecision: { approved: false, reason: 'no receipt' } }, }, ); expect(run.result).toMatchObject({ approved: false, reason: 'no receipt' }); }); });

This file is the contract. A reviewer reads the it() names top to bottom and confirms the task was understood — before or after implementation, without deciphering test internals. Write the scenarios first, as failing tests, and let the workflow’s design follow from what they need to script: every LLM call and external effect ends up behind a seam (replayTools, config) because the scenarios demand it.

Assert outcomes, not mechanics: result, documents, where the run parked, what the user would see (parkView()). Avoid asserting internal call sequences — those tests break on refactors that change nothing observable. run.trace is for debugging a divergence, not for routine assertions.

The criteria matrix is a test-report query

With criterion IDs in test names, the criterion → scenario → result matrix needs no tooling:

  • The test report is the matrix — every C*: line shows pass/fail, grouped by workflow.
  • “Is every criterion covered?” is a grep: a criterion ID that appears in the task but not in any test name is uncovered.
  • “Is every path covered?” is a query: coverage(runs, WorkflowClass) reports declared transitions and parks no scenario exercised — criteria coverage and state-machine coverage are different questions; check both.

One scenario, three run modes

A scenario written against fixture runs in all three modes without changes:

ModeHowWhen
Hermeticfixture exists → replayed, milliseconds, no keysThe default. CI gate, every local run
Recordfixture missing (non-CI) → tools run live, responses capturedFirst run, and re-recording after intended behavior changes
Liveseparate spec without fixture, sampled, rubric-assertedRare, deliberate — semantic quality regression checks

Drift protection keeps the modes honest: replayed runs assert recorded args/config against the live call and validate envelopes against resultSchema — a stale fixture fails loudly instead of proving nothing.

Pick the check type by determinism class

A workflow’s correctness decomposes into classes, each with a different right kind of check — blurring them into one integration test produces something flaky, slow, and vague at once:

ClassWhatCheck
Mechanicalwiring, data flow, schemas, documentsExact assertions on result/path/document() under replay
Semanticquality of LLM outputStructural assertions under replay; live sampled runs for quality
Effectfulexternal writes (git push, email)Replay inside the suite — tools marked effects: 'external' never run live in the inner loop; contract checks against the real service shape
InteractiveHITL behaviorScripted answers (+ failure() for failing children, TestClock for timeouts), parkView() for what the user sees

Checklist

  1. Extract criteria (including failure modes); assign IDs.
  2. Write criterion-named scenarios as failing tests; review them with a human — this is the cheap moment to resolve ambiguity.
  3. Implement until the hermetic suite is green; unit-test tools with real logic via testTool().
  4. Run coverage() — close the gaps it names.
  5. Record fixtures from good live runs; commit them.
  6. Hand off with the test report: every criterion visibly green, park views asserted for every human touchpoint.
Last updated on