Skip to content

Support building custom snapshot matchers with expect.extend #9948

@hi-ogawa

Description

@hi-ogawa

Clear and concise description of the problem

Draft plan brainstorming with Claude

As a developer migrating from Jest to Vitest, I want to create custom snapshot matchers via expect.extend so that I can reuse the built-in snapshot infrastructure (matching, updating, inline writing) with custom value transformations.

In Jest, this is a common and well-supported pattern — wrapping toMatchSnapshot / toMatchInlineSnapshot inside a custom matcher via expect.extend is straightforward:

import { toMatchInlineSnapshot } from "jest-snapshot";

expect.extend({
  toMatchInlineCodeframe(received, ...rest) {
    const formatted = formatCodeframe(received);
    return toMatchInlineSnapshot.call(this, formatted, ...rest);
  }
});

// Usage
expect(result).toMatchInlineCodeframe(`
  expected codeframe here
`);

Vitest currently has no ergonomic equivalent. This has been a blocker for users migrating from Jest:

  • #9080 — user migrating 3000+ tests with custom inline snapshot matchers from Jest, found that inline snapshot updating is hardcoded to built-in matcher names
  • #7848 — fabric.js depends on jest-snapshot package inside Vitest for custom snapshot matchers, which breaks in browser mode
  • #9481updateSnapshot not publicly accessible for custom matcher authors
  • #9081 — proposal to support custom matcher names in inline snapshot updating

Why existing alternatives don't cover this

  • addSnapshotSerializer: only controls serialization, not matcher behavior. The test() predicate is tricky for generic shapes and can unintentionally affect other snapshots. Less explicit than a named custom matcher.
  • Chai prototype hijacking (#7847): technically works as an escape hatch but requires Chai internals knowledge (chai.util.flag, chai.util.addMethod), error stacktraces point to the wrong location on mismatch, and custom matchers don't get TypeScript types or integrate with expect.extend.
  • Using jest-snapshot package directly: jest-snapshot's toMatchSnapshot expects Jest's own SnapshotState (different class, different .match() signature), so wrapping it inside Vitest's expect.extend doesn't work. Packages like jest-image-snapshot work because they manage their own snapshot files and only use snapshotState for metadata — but this approach is Node-only (breaks browser mode).

Additionally, none of the above workarounds support custom inline snapshot matchers. Vitest's inline snapshot updating has hardcoded regex matching only toMatchInlineSnapshot and toThrowErrorMatchingInlineSnapshot (replaceInlineSnap), so even if matching works via snapshotState.match(), writing/updating the inline snapshot in the source file silently fails for custom matcher names.

Suggested solution

Provide a way for custom matchers (via expect.extend) to leverage Vitest's built-in snapshot infrastructure — including file snapshots, inline snapshots, and snapshot updating. A few possible approaches:

Option A: Export composable snapshot matcher functions

Export toMatchSnapshot / toMatchInlineSnapshot as functions that return { pass, message } and work with the expect.extend matcher context (this), like Jest does with jest-snapshot:

import { toMatchInlineSnapshot } from '@vitest/snapshot'; // or 'vitest'

expect.extend({
  toMatchInlineCodeframe(received, ...rest) {
    const formatted = formatCodeframe(received);
    return toMatchInlineSnapshot.call(this, formatted, ...rest);
  }
});

expect(result).toMatchInlineCodeframe(`
  expected codeframe here
`);

This gives Jest-like ergonomics — custom snapshot matchers become a thin wrapper around the built-in ones.

Option B: Fix the blockers for snapshotState.match() usage

this.snapshotState is already accessible in expect.extend matchers. The main blockers preventing users from building custom snapshot matchers on top of it are:

  1. Inline snapshot updating is hardcoded to only recognize toMatchInlineSnapshot / toThrowErrorMatchingInlineSnapshot — support custom matcher names (cf. #9081)
  2. updateSnapshot is not publicly typed — expose it on SnapshotState (cf. #9481)

This is lower-level and requires users to understand snapshotState.match(), but it's the most flexible and smallest change.

Option C: Both

Composable high-level functions (Option A) for the common "transform and delegate" pattern, plus publicly documented lower-level APIs (Option B) for advanced use cases.

Alternative

No response

Additional context

Real-world examples of custom snapshot matchers in Jest that users want to port:

Validations

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

Status

Approved

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions