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:
| Mode | How | When |
|---|---|---|
| Hermetic | fixture exists → replayed, milliseconds, no keys | The default. CI gate, every local run |
| Record | fixture missing (non-CI) → tools run live, responses captured | First run, and re-recording after intended behavior changes |
| Live | separate spec without fixture, sampled, rubric-asserted |
Rare, 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:
| Class | What | Check |
|---|---|---|
| Mechanical | wiring, data flow, schemas, documents | Exact assertions on result/path/document() under replay |
| Semantic | quality of LLM output | Structural assertions under replay; live sampled runs for quality |
| Effectful | external 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 |
| Interactive | HITL behavior | Scripted answers (+ failure() for failing children, TestClock for timeouts), parkView() for what the user sees |
Checklist
- Extract criteria (including failure modes); assign IDs.
- Write criterion-named scenarios as failing tests; review them with a human — this is the cheap moment to resolve ambiguity.
- Implement until the hermetic suite is green; unit-test tools with real logic via
testTool(). - Run
coverage()— close the gaps it names. - Record fixtures from good live runs; commit them.
- Hand off with the test report: every criterion visibly green, park views asserted for every human touchpoint.