pytest tests — parametrized, marked, and run just like any other test — and Phoenix records each case as an experiment run so you can track quality over time.
When a test passes, Phoenix records pass=True. When it fails, pass=False. Because the plugin hooks into pytest’s own exit code, your existing CI gate works without any extra configuration.
When to use the pytest plugin
Phoenix also offersrun_experiment for evaluating a whole dataset with a single task and a shared set of evaluators. Reach for the pytest plugin instead when:
- Each case needs different logic.
run_experimentruns one task and the same evaluators across every example. When subsets of your system need different inputs, setup, or metrics, separate test functions are easier to express than one branching task. - You want to assert hard expectations. Tests turn an eval into a binary gate: a failed
assertfails the pytest item, fails the run, and fails CI — exactly like any other broken test. - You already use pytest. Add Phoenix tracking to an existing suite without changing how you run it, and keep
pytestfeatures like fixtures,parametrize,-kfiltering,pytest-xdist, andpytest-asyncio.
run_experiment — it parallelizes the work and keeps each experiment and its dataset easy to manage.
How it works
Each marked test suite maps to a Phoenix dataset. Each parametrized test case maps to a dataset example. Each run of the suite creates a new experiment on that dataset. The assertion outcome — did the test pass or fail? — becomes apass annotation on the experiment run. Any additional scores you log become their own named annotations.
Two kinds of checks: invariants vs. signals
Before reaching for a single 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 you treat them differently.- 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
assert. A failed assertion recordspass=Falseand turns CI red, exactly like any unit test. If the invariant breaks, the build breaks. - Quality signals. There’s no single correct string, only better and worse answers — helpfulness, groundedness, tone. You score these (often with an LLM judge) instead of asserting on them, then watch the trend in Phoenix. A single weak result shouldn’t fail the build, because some variance is the nature of the model. Log them with
log_evaluation()/evaluate()and let them accumulate as annotations; gate on the aggregate trend separately rather than on every case.
assert the smallest behavior you’d be embarrassed to ship broken, and score the fuzzier stuff as a signal.
Installation
Requires
arize-phoenix-client>=2.10.0 — the version that introduced the @pytest.mark.phoenix plugin.arize-phoenix-client. Install it with the pytest extra:
arize-phoenix-evals, add that extra too:
conftest.py setup required.
Your first eval suite
Here is a minimal example that evaluates a question-answering function:ids you supply to parametrize are important: they give each case a stable identity. Re-running the suite maps each case back to the same dataset example, so runs accumulate as experiments over a fixed set of examples rather than creating duplicates.
The @pytest.mark.phoenix marker
The marker is what tells the plugin which tests to record. Tests without it run normally and are invisible to Phoenix.
Dataset naming
When you omitdataset=, the plugin uses the test file’s path relative to your project root — for example tests/evals/test_sql — so tests in different files become separate datasets and two files that share a basename never collide.
The full precedence order for dataset names is:
PHOENIX_TEST_DATASETenvironment variable (highest — overrides everything)phoenix_datasetinpytest.ini- The marker’s
dataset=keyword argument - The file path (default)
Logging outputs and evaluations
log_output(value)
Records the system-under-test’s output for the current run. Pass anything JSON-serializable — a string, dict, or list. Because pytest warns when a test returns a non-None value, you pass the output to this helper rather than returning it.
log_evaluation(name, score, label?, explanation?)
Records a named score on the current run. Use this to attach any metric you compute inline:
evaluate(evaluator, **kwargs)
Runs an evaluator callable and records its result as an annotation. The function returns the evaluator’s result, so you can assert on it to gate the individual test:
evaluate() records pass=False and fails the pytest item — making the evaluator score a CI gate.
Every score from
log_evaluation(), evaluate(), and hoisted marker evaluators is wrapped in its own evaluator span, so an LLM-as-judge call is traced separately from the test’s task and the annotation links straight to the trace that produced it. You don’t need a separate “trace feedback” context — the separation is automatic. Click any annotation in the Phoenix UI to open its evaluator trace.Using pre-built Phoenix evaluators
Evaluators fromarize-phoenix-evals work directly with evaluate() and as hoisted evaluators on the marker:
run_experiment, so an evaluator you wrote for one works identically under the other. Arguments are bound by parameter name:
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 withcreate_classifier, which emits a label mapped to a numeric score plus an explanation. Pass it to evaluate() and the verdict is recorded as its own annotation under a linked evaluator span — without asserting on it, so a single weak answer trends in Phoenix instead of breaking the build. The judge reads only what it needs to grade — here the question and the bot’s response — so the kwargs you pass to evaluate() are exactly the template variables.
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 a dozen 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 and hard-assert the refusal:
assert pins the invariant. To gate CI on a judge anyway — when an invariant genuinely needs an LLM to verify it — capture the result and assert on it: result = evaluate(judge, ...); assert result["score"] == 1.0. Use that sparingly, since it makes a non-deterministic score a hard gate.
Repetitions
Run each case more than once to measure non-determinism in LLM outputs. Each repetition is a separate pytest item — visible to-k, pytest-xdist, and your IDE — and a separate experiment run in Phoenix. The Phoenix compare view lines them up for you.
repetitions: per-test marker argument → PHOENIX_TEST_REPETITIONS env var → 1.
Environment variables
The plugin is configured entirely through environment variables, so the same suite can behave differently in local development and in CI without code changes.
To iterate locally without recording anything:
Running in parallel with pytest-xdist
The plugin supportspytest -n auto. The controller creates the dataset and experiment once and distributes their IDs to workers, which record runs concurrently. Exactly one experiment is created regardless of worker count.
Works with the rest of pytest
The marker is designed to stay out of your way — the tools you already use keep working:- Async tests.
async deftests run unchanged underpytest-asyncio(oranyio). Inlineevaluate()works inside an async test, including with async evaluators — the result is recorded the same way as a sync one. - Fixtures and
parametrize. Use them as normal. Eachparametrizecase becomes its own dataset example; give cases stableidsso reruns map back to the same example. - Focusing and skipping. Filter with
-k, select markers with-m, or skip a case with@pytest.mark.skip/pytest.skip(). Skipped cases are simply not recorded; a filtered run only appends to the dataset (see How the dataset stays in sync). - Watch mode.
pytest-watch(ptw) re-runs on save. Pair it withPHOENIX_TEST_TRACKING=0while iterating so you don’t create an experiment on every keystroke.
Configuring the dataset name in pytest.ini
To pin a dataset name for a whole project without editing tests or exporting an env var, set the phoenix_dataset ini option:
PHOENIX_TEST_DATASET still takes precedence over the ini option, which in turn takes precedence over the marker’s dataset= (see Dataset naming).
How the dataset stays in sync
On a full run (no path filter), the plugin updates the dataset to match exactly the collected cases, pruning examples for tests that no longer exist. On a partial run (with-k, -m, a file, or a ::node filter), the plugin only appends, leaving unselected examples in place. This prevents pytest tests/evals/test_sql.py from deleting the rest of your dataset.
Gating CI with GitHub Actions
No additional configuration is needed to use pytest as a CI gate: the pytest exit code is1 when any test fails, and 0 when all pass. A regression in LLM quality fails the job exactly like a broken import.

