Vitest JSON Reporter Output Format: Schema and Examples

By Alex Gandy August 24, 2026

The Vitest JSON reporter output format is a Jest-compatible object with thirteen top-level keys and a two-level tree underneath: testResults[] for files, assertionResults[] for individual tests. It is a subset of Jest’s shape, with three fewer keys and none of its own added. Vitest’s documentation shows an example payload but not what the fields mean, which ones are optional, or which are inherited from Jest. This is that reference, with every claim checked against a real run.

TL;DR

One JSON object with thirteen keys: nine counters (numTotalTests, numPassedTests, numFailedTests, numPendingTests, numTodoTests, plus the four suite-level equivalents), a snapshot summary, a startTime in epoch milliseconds, a boolean success, and testResults[]. A fourteenth key, coverageMap, appears when coverage is enabled. Each entry in testResults[] is one test file with its own startTime/endTime and an assertionResults[] array of individual tests. There is no total-duration field; you compute it yourself.

The Vitest JSON Reporter Output Format

The JSON reporter produces a single object per run. Its shape is inherited from Jest’s --json output, which is why the same parser handles both.

Full example payload

This is a genuine payload from a Vitest 3.2.4 run of five tests across two files, one of which failed. The absolute paths are scrubbed to CI-style ones and the stack trace is truncated to two frames. Nothing else is altered, so the fields present and absent here are the fields Vitest actually emits. Coverage was disabled, so there is no coverageMap.

{
"numTotalTestSuites": 5,
"numPassedTestSuites": 3,
"numFailedTestSuites": 2,
"numPendingTestSuites": 0,
"numTotalTests": 5,
"numPassedTests": 3,
"numFailedTests": 1,
"numPendingTests": 1,
"numTodoTests": 0,
"snapshot": {
"added": 0,
"failure": false,
"filesAdded": 0,
"filesRemoved": 0,
"filesRemovedList": [],
"filesUnmatched": 0,
"filesUpdated": 0,
"matched": 0,
"total": 0,
"unchecked": 0,
"uncheckedKeysByFile": [],
"unmatched": 0,
"updated": 0,
"didUpdate": false
},
"startTime": 1787574088656,
"success": false,
"testResults": [
{
"assertionResults": [
{
"ancestorTitles": ["Auth"],
"fullName": "Auth should authenticate user",
"status": "failed",
"title": "should authenticate user",
"duration": 4.414874999999995,
"failureMessages": [
"AssertionError: expected 401 to be 200 // Object.is equality\n at /home/runner/work/project/tests/auth.test.ts:3:54\n at file:///home/runner/work/project/node_modules/@vitest/runner/dist/chunk-hooks.js:155:11"
],
"meta": {}
},
{
"ancestorTitles": ["Auth"],
"fullName": "Auth should handle logout",
"status": "passed",
"title": "should handle logout",
"duration": 0.18741600000001313,
"failureMessages": [],
"meta": {}
}
],
"startTime": 1787574088877,
"endTime": 1787574088881.4148,
"status": "failed",
"message": "",
"name": "/home/runner/work/project/tests/auth.test.ts"
},
{
"assertionResults": [
{
"ancestorTitles": ["Calculator", "add"],
"fullName": "Calculator add should add two positive numbers",
"status": "passed",
"title": "should add two positive numbers",
"duration": 0.8319999999999936,
"failureMessages": [],
"meta": {}
},
{
"ancestorTitles": ["Calculator", "add"],
"fullName": "Calculator add should handle negative numbers",
"status": "passed",
"title": "should handle negative numbers",
"duration": 0.08108300000000668,
"failureMessages": [],
"meta": {}
},
{
"ancestorTitles": ["Calculator", "add"],
"fullName": "Calculator add pending feature",
"status": "skipped",
"title": "pending feature",
"failureMessages": [],
"meta": {}
}
],
"startTime": 1787574088877,
"endTime": 1787574088878.081,
"status": "passed",
"message": "",
"name": "/home/runner/work/project/tests/calculator.test.ts"
}
]
}

Three things in that payload are worth pausing on before the field tables, because each one contradicts a reasonable assumption. Two files produced numTotalTestSuites: 5. The skipped test has no duration key at all rather than a zero or a null. And its status is the string skipped while the counter that tracks it is named numPendingTests.

Top-level fields

FieldTypeMeaning
numTotalTestsnumberEvery test the run collected, across all files
numPassedTestsnumberTests that finished with status passed
numFailedTestsnumberTests that finished with status failed
numPendingTestsnumberTests skipped at runtime (test.skip, describe.skip). Their assertion status is the string skipped, not pending
numTodoTestsnumberTests declared with test.todo. Vitest always emits it; Gaffer’s parser defaults it to 0 rather than requiring it
numTotalTestSuitesnumberSuites, not files: every test file plus every describe block inside it, counted recursively
numPassedTestSuitesnumberSuites with no failures. Computed as total minus failed minus pending, so a suite in which nothing ran still counts as passed
numFailedTestSuitesnumberSuites containing at least one failure, on the same recursive definition
numPendingTestSuitesnumberSuites still running or queued, plus describe.todo. A describe.skip is not counted here; it falls into numPassedTestSuites
snapshotobjectSnapshot totals for the run (total, matched, unmatched, updated, and file-level counts). Always present, with all counters at zero when you use no snapshots
startTimenumberRun start as epoch milliseconds
successbooleanWhether the run as a whole succeeded
testResultsarrayOne entry per test file
coverageMapobjectIstanbul-shaped coverage data, present only when coverage is enabled. The key is absent otherwise, not null

The suite counters do not count files. Vitest builds them by walking the task tree and collecting everything of type suite, and a test file is itself a suite, so nested describe blocks are counted alongside it. The payload above has two files and emits numTotalTestSuites: 5: the two files, plus Calculator, plus the add block nested inside it, plus Auth. If you are moving from Jest and reading numTotalTestSuites as a file count, you will overcount by the number of describe blocks in your suite. Use testResults.length when you want files.

One note from parsing this in production: startTime is a JSON number of epoch milliseconds, not an ISO-8601 string, and Gaffer deserializes it as a 64-bit float rather than an integer. The file-level endTime values in the payload above show why that matters.

What does numPassedTests mean in Vitest’s JSON reporter output?

numPassedTests is the count of individual tests that finished with status passed, summed across every file in the run. It counts tests, not files and not assertions. A single it() block containing twenty expect() calls contributes exactly 1.

The identity numTotalTests = numPassedTests + numFailedTests + numPendingTests + numTodoTests holds by construction: the reporter derives all five from one pass over the same task list. If you are computing a pass rate, use numPassedTests / numTotalTests and decide deliberately whether skipped and todo tests belong in the denominator. Gaffer folds numPendingTests and numTodoTests together into a single skipped count, keeps them in the total, and tracks the resulting pass rate across runs rather than per report.

What does the success field represent in Vitest’s JSON output?

success is the run-level verdict: true when the run finished clean, false when anything about it failed. It is the field to gate a build on, because it is broader than numFailedTests.

numFailedTests only counts tests that actually ran and failed an assertion. A file that throws while collecting tests never contributes one. Give a suite a single unresolvable import and Vitest emits "numFailedTests": 0, "numFailedTestSuites": 1, "success": false: a gate written against numFailedTests > 0 alone passes a run in which an entire file never executed. Check success first, then use the counters.

Per-test fields: testResults[] and assertionResults[]

Each entry in testResults[] describes one test file:

FieldTypeMeaning
namestringAbsolute path to the test file
startTimenumberFile start, epoch milliseconds
endTimenumberFile end, epoch milliseconds. Frequently fractional (1787574088881.4148 above), because it is the start plus a fractional-millisecond duration
statusstringpassed or failed for the file as a whole
messagestringFile-level error text, usually empty
assertionResultsarrayThe individual tests in this file

Each entry in assertionResults[] is one test:

FieldTypeMeaning
titlestringThe test name as written in it() / test()
fullNamestringAncestor titles joined with the title
ancestorTitlesstring[]Enclosing describe blocks, outermost first
statusstringpassed, failed, skipped, or todo
durationnumberTest duration in fractional milliseconds. The key is omitted entirely for tests that never ran
failureMessagesstring[]Formatted assertion errors with stack traces, empty when passing
metaobjectVitest-specific metadata, frequently {}
locationobjectSource position (line + column), present only when task locations are enabled

duration is fractional milliseconds, not an integer: 0.08108300000000668 in the payload above is a real value. A consumer that types it as number and reads it unconditionally will get undefined on every skipped test, because the key is absent rather than null. Gaffer rounds the value when present and discards negative or non-finite ones.

A fifth status, pending, exists in Vitest’s status mapping but is effectively unreachable in a finished report: it corresponds to a task that is still running or is queued behind mode: "only". Handle it defensively if you like, but do not expect to see it.

Why is the location field missing from Vitest assertion results?

location is optional, and Vitest only records source positions when the includeTaskLocation option is enabled. That option is off by default, so location is absent from many reports, including the one above.

It can also appear without you configuring it: the Vitest UI, the HTML reporter, and non-headless browser mode all switch includeTaskLocation on, so a run with any of those active emits locations while a plain CI run does not. Check your own output before building tooling on the field. When present, location carries both line and column. Gaffer’s parser declares it optional and reads only line, which is a choice in our parser rather than a limit of the format.

How do you tell a Vitest JSON report apart from a Jest one?

By the three keys Jest emits and Vitest doesn’t (numRuntimeErrorTestSuites, openHandles, wasInterrupted), or by the meta object Vitest attaches to every assertion result. There is no Vitest-only top-level key to look for.

Vitest’s JSON report is a subset of Jest’s at the top level: it emits three fewer keys and adds none of its own.

Jest-only fieldWhat it holdsVitest
numRuntimeErrorTestSuitesFiles that failed to run at allAbsent
openHandlesHandles keeping the process aliveAbsent
wasInterruptedWhether the run was cut shortAbsent

Gaffer’s parser uses exactly those three as its Jest tell: if any is present, the report is Jest. Note that snapshot is not a discriminator despite being a Jest inheritance; Vitest emits it unconditionally.

The tell also exists one level down, inside assertionResults[]. A meta object means Vitest. invocations or numPassingAsserts mean Jest, and Jest payloads additionally carry failureDetails and retryReasons, which have no Vitest counterpart.

Format detection itself hangs on three fields. Gaffer scores a .json file as a 90%-confidence Jest/Vitest report when numTotalTests, testResults, and success are all present at the top level, and 0 otherwise. That combination is specific enough that no other report format Gaffer supports collides with it.

Is there a total duration field in Vitest’s JSON output?

Vitest’s JSON reporter emits no top-level duration field. You compute run time yourself from the per-file startTime/endTime pairs, and the arithmetic you want depends on the question you are asking. Anyone writing a CI gate against a top-level duration field is writing against a field that does not exist.

// Aggregate file time: every file's elapsed time added together.
// Files run in parallel, so their intervals overlap and this OVERSTATES
// how long the run actually took.
const fileTimeMs = report.testResults
.reduce((sum, file) => sum + (file.endTime - file.startTime), 0);
// Wall-clock span of the test phase: first file start to last file end.
// This is what you want for "how long did testing take".
const wallClockMs =
Math.max(...report.testResults.map(f => f.endTime)) -
Math.min(...report.testResults.map(f => f.startTime));

The two disagree, and the payload above shows it at small scale: the files overlap, so adding their elapsed times gives 5.50 ms while the actual span from first start to last end is 4.41 ms. Summing is the intuitive move and the wrong one for elapsed time, because it counts overlapping intervals twice. Use the sum when you want aggregate work done across files, and the span when you want how long testing took.

Neither figure covers the whole command. Vitest’s top-level startTime precedes the first file’s start by the collection and startup phase, which in that same payload is 221 ms, far larger than either test-time number. If you are timing CI, measure the process, not the report.

Gaffer stores the aggregate rather than the span, with non-positive and non-finite per-file deltas dropped and each remaining delta rounded before adding, then tracks it run over run. When no file reports a positive elapsed time, it falls back to summing per-test duration values instead.

How is the Vitest 4 JSON reporter format different from Vitest 3?

The top-level shape is stable across Vitest 3 and 4. Gaffer parses reports from both with one set of required fields and no version branching, and has never needed a compatibility shim for this reporter.

That is a claim about the fields listed above, not a guarantee about every byte of the payload, so here is what to check in your own output rather than a changelog someone invented:

  • Run vitest run --reporter=json --outputFile=v3.json on each version and diff the two files. With a fixed test suite the diff is small and definitive.
  • Check whether meta is populated. Vitest emits the key; what goes inside it is where custom metadata and future additions land.
  • Check whether location appears. It is gated on configuration, not on version, and is the field most likely to be missing when you expected it.
  • Check whether coverageMap is present. It tracks whether coverage is enabled, not the 3-to-4 upgrade.

If you write a consumer, ignore unknown fields rather than rejecting them. Vitest 4’s larger changes (the projects configuration replacing workspaces) sit outside this reporter, but a permissive parser costs nothing and survives the next minor version.

How do you generate a Vitest JSON report?

Run Vitest with --reporter=json and send the output to a file:

Terminal window
npx vitest run --reporter=json --outputFile=./test-results.json

Without --outputFile the JSON goes to stdout mixed in with everything else the run prints. Use vitest run rather than bare vitest: the latter starts watch mode and never exits, which in CI means a job that hangs until it times out.

How do I set the outputFile path for Vitest’s JSON reporter?

Pass --outputFile=<path> on the CLI, or set test.outputFile in vitest.config.ts. When you run more than one reporter, outputFile takes an object keyed by reporter name so each gets its own destination:

Terminal window
npx vitest run --reporter=default --reporter=json --outputFile.json=./results/vitest.json

Vitest creates missing parent directories for the path you give it. Relative paths resolve against the project root, so if your config sets a custom root, check where the file actually landed before wiring a CI step to it.

vitest.config.ts: reporters plus the outputFile object form

Config is the better home for this if CI and local runs should agree:

vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
reporters: ['default', 'json', 'junit'],
outputFile: {
json: './results/vitest.json',
junit: './results/vitest.xml',
},
},
});

Vitest reporters compose, so the array can hold as many as you need. Keeping default in the list matters: it prints readable progress in the terminal, and dropping it makes a local run go silent until the JSON lands.

CI Integration

No CI system parses this format natively, so the two things you do with it are archive it as an artifact and read it in a script. Both are a few lines.

GitHub Actions

Generate the report, then upload it with if: always() so a failing test run still produces the artifact you need to diagnose it.

- name: Run tests
run: npx vitest run --reporter=default --reporter=json --outputFile.json=./results/vitest.json
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: vitest-results
path: ./results/vitest.json

Without if: always() the upload step is skipped whenever the test step fails, which is precisely the run whose results you wanted. Artifacts also expire on a retention clock, so treat this as short-term storage.

Parsing results in a script

A build gate reading the report directly, in plain Node with no dependencies:

scripts/check-tests.mjs
import { readFile } from 'node:fs/promises';
const report = JSON.parse(await readFile('./results/vitest.json', 'utf8'));
const durationMs = report.testResults
.reduce((sum, file) => sum + (file.endTime - file.startTime), 0);
console.log(
`${report.numPassedTests}/${report.numTotalTests} passed in ${Math.round(durationMs)}ms`
);
if (!report.success || report.numFailedTests > 0) {
const failures = report.testResults.flatMap((file) =>
file.assertionResults
.filter((test) => test.status === 'failed')
.map((test) => `${file.name}\n ${test.fullName}\n ${test.failureMessages.join('\n ')}`)
);
console.error(failures.join('\n\n'));
process.exit(1);
}

Checking success alongside numFailedTests is the point of the first condition: a file that fails to load produces zero failed tests and a success of false.

Are there official TypeScript types for Vitest’s JSON output?

Yes. Vitest exports JsonTestResults, JsonTestResult, and JsonAssertionResult from vitest/reporters, so a consumer that already depends on Vitest should import them rather than hand-rolling interfaces.

import { readFile } from 'node:fs/promises';
import type { JsonTestResults } from 'vitest/reporters';
const report: JsonTestResults = JSON.parse(
await readFile('./results/vitest.json', 'utf8')
);

JsonTestResults is the whole report, JsonTestResult is one file, and JsonAssertionResult is one test. They have shipped since January 2024 (PR #5081, which closed the request in issue #5068) and are exported from the reporters barrel in both v3 and v4.

If your consumer is a standalone script or a service that should not depend on Vitest, the equivalent shape is small enough to declare yourself. The interfaces below match the payload above and the field set Gaffer’s parser deserializes. The official types are stricter: they mark snapshot and numTodoTests required, where these are permissive.

// For consumers that don't depend on Vitest. The official types are stricter.
export interface VitestJsonReport {
numTotalTests: number;
numPassedTests: number;
numFailedTests: number;
numPendingTests: number;
numTodoTests?: number;
numTotalTestSuites: number;
numPassedTestSuites: number;
numFailedTestSuites: number;
numPendingTestSuites: number;
snapshot?: Record<string, unknown>;
startTime: number;
success: boolean;
testResults: VitestFileResult[];
coverageMap?: Record<string, unknown>;
}
export interface VitestFileResult {
name: string;
startTime: number;
endTime: number;
status: string; // 'passed' | 'failed' in every payload we have seen
message: string;
assertionResults: VitestAssertionResult[];
}
export interface VitestAssertionResult {
title: string;
fullName: string;
ancestorTitles: string[];
// 'pending' exists in the status map but is unreachable in a finished report
status: 'passed' | 'failed' | 'skipped' | 'todo' | 'pending';
duration?: number; // absent, not null, for tests that never ran
failureMessages: string[];
meta?: Record<string, unknown>;
location?: { line: number; column: number };
}

Gaffer’s parser also accepts disabled as a status value, which comes from Jest rather than Vitest, and maps anything it does not recognise to skipped rather than throwing.

What are the limits of Vitest’s JSON reporter?

Five things this format cannot tell you: total run time, retries, console output and attachments, nested suite structure, and portable file paths. It is a counter summary with a flat test list attached, and it shows.

No total duration. There is no top-level duration field, so every consumer recomputes run time from per-file timestamps. It is the single most common wrong assumption about this payload.

No retry or flaky information. Vitest assertion results carry no retry count, so a test that passed on its third attempt looks identical to one that passed immediately. Detecting flakiness from this format requires comparing multiple runs of the same suite, which means storing history somewhere.

No console output or attachments. Nothing captures console.log from a test, screenshots, or arbitrary artifacts. The file-level message is the only free-text field and it is usually empty.

Only one level of hierarchy is preserved. ancestorTitles gives you the describe chain as strings. Beyond that, structure is whatever you encoded in the names.

Absolute paths. name is the absolute path on the machine that ran the tests, so /home/runner/work/... on a GitHub runner and /Users/you/... locally. Normalise before comparing runs, or the same test file looks like two different files.

Alternatives

Vitest ships several reporters. If the JSON format is the wrong shape for what you are doing, these are the main alternatives, three built in and one from a separate package:

FormatFlagBest forTradeoff
JUnit XML--reporter=junitCI systems that parse test results natively, like GitLab merge-request annotations and Jenkins trend graphs. Schema guideOlder, weaker data model than JSON
HTML--reporter=htmlA human debugging a failure interactivelyRequires @vitest/ui installed. Useless as a parsing target
TAP--reporter=tap, tap-flatStreaming line-oriented output into existing TAP toolingCarries less metadata than JSON
CTRFn/a (separate package)First-class retry and flaky fields, the gap this format has. CTRF guideExtra dependency, fewer consumers

Coverage is a separate artifact with its own formats and is not covered by any of these. See Vitest coverage reports for that side.

Using the Vitest JSON Reporter with Gaffer

Gaffer parses this format directly. Point the uploader at the file the reporter wrote and it detects the format, identifies Vitest from the meta object on each assertion result, and extracts the test list.

- name: Run tests
run: npx vitest run --reporter=default --reporter=json --outputFile.json=./results/vitest.json
- name: Upload to Gaffer
if: always()
uses: gaffer-sh/gaffer-uploader@v2
with:
gaffer_upload_token: ${{ secrets.GAFFER_PROJECT_TOKEN }}
report_path: ./results/vitest.json

What comes out of the parser: test names and full names, statuses, per-test durations, failure messages, file paths trimmed to start at a tests/, test/, __tests__/ or src/ segment, and a run duration computed from the file timestamps. Suite counts, success, and snapshot totals are kept as metadata.

The reason to send it somewhere rather than just archiving the file is the retry gap noted above. A single JSON report cannot tell you a test is flaky, because flakiness is a property of a test across runs. Gaffer keeps run history and compares runs to flag tests that flip between pass and fail rather than failing consistently.

If you run Vitest alongside other frameworks, multiple test frameworks covers reporting across all of them in one place. For the sharing side specifically, see Vitest test reporting.

Stop parsing test JSON by hand

Gaffer reads Vitest’s JSON reporter output natively and keeps run history, so you can ask questions across runs instead of reading one file at a time. Unlimited users on every plan.

Start Free

Start Free