Skip to main content
Most of the interesting work in an Output app happens inside LLM calls — and LLM output is non-deterministic by nature. The same prompt can produce different results every time. That’s why the primary quality tools for LLM-heavy apps are evaluators (LLM-as-a-judge) and humans reviewing trace files to annotate failure modes. Those are your first line of defense. But there’s still plenty of deterministic logic worth testing the traditional way: data transformations, branching logic, schema validation, retry loops, and the glue code that ties your steps together. This page covers how to write those unit tests. Every exported step, evaluator, and workflow is a callable async function. Import it and call it — no Temporal server needed.

Testing Steps

Import the step and call it with input that matches its inputSchema. The return value is validated against outputSchema automatically.
steps.spec.ts
If the input doesn’t match inputSchema or the return value doesn’t match outputSchema, it throws a ValidationError — see Error Handling.
This test calls the real step implementation, including any API calls it makes. For fast, isolated tests, mock the external dependencies — see Mocking Steps and Dependencies below.

Testing Evaluators

Same pattern. Import the evaluator and call it with input. The result is an EvaluationResult with value, confidence, and optional reasoning. Deterministic evaluators are especially good candidates for unit tests since they have predictable outputs:
evaluators.spec.ts
You can test LLM-based evaluators the same way — just know that each test run makes a real API call, so it costs money and it will be unpredictable. If your judge prompt is well-designed (temperature set, clear criteria — see Best Practices), the results should be somewhat consistent enough to assert against. Just keep these tests separate from your fast unit tests.

Testing Workflows

When a workflow runs outside Temporal (like in Vitest), it executes as a plain async function with a mock context. You’ll typically want to mock your steps so the workflow logic runs without real API calls — this is where deterministic testing shines. You’re testing your branching logic, retry loops, and data flow, not the LLM output.
workflow.spec.ts
With mocked steps, you’re testing that the workflow calls the right steps in the right order with the right data — and that it assembles the final output correctly. The LLM quality itself is handled by evaluators in production.

Mock Context

Outside Temporal, the framework injects a default mock context:
  • context.info.workflowId'test-workflow'
  • context.control.continueAsNew → no-op async function
  • context.control.isContinueAsNewSuggested()false
You can override these by passing a context in the second argument. This is useful when your workflow logic depends on the workflow ID or other context values:
The context override only applies outside Temporal. When running in production, the real Temporal context is used.

Mocking Steps and Dependencies

In workflow tests, mock your step modules so no real I/O happens:
If your workflow uses sleep or other utilities from @outputai/core, mock those too:

Testing Retry Logic

If your workflow has a generate-evaluate-retry loop, you can test the branching by controlling what the mocked evaluator returns:
workflow.spec.ts
This is the sweet spot for deterministic testing in an LLM app — you’re verifying that your workflow responds correctly to different evaluator outcomes without involving any actual LLM calls.

What to Test vs. What to Evaluate

The general rule: if the outcome is predictable, write a unit test. If it depends on LLM output, use evaluators and tracing instead.

Offline Evaluation with Datasets

Unit tests verify deterministic logic. But how do you catch regressions in LLM output quality across a set of known inputs? That’s what offline evaluation is for. The @outputai/evals package lets you run your workflow against saved datasets and check output quality using typed evaluators — without modifying your workflow code. You define evaluators with verify(), write dataset YAML files with expected inputs and ground truth, and run them via the CLI:
Evaluators can be deterministic (checking length, format, required fields) or use LLM judges for subjective quality. Results are reported as pass/partial/fail per dataset case, with a summary showing the overall acceptable rate. See the @outputai/evals package docs for the full guide on creating evaluators, writing datasets, and configuring eval workflows. See CLI Evaluation Commands for the command reference.