Skip to main content
@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 offers runExperiment 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. runExperiment runs one task and the same evaluators across every example. When subsets of your system need different inputs, setup, or metrics, separate test() 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.
For large, homogeneous datasets where every example is scored the same way, prefer runExperiment — it parallelizes the work and keeps each experiment and its dataset easy to manage.

How it works

Suite-level 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 with acceptanceCriteria. A single weak result shouldn’t fail the build, so the gate runs on the aggregate (e.g. ≥70% helpful), not on every case.
The split is the real first move: per-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.
The Vitest and Jest entrypoints are bundled in @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:
  • include keeps eval suites in *.eval.ts files separate from unit tests.
  • reporters keeps Vitest’s default output and adds the Phoenix summary block.
  • setupFiles loads PHOENIX_HOST, PHOENIX_API_KEY, and other env vars from .env.
  • testTimeout is bumped because LLM calls can be slow.
The jsdom test environment is not supported. Either omit environment or set it to "node".
Add a script to 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.
Add a script to package.json:

Your first eval suite

Run it:
On first run, Phoenix creates the dataset and experiment. Subsequent runs add new experiments to the same dataset so you can compare quality over time.
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:
The name template supports %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 with test.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.
Pass splits: ["regression"] (or a versionId) to getDatasetExamples to evaluate only a slice or a pinned version of the dataset.
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.
The full 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.
If 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.
The annotation name defaults to the function’s name, falling back to "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 with createClassificationEvaluator, 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:
This is the whole pattern: the refusal stays a per-case 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

passFn receives the full annotation object — score, label, explanation, metadata — so you can gate on any combination. For example, a.label === "correct" && a.score >= 0.8 or a.score >= 0.5 && a.score <= 0.9.

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.
Resolution order: per-test repetitions → suite repetitionsPHOENIX_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:
Focused (.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:
The reporter still prints a local summary even in dry-run mode.

Suite and test configuration

Pass a config object as the third argument to describe() 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:

Environment variables

Gating CI with GitHub Actions

The test runner’s exit code is your CI gate. A failed assertion — or a failed acceptance criterion — exits with a non-zero code and fails the job.

Complete example

A full text-to-SQL eval suite with inline evaluators, hoisted annotations, and acceptance criteria:
Run it locally without recording:
Then in CI, enable Phoenix and watch scores accumulate across experiments:

Module map