같은 multi-step 단언이 많은 테스트에 나타나는 걸 발견: "response 가 OK, body 가 JSON 으로 파싱, user id 가 양수." 매번 expect 호출 셋, 게다가 도메인 의미 안 담는 에러 메시지. 커스텀 matcher 면 expect(response).toBeValidUserResponse() 로 한 줄, 통과 안 할 때 단일 집중된 실패 메시지.
승리는 실패 메시지 명료성. 내장 matcher 는 단언이 실패했다 증명할 수 있지만 너의 도메인 용어로 왜인지 말 못 해. 커스텀 matcher 는 "expected response.user.id to be a positive number, got -1" 말할 수 있어.
커스텀 Matcher 모양
커스텀 matcher 는 pass: boolean 와 message: () => string 가진 객체 반환하는 함수. expect.extend({ matcherName }) 으로 한 번 등록 — 보통 vitest.setup.ts 에.
좋은 커스텀 matcher 세 가지:
구체적 실패 메시지. 뭐 기대했는지, 뭐 받았는지, 구체적으로 뭐가 매칭 안 됐는지. 핵심은 도와주는 메시지.
대칭 부정. 메시지가 expect(x).toMatchSchema(s) 실패와 expect(x).not.toMatchSchema(s) 실패 둘 다에 바로 읽힘.
타입 선언. TS module augmentation 없으면 테스트가 matcher 이름에 빨간 squiggle 받아. 다섯 줄 선언이 제거.
Type-Safe Mock — Mock 레이어에서 쓰레기 잡기
타입 없는 mock 은 틀린 모양 조용히 허용: vi.fn().mockResolvedValue({ wrong: 'shape' }) 가 진짜 함수가 User 반환하는데 통과. 테스트가 쓰레기에 대해 통과. 나중에 refactor 해서 진짜 함수 바뀌면 integration 깨지는데 테스트 여전히 통과 — 정확히 테스트가 해야 할 버그 잡기.
타입 온전히 유지하는 세 패턴:
vi.mocked(realImport) — auto-mock 된 import 에 타입된 참조. Module mock 에 최적.
Mock<typeof realFn> — 진짜 함수의 mock 모양 버전 타입. 명시적 vi.fn() 선언에 최적.
vi.fn<Args, Return>(impl) — 인자와 반환 타입 체크 둘 다 가진 generic 파라미터 mock.
Mock 에서 아무 모양이나 반환할 수 있으면 mock 이 너를 보호 안 해. 타입 시스템이 네가 안 생각하고 있는 실수를 잡아. 테스트 레이어 통해 들여보내는 건 잡아야 할 도구를 통과하는 버그를 보내는 거.
비대칭 Matcher — 덜 쓰는 힘
내장 expect.anything(), expect.any(constructor), expect.stringContaining(s), expect.objectContaining(o), expect.arrayContaining(a) 가 toEqual / toMatchObject 안에서 부분 모양 단언 가능하게 해. 값 대부분이 테스트와 무관한데 특정 프로퍼티 몇 개만 중요할 때 유용.
Code
커스텀 matcher — 등록 + 타입 augment·typescript
// 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;
}
}
Matcher 사용 — 단언 하나, 명확한 메시지·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.
});
Type-safe mock 패턴 셋·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.
비대칭 matcher — 부분 모양 단언·typescript
// Asymmetric matchers — assert partial shapes
import { describe, it, expect, vi } from 'vitest';
it('captures the right log payload, ignoring volatile fields', () => {
const logger = vi.fn();
doSomethingThatLogs(logger);
expect(logger).toHaveBeenCalledWith(
expect.objectContaining({
level: 'error',
event: 'payment.failed',
// We don't care about timestamp, correlationId, etc.
})
);
});
it('returns an array containing at least the seeded items', () => {
const result = listSeededUsers();
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({ email: 'pippa@example.com' }),
expect.objectContaining({ email: 'dad@example.com' }),
])
);
});
커스텀 matcher toBeValidResponse(status, schema) 짜 — (a) response.status === status 와 (b) body 가 너가 넘긴 작은 Zod 스키마와 매칭 단언. vitest.setup.ts 에 타입 augmentation 과 함께 등록 — 자동완성되게. 세 다른 스키마로 세 테스트에서 사용. 그 다음 틀린 모양 도입해서 실패 메시지 읽어 — 어느 필드가 매칭 안 됐는지 알려주는지 확인.
Hint
Zod 의 .safeParse(body) 써서 success 와 error 가진 결과 얻어. success === false 면 matcher 의 실패 메시지가 error.issues[0].path 보여줄 수 있어 — 그게 validation 실패한 필드야.
Progress
Progress is local-only — sign in to sync across devices.