C.W.K.
Stream
Lesson 03 of 03 · published

커스텀 Matcher 와 Type-Safe Mock

~17 min · vitest-advanced, custom-matchers, types

Level 0테스트 호기심
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"명확한 실패 메시지와 타입된 mock — 세팅해두면 다행이라 느낄 두 가지."

커스텀 Matcher — 내장이 장황해질 때

같은 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: booleanmessage: () => string 가진 객체 반환하는 함수. expect.extend({ matcherName }) 으로 한 번 등록 — 보통 vitest.setup.ts 에.

좋은 커스텀 matcher 세 가지:

  1. 구체적 실패 메시지. 뭐 기대했는지, 뭐 받았는지, 구체적으로 뭐가 매칭 안 됐는지. 핵심은 도와주는 메시지.
  2. 대칭 부정. 메시지가 expect(x).toMatchSchema(s) 실패와 expect(x).not.toMatchSchema(s) 실패 둘 다에 바로 읽힘.
  3. 타입 선언. TS module augmentation 없으면 테스트가 matcher 이름에 빨간 squiggle 받아. 다섯 줄 선언이 제거.

Type-Safe Mock — Mock 레이어에서 쓰레기 잡기

타입 없는 mock 은 틀린 모양 조용히 허용: vi.fn().mockResolvedValue({ wrong: 'shape' }) 가 진짜 함수가 User 반환하는데 통과. 테스트가 쓰레기에 대해 통과. 나중에 refactor 해서 진짜 함수 바뀌면 integration 깨지는데 테스트 여전히 통과 — 정확히 테스트가 해야 할 버그 잡기.

타입 온전히 유지하는 세 패턴:

  1. vi.mocked(realImport) — auto-mock 된 import 에 타입된 참조. Module mock 에 최적.
  2. Mock<typeof realFn> — 진짜 함수의 mock 모양 버전 타입. 명시적 vi.fn() 선언에 최적.
  3. 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' }),
    ])
  );
});

External links

Exercise

커스텀 matcher toBeValidResponse(status, schema) 짜 — (a) response.status === status 와 (b) body 가 너가 넘긴 작은 Zod 스키마와 매칭 단언. vitest.setup.ts 에 타입 augmentation 과 함께 등록 — 자동완성되게. 세 다른 스키마로 세 테스트에서 사용. 그 다음 틀린 모양 도입해서 실패 메시지 읽어 — 어느 필드가 매칭 안 됐는지 알려주는지 확인.
Hint
Zod 의 .safeParse(body) 써서 successerror 가진 결과 얻어. success === false 면 matcher 의 실패 메시지가 error.issues[0].path 보여줄 수 있어 — 그게 validation 실패한 필드야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.