Writing Custom Vitest Reporters for CI and Dashboards
Vitest's built-in reporters — verbose, dot, tap, junit — cover most cases. But sometimes you need test output in a custom format: a Slack message shaped exactly how your team wants it, a JSON file that feeds your internal dashboard, or a compact CI format that highlights only failures. Writing a custom Vitest reporter is straightforward.
Reporter Interface
A Vitest reporter implements the Reporter interface from vitest/reporters. The key lifecycle methods:
import type { Reporter, File, TaskResultPack, Vitest } from 'vitest';
export class MyReporter implements Reporter {
// Called once when Vitest initializes
onInit(ctx: Vitest): void {}
// Called when a test file starts executing
onPathsCollected(paths?: string[]): void {}
// Called after all files are collected
onCollected(files?: File[]): void {}
// Called as tests complete (streaming)
onTaskUpdate(packs: TaskResultPack[]): void {}
// Called once all tests finish
onFinished(files?: File[], errors?: unknown[]): void {}
// Called for console output from tests
onUserConsoleLog(log: UserConsoleLog): void {}
}You don't need to implement all methods — only the ones relevant to your use case.
Project Setup
npm install -D vitestCreate reporters/my-reporter.ts:
import type { Reporter, File, Vitest } from 'vitest';
export default class MyReporter implements Reporter {
// implementation here
}Register in vitest.config.ts:
import { defineConfig } from 'vitest/config';
import MyReporter from './reporters/my-reporter';
export default defineConfig({
test: {
reporters: [
'verbose', // built-in (keep console output)
new MyReporter(), // custom reporter instance
],
},
});Or reference by path for simpler reporters:
reporters: ['verbose', './reporters/my-reporter'],Example 1: Compact Failure-Only Reporter
For large CI pipelines where you only care about what broke:
import type { Reporter, File, Task } from 'vitest';
function collectFailures(tasks: Task[], failures: Task[] = []): Task[] {
for (const task of tasks) {
if (task.result?.state === 'fail') {
failures.push(task);
}
if ('tasks' in task && task.tasks) {
collectFailures(task.tasks, failures);
}
}
return failures;
}
export default class FailureOnlyReporter implements Reporter {
private startTime = 0;
onInit() {
this.startTime = Date.now();
}
onFinished(files: File[] = []) {
const duration = ((Date.now() - this.startTime) / 1000).toFixed(1);
const allTasks = files.flatMap(f => collectFailures(f.tasks));
if (allTasks.length === 0) {
console.log(`\n✅ All tests passed (${duration}s)\n`);
return;
}
console.log(`\n❌ ${allTasks.length} test(s) failed:\n`);
for (const task of allTasks) {
console.log(` FAIL ${task.name}`);
const error = task.result?.errors?.[0];
if (error) {
const message = error.message?.split('\n')[0] ?? 'Unknown error';
console.log(` ${message}`);
}
}
console.log('');
}
}Output:
❌ 2 test(s) failed:
FAIL user can complete checkout
Expected 'Order confirmed' to contain 'success'
FAIL cart persists after page reload
Expected 3 to equal 2Example 2: JSON Dashboard Reporter
Emit structured JSON for an internal metrics dashboard:
import type { Reporter, File, Task } from 'vitest';
import { writeFileSync } from 'fs';
interface TestResult {
name: string;
suitePath: string[];
status: 'pass' | 'fail' | 'skip';
durationMs: number;
errorMessage?: string;
}
interface ReportOutput {
timestamp: string;
totalTests: number;
passed: number;
failed: number;
skipped: number;
durationMs: number;
tests: TestResult[];
}
function flattenTasks(tasks: Task[], suitePath: string[] = []): TestResult[] {
const results: TestResult[] = [];
for (const task of tasks) {
if (task.type === 'test') {
results.push({
name: task.name,
suitePath: [...suitePath],
status: task.result?.state === 'pass' ? 'pass'
: task.result?.state === 'fail' ? 'fail'
: 'skip',
durationMs: task.result?.duration ?? 0,
errorMessage: task.result?.errors?.[0]?.message,
});
} else if (task.type === 'suite' && task.tasks) {
results.push(...flattenTasks(task.tasks, [...suitePath, task.name]));
}
}
return results;
}
export default class DashboardReporter implements Reporter {
private outputPath: string;
private startTime = 0;
constructor(options?: { outputPath?: string }) {
this.outputPath = options?.outputPath ?? 'test-results/dashboard.json';
}
onInit() {
this.startTime = Date.now();
}
onFinished(files: File[] = []) {
const allTests = files.flatMap(f => flattenTasks(f.tasks));
const output: ReportOutput = {
timestamp: new Date().toISOString(),
totalTests: allTests.length,
passed: allTests.filter(t => t.status === 'pass').length,
failed: allTests.filter(t => t.status === 'fail').length,
skipped: allTests.filter(t => t.status === 'skip').length,
durationMs: Date.now() - this.startTime,
tests: allTests,
};
writeFileSync(this.outputPath, JSON.stringify(output, null, 2));
console.log(`\nDashboard report written to ${this.outputPath}`);
}
}Configure with options:
reporters: [
'verbose',
[DashboardReporter, { outputPath: 'reports/vitest-results.json' }],
]Example 3: Slack Webhook Reporter
Post results to Slack when the suite finishes:
import type { Reporter, File } from 'vitest';
import https from 'https';
import url from 'url';
export default class SlackReporter implements Reporter {
private webhookUrl: string;
private projectName: string;
constructor(options: { webhookUrl: string; projectName?: string }) {
this.webhookUrl = options.webhookUrl;
this.projectName = options.projectName ?? 'Vitest';
}
async onFinished(files: File[] = []) {
const tests = files.flatMap(f => this.flattenTests(f));
const passed = tests.filter(t => t.result?.state === 'pass').length;
const failed = tests.filter(t => t.result?.state === 'fail').length;
const total = tests.length;
const emoji = failed === 0 ? '✅' : '❌';
const status = failed === 0 ? 'passed' : 'failed';
const failedNames = tests
.filter(t => t.result?.state === 'fail')
.slice(0, 5)
.map(t => `• ${t.name}`)
.join('\n');
const payload: object = {
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `${emoji} *${this.projectName} ${status}*\n${passed}/${total} tests passed`,
},
},
...(failedNames ? [{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Failed:*\n${failedNames}`,
},
}] : []),
],
};
await this._post(payload);
}
private flattenTests(file: File): any[] {
const collect = (tasks: any[]): any[] =>
tasks.flatMap(t => t.type === 'test' ? [t] : collect(t.tasks ?? []));
return collect(file.tasks);
}
private _post(payload: object): Promise<void> {
return new Promise((resolve, reject) => {
const data = JSON.stringify(payload);
const parsed = url.parse(this.webhookUrl);
const req = https.request({
hostname: parsed.hostname,
path: parsed.path,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': data.length },
}, resolve);
req.on('error', reject);
req.write(data);
req.end();
});
}
}reporters: [
'dot',
[SlackReporter, {
webhookUrl: process.env.SLACK_WEBHOOK_URL!,
projectName: 'Frontend Tests',
}],
]Accessing Full Task Data
The File type has a tasks array that's recursive — suites contain tests, suites can be nested. The Task union is Test | Suite | Custom. Key fields:
interface Test {
type: 'test';
name: string;
result?: {
state: 'pass' | 'fail' | 'skip' | 'todo' | 'run';
duration?: number;
errors?: Array<{ message: string; stack?: string }>;
};
}
interface Suite {
type: 'suite';
name: string;
tasks: Task[];
}Using Multiple Reporters
Reporters compose — run several simultaneously:
reporters: [
'verbose', // console output
'junit', // for CI JUnit parsing
'./reporters/failure-only-reporter', // compact failure summary
[SlackReporter, { webhookUrl: '...' }], // Slack
[DashboardReporter, { outputPath: 'reports/results.json' }],
]Each reporter is independent. A crash in one won't affect others (Vitest catches reporter errors).
Testing Your Reporter
import { describe, it, expect, vi } from 'vitest';
import FailureOnlyReporter from './failure-only-reporter';
describe('FailureOnlyReporter', () => {
it('prints nothing on full pass', () => {
const consoleSpy = vi.spyOn(console, 'log');
const reporter = new FailureOnlyReporter();
reporter.onInit?.();
reporter.onFinished?.([
{ tasks: [{ type: 'test', name: 'passes', result: { state: 'pass' } }] } as any
]);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('✅'));
});
});Summary
Custom Vitest reporters implement Reporter from vitest/reporters, override the lifecycle methods you need (onInit, onFinished are the most common), and are registered in vitest.config.ts. The pattern scales from simple console output formatters to full Slack integrations and dashboard JSON emitters. Start with onFinished and getFinishedSpans() equivalents (file.tasks), then add streaming via onTaskUpdate if you need real-time feedback.