Result formats
evalship reads three result formats. Each becomes the same case record: a suite, a key, pass or fail, and optionally a score, threshold, duration, cost, tokens, input, output, failure message, file and targets.
| Format | Typical source | File |
|---|---|---|
| JUnit XML | pytest, Vitest, Jest (jest-junit), RSpec (rspec_junit_formatter), go-junit-report | .xml |
| promptfoo JSON | promptfoo eval -o results.json |
.json |
| evalship JSON | Hand-rolled evals, or any framework you can write JSON from | .json |
Auto-detection
evalship detects the format from the content, not the file name:
- Content that starts with
<is JUnit XML. - A JSON object with a
casesarray is evalship JSON. - A JSON object with
evalIdor aresults.resultsarray is promptfoo JSON.
Anything else is skipped, so other .xml and .json files in the same artifact do no harm. A leading byte order mark is ignored.
JUnit XML
Each <testcase> is one case. Cases with a <skipped> child are ignored.
| Field | Where it comes from |
|---|---|
| key | classname and name joined with a dot, or name alone when there is no classname |
| name | name |
| suite | The enclosing <testsuite> name. When that name is generic (pytest, rspec, vitest..., jest..., mocha..., root, default, test, tests or empty) or looks like a file path, the suite is the stem of the case's file instead. |
| file | file on the testcase, else file on the testsuite, else the testsuite name when it is a path, else classname with dots replaced by slashes |
| passed | false when the testcase has a <failure> or <error> child, else true |
| failure | The message attribute and the text of <failure> or <error> |
| duration_ms | time (seconds) times 1000 |
| output | The output property, else <system-out> |
The stem of a file is its base name up to the first dot, without a test_, eval_ or evals_ prefix and without a _test, _spec, _eval or _evals suffix. tests/evals/test_support_bot.py has the stem support_bot.
For example, pytest writes a testsuite named pytest and this testcase:
<testcase classname="tests.evals.test_support_bot" name="test_refund_limit"
file="tests/evals/test_support_bot.py" time="1.84">
evalship reads it as suite support_bot, key tests.evals.test_support_bot.test_refund_limit.
Properties
A <properties> block inside a <testcase> adds data to the case:
| Property | Meaning |
|---|---|
score |
Number. Scores between 0 and 1 work best, because tolerance is an absolute difference. |
threshold |
Number. Shown next to failing scores. |
targets |
Comma-separated code the case exercises, as path or path#symbol. See Untested changes. |
input |
The eval input. |
output |
The model output. |
cost_usd |
Cost of the case in US dollars. |
tokens |
Tokens used. |
| Any other name | Stored in the case's metadata. |
In JUnit, pass or fail always comes from <failure> and <error>. A score below its threshold does not fail a case by itself.
<testcase classname="tests.evals.test_support_bot" name="test_refund_limit"
file="tests/evals/test_support_bot.py" time="1.84">
<properties>
<property name="score" value="0.41"/>
<property name="threshold" value="0.7"/>
<property name="targets" value="app/support_bot.py#answer,prompts/support_system.md"/>
</properties>
<failure message="assert 0.41 >= 0.7">...</failure>
</testcase>
pytest: record_property
pytest's record_property fixture writes properties into the XML:
def test_refund_limit(record_property):
question = "Can I get a refund of 500 euros?"
reply = answer(question)
score = judge_refund_policy(reply)
record_property("score", score)
record_property("threshold", 0.7)
record_property("targets", "app/support_bot.py#answer")
record_property("input", question)
record_property("output", reply)
assert score >= 0.7
Run pytest with -o junit_family=xunit1:
pytest tests/evals --junitxml=eval-results.xml -o junit_family=xunit1
pytest's default family, xunit2, doesn't write per-test properties. To set it once, add this to pyproject.toml:
[tool.pytest.ini_options]
junit_family = "xunit1"
promptfoo JSON
Write it with promptfoo eval -o results.json. Each test, prompt and provider combination is one case.
| Field | Where it comes from |
|---|---|
| suite | description at the top of your promptfoo config, else promptfoo |
| key | The test's description. When the eval has more than one prompt, | <prompt label> is appended; when it has more than one provider, | <provider> is appended. |
| name | The test's description |
| passed | success, else gradingResult.pass |
| score | score, else gradingResult.score |
| failure | gradingResult.reason, else error |
| input | The test's vars |
| output | response.output |
| duration_ms | latencyMs |
| cost_usd | cost, else response.cost |
| tokens | response.tokenUsage.total |
| targets | The prompt file, when the prompt label is a file path such as file://prompts/support.txt |
| metadata | The provider, the assertion types, and judge: llm when every assertion is model-graded, deterministic when any is not |
A test without a description gets the key test <8 hex characters>, derived from its vars. Changing any var then changes the key. Give every test a description.
With two prompts and two providers, a key looks like Refund above limit | prompts/support_v2.txt | openai:gpt-4o-mini.
evalship JSON
Use evalship JSON for hand-rolled evals or frameworks without JUnit output. The schema is at evalship.com/schema/v1.json.
{
"schema": "evalship/v1",
"suite": "support-bot",
"cases": [
{
"key": "refund-above-limit",
"name": "Declines a refund above 100 euros",
"passed": true,
"score": 0.92,
"threshold": 0.7,
"duration_ms": 1840,
"cost_usd": 0.0021,
"tokens": 812,
"input": "Can I get a refund of 500 euros?",
"output": "I can refund up to 100 euros. For larger amounts I'll pass you to a colleague.",
"file": "evals/support_bot.eval.ts",
"targets": ["app/support_bot.py#answer", "prompts/support_system.md"],
"metadata": { "model": "gpt-4o-mini" }
}
]
}
The top-level suite applies to cases without their own. Only cases is required at the top level.
| Field | Type | Notes |
|---|---|---|
key |
string | Stable identifier. Each case needs key or name; name is used as the key when key is missing. Up to 255 characters. |
name |
string | Display name. Up to 500 characters. |
suite |
string | Overrides the top-level suite. Default default. Up to 100 characters. |
passed |
boolean | Required unless both score and threshold are given; then passed is score >= threshold. |
score |
number or null | 0 to 1. |
threshold |
number or null | 0 to 1. |
duration_ms |
number or null | Rounded to whole milliseconds. |
cost_usd |
number or null | |
tokens |
integer or null | |
input |
string or null | Truncated to 4 KB. |
output |
string or null | Truncated to 4 KB. |
failure |
string or null | Why the case failed. Truncated to 4 KB. |
file |
string or null | The eval file. Up to 500 characters. |
targets |
array of strings | Code the case exercises, as path or path#symbol. Up to 50 entries. |
metadata |
object | Stored as given. Values that aren't objects are dropped. |
Non-string values in input, output and failure are serialized as JSON before truncation.
Stable keys
evalship matches cases across runs by suite and key. When either changes, the old case shows up as removed and the new one as new, and its flakiness history starts over.
- Keep timestamps, random ids, dates and changing model names out of test names and keys.
- In pytest, give parametrized tests explicit
ids=so parameter values don't end up in the key. - In JUnit, the key includes the module path, so moving or renaming a test file changes its keys.
- In promptfoo, give every test a
description. - If the same suite and key appear twice in one file, the repeats become
key (2),key (3)in file order. That depends on order, so give each case its own key.
Up to 20,000 cases per file are read. See Connecting CI for artifact and file size limits.