@arizeai/phoenix-client/vitest and @arizeai/phoenix-client/jest let you write evaluations as ordinary test suites — using the describe / test API you already know — while automatically recording every run to Phoenix as a versioned experiment. LLM calls instrumented with OpenInference appear as child spans of each test’s task span, giving you full trace visibility alongside your eval results.
Each describe() block becomes a Phoenix dataset and a new experiment. Each test() becomes a dataset example plus a recorded experiment run. The assertion outcome is captured as a pass boolean annotation. Anything you log via logOutput(), logAnnotation(), evaluate(), or traceEvaluator() lands on the run and shows up in the Phoenix UI.
When to use Vitest / Jest
Phoenix also offersrunExperiment for evaluating a whole dataset with one task and a shared set of evaluators. Reach for the test-runner integration instead when:
- Each case needs different logic.
runExperimentruns one task and the same evaluators across every example. When subsets of your system need different inputs, setup, or metrics, separatetest()cases are easier to express than one branching task. - You want to assert hard expectations. A failed
expect()fails the test, fails the run, and fails CI — and acceptance criteria let you gate the whole suite on aggregate scores. - You already use Vitest or Jest. Add Phoenix tracking to a suite you already run, keeping
test.each,.only/.skip, mocks, and watch mode.
runExperiment — it parallelizes the work and keeps each experiment and its dataset easy to manage.
How it works
acceptanceCriteria can fail CI when aggregate metrics drop below a threshold — for example, when average correctness falls below 0.8 or more than 10% of runs produce invalid SQL.
Two kinds of checks: invariants vs. signals
Before reaching for a helper, decide what each check is. Evals differ from ordinary tests because an LLM is in the loop: outputs are non-deterministic, some can’t be graded by code at all, and pass/fail is often too blunt — quality lives on a spectrum. That pushes every check into one of two buckets, and this runner gives each its own home.- Hard invariants. There is exactly one acceptable behavior, and ordinary code can verify it — a required refusal, valid JSON, a tool that must be called. These belong in a per-case
expect(). A failed assertion fails the test, fails the run, and turns CI red. - Quality signals. There’s no single correct string, only better and worse answers — helpfulness, groundedness, tone. You score these (often with an LLM judge) and record them with
logAnnotation()/evaluate(), then gate them at the suite level withacceptanceCriteria. A single weak result shouldn’t fail the build, so the gate runs on the aggregate (e.g. ≥70% helpful), not on every case.
expect() for invariants, acceptanceCriteria for signals. assert the smallest behavior you’d be embarrassed to ship broken, and let everything fuzzier ride along as a tracked signal.
Installation
Requires
@arizeai/phoenix-client>=6.11.1. The testing API is in beta and may change in a future release.@arizeai/phoenix-client. No separate package is needed.
Vitest setup
Create a dedicated config file so eval suites don’t get swept into your normal unit-test run:includekeeps eval suites in*.eval.tsfiles separate from unit tests.reporterskeeps Vitest’s default output and adds the Phoenix summary block.setupFilesloadsPHOENIX_HOST,PHOENIX_API_KEY, and other env vars from.env.testTimeoutis bumped because LLM calls can be slow.
The
jsdom test environment is not supported. Either omit environment or set it to "node".package.json:
vitest run (not watch mode) is intentional — evaluations run once per CI job.
Jest setup
Create a separate config file for eval suites:The
jsdom test environment is not supported. Either omit testEnvironment or set it to "node". For TypeScript or ESM projects, use ts-jest or @swc/jest per Jest’s docs.package.json:
Your first eval suite
The reference output can be given under any one of three interchangeable keys —
expected, reference, or output (at most one). All three become the dataset example’s reference output and arrive as expected in the test body, so you can match whichever vocabulary your team or migration source uses:it is the canonical alias for test; the two are identical.Testing many examples with test.each
For larger datasets, test.each keeps the suite concise:
%i (index), %s (stringified), and %j (JSON). Without a placeholder the row index is appended automatically.
Running against an existing Phoenix dataset
Instead of defining examples inline, you can pull them from a dataset that already lives in Phoenix — curated in the UI, captured from production traces, or built by a previous run — and fan out over them withtest.each. Because test.each accepts any array, this works with both Vitest and Jest.
Fetch the examples at module load with getDatasetExamples, map each to a row (expected carries the example’s reference output), and pass the rows to test.each. Preserving each example’s id upserts runs back onto the same examples so experiments line up across runs.
The example loads the dataset with top-level
await, which requires ESM — native in Vitest, and in Jest when configured for ESM. On CommonJS Jest, fetch the examples in an async bootstrap and reference them once resolved instead.Logging outputs and annotations
logOutput(value)
Records the system-under-test’s output for the run. Everything you pass here shows up as ExperimentRun.output in Phoenix.
logAnnotation(annotation)
Records a named score, label, or explanation on the run. Use this for metrics you compute inline.
Annotation shape:
evaluate(evaluator, params?)
Runs an evaluator object and records its result as an annotation linked to an evaluator trace. This is the recommended approach when you want full trace visibility into the evaluator’s LLM calls.
params is omitted, Phoenix supplies the current test’s input, recorded output, expected, metadata, and task traceId automatically.
traceEvaluator(fn, options?)
When you’d rather grade with a plain inline function than build an Evaluator object, wrap it with traceEvaluator. The wrapped function runs inside its own evaluator span — so an LLM-as-judge call is traced separately from the test’s task — and if it returns an { name, score }-shaped value, that result is captured as an annotation automatically.
"evaluator"; override it with { name }. Unlike evaluate(), traceEvaluator() passes through whatever arguments you give it rather than auto-supplying the test’s input / output / expected.
evaluate(), traceEvaluator(), and any annotation logged through them are recorded under a dedicated evaluator span, so the judge’s LLM calls never clutter the task trace and each annotation links straight to the trace that produced it. Click an annotation in the Phoenix UI to open its evaluator trace.LLM-as-a-judge: scoring quality signals
When a check is a quality signal — accuracy, helpfulness, tone — there’s no single correct string to assert against. Hand the judging to another model withcreateClassificationEvaluator, which emits a label mapped to a numeric score plus an explanation. The verdict is recorded as an annotation under a linked evaluator span — without an expect() — so it trends in Phoenix and a single weak answer doesn’t break the build. Pass the judge only what it needs to grade (here the question and response) as the second argument to px.evaluate(), matching the template variables — no inputMapping required.
Consider a support bot with two jobs that map cleanly onto the two kinds of checks. For a question it can answer, the reply should be helpful — a quality signal, since a good answer can be phrased many ways. For an off-topic question, it should decline with a fixed line — a hard invariant, since exactly one output is acceptable. So we judge helpfulness on the answerable cases (gated by acceptanceCriteria) and hard-assert the refusal:
expect() (invariant), while helpfulness and latency become acceptanceCriteria (signals). The suite still fails CI if quality drops across the board — just not on a single unlucky generation.
Acceptance criteria — CI gates on aggregate scores
Acceptance criteria let you fail CI when a quality metric drops across the full suite. They run after all tests, so every case still executes and the reporter prints the full scorecard before failing — you see every regression in one run, not just the first.Criterion fields
Repetitions
Run a test (or a whole suite) multiple times to measure non-determinism. Each repetition is a separate experiment run against the same dataset example, and the Phoenix compare view lines them up side by side.repetitions → suite repetitions → PHOENIX_TEST_REPETITIONS env var → 1.
Focusing and skipping tests
px.test and px.describe carry the same .only and .skip modifiers as your runner, so you can iterate on one case without running the whole suite:
.only) tests run while their siblings in the same file are skipped — focus is file-scoped, so suites in other files still run. Skipped tests are never recorded: no dataset example and no experiment run are created for them. To track a case locally without recording it, use dry-run mode instead of .skip.
Dry-run mode
Execute test bodies locally without creating anything in Phoenix — useful for iterating on prompts and evaluators:Suite and test configuration
Pass a config object as the third argument todescribe() to control how the whole suite syncs to Phoenix:
Individual cases (and
test.each rows) take their own fields alongside input and the reference output:

