"A clear failure message and a typed mock are the two things you'll be glad you set up."
Custom Matchers — When the Built-Ins Get Verbose
You'll find the same multi-step assertion appearing across many tests: "response is OK, body parses as JSON, and the user id is positive." Three expect calls every time, plus error messages that don't carry domain meaning. A custom matcher lets you write expect(response).toBeValidUserResponse() with a single, focused failure message when it doesn't pass.
The win is failure-message clarity. Built-in matchers can prove the assertion failed but not why in your domain's terms. A custom matcher can say "expected response.user.id to be a positive number, got -1."
The Custom-Matcher Shape
A custom matcher is a function that returns an object with pass: boolean and message: () => string. Register it once with expect.extend({ matcherName }) — typically in vitest.setup.ts.
Three things make a custom matcher good:
Specific failure message. Say what was expected, what was received, and what specifically didn't match. The whole point is messages that help.
Symmetric negation. The message reads correctly both for expect(x).toMatchSchema(s) failing and expect(x).not.toMatchSchema(s) failing.
Type declarations. Without TS module augmentation, your test gets red squiggles on the matcher name. Five lines of declaration removes them.
Type-Safe Mocks — Catch Garbage at the Mock Layer
An untyped mock silently allows the wrong shape: vi.fn().mockResolvedValue({ wrong: 'shape' }) passes when the real function returns a User. The test passes against the garbage. When you later refactor and the real function changes, the integration breaks but the test still passes — exactly the bug catching the test was supposed to do.
Three patterns keep types intact:
vi.mocked(realImport) — gives you a typed reference to an auto-mocked import. Best for module mocks.
Mock<typeof realFn> — the type of a mock-shaped version of a real function. Best for explicit vi.fn() declarations.
vi.fn<Args, Return>(impl) — generic-parameterized mock with both argument and return-type checking.
If you can return any shape from your mock, your mock isn't protecting you. The type system catches the mistakes you're not thinking about. Letting it through the test layer is letting bugs slip past the very tool that should catch them.
Asymmetric Matchers — The Underused Power
Built-in expect.anything(), expect.any(constructor), expect.stringContaining(s), expect.objectContaining(o), expect.arrayContaining(a) let you assert partial shapes inside toEqual / toMatchObject. Useful when most of the value is irrelevant to the test, but a few specific properties matter.
// vitest.setup.ts — register the matcher and its types
import { expect } from 'vitest';
interface UserResponse {
user?: { id?: number; email?: string };
status?: number;
}
expect.extend({
toBeValidUserResponse(received: UserResponse) {
const { user, status } = received ?? {};
if (status !== 200) {
return {
pass: false,
message: () => `expected status 200, got ${status}`,
};
}
if (!user || typeof user.id !== 'number' || user.id <= 0) {
return {
pass: false,
message: () =>
`expected user.id to be a positive number, got ${JSON.stringify(user?.id)}`,
};
}
if (!user.email || !/@/.test(user.email)) {
return {
pass: false,
message: () =>
`expected user.email to contain an @, got ${JSON.stringify(user.email)}`,
};
}
return {
pass: true,
message: () => 'expected response not to be a valid user response',
};
},
});
// Type augmentation so the matcher is type-safe in tests.
declare module 'vitest' {
interface Assertion {
toBeValidUserResponse(): void;
}
interface AsymmetricMatchersContaining {
toBeValidUserResponse(): void;
}
}
Using the matcher — one assertion, clear message·typescript
// Using the custom matcher — focused failure message
import { describe, it, expect } from 'vitest';
import { fetchUserResponse } from './user-service';
it('returns a valid user response', async () => {
const result = await fetchUserResponse(1);
expect(result).toBeValidUserResponse();
// On failure: "expected user.id to be a positive number, got -1"
// — not the three-line generic toEqual diff.
});
Three type-safe mock patterns·typescript
// Type-safe mocks — the three patterns
import { vi, type Mock } from 'vitest';
import { fetchUserById } from './api';
// 1. vi.mocked — best for hoisted vi.mock()
vi.mock('./api');
const mockedFetch = vi.mocked(fetchUserById);
// TS knows mockedFetch is (id: number) => Promise<User>
mockedFetch.mockResolvedValue({ id: 1, name: 'Pippa' });
// mockedFetch.mockResolvedValue({ wrong: 'shape' }); // ← Type error
// 2. Mock<typeof realFn> — for declared mocks
import type { sendEmail as RealSendEmail } from './email';
const sendEmail: Mock<typeof RealSendEmail> = vi.fn();
sendEmail.mockReturnValue(true);
// sendEmail.mockReturnValue('not boolean'); // ← Type error
// 3. Inline generics — when you don't have a real function to reference
const onClick = vi.fn<[event: MouseEvent], void>();
// onClick now requires its arg to be (event: MouseEvent), returns void.
Write a custom matcher toBeValidResponse(status, schema) that asserts (a) response.status === status and (b) the body matches a small Zod schema you pass in. Register it in vitest.setup.ts with type augmentation so it autocompletes. Use it in three tests with three different schemas. Then introduce a wrong shape and read the failure message — confirm it tells you WHICH field didn't match.
Hint
Use Zod's .safeParse(body) to get a result with success and error. When success === false, the matcher's failure message can show error.issues[0].path — that's the field that failed validation.
Progress
Progress is local-only — sign in to sync across devices.