Writing Custom Vitest Reporters for CI and Dashboards

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 vitest

Create 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 2

Example 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.

Read more