테스팅 강좌 #2 Vitest 셋업과 첫 단위 테스트 — describe,it,expect

8 분 소요

#1 에서 그림을 잡았다면 이번 글은 손을 움직이는 첫 단계입니다. 도구는 Vitest로 갑니다.

테스팅 강좌에서 이번 글의 위치:

  • #1 왜 테스트인가
  • #2 Vitest 셋업과 첫 단위 테스트 — describe,it,expect ← 이번 글
  • #3 React Testing Library — 컴포넌트 테스트
  • #4 비동기와 네트워크 모킹 — MSW
  • #5 사용자 이벤트와 폼 테스트
  • #6 Playwright로 E2E와 CI 통합

이번 글은 순수 함수 한 개부터 시작합니다. 컴포넌트 / React / DOM은 #3 부터.

Vitest vs Jest — 한 단락 #

Vitest는 Vite 위에서 동작하는 테스트 러너. API가 Jest와 거의 동일해서 마이그레이션이 쉽고, ESM/TypeScript가 셋업 없이 바로 통합됩니다. Vite 빌드를 공유해서 첫 실행이 빠르고, watch 모드가 정말로 즉각적입니다. Jest가 강한 영역(거대한 단일 monorepo, jsdom의 미세 호환)은 여전히 있지만, 새 프로젝트에서 Vitest를 못 고를 이유는 거의 없습니다.

이 시리즈는 Vitest 기준으로 갑니다. Jest 사용자는 import 경로(vi 대신 jest)와 vitest.config.ts 부분만 다르고 나머지는 같습니다.

빈 프로젝트로 시작 #

Vite + React + TS 프로젝트에 Vitest를 붙이는 게 가장 흔한 출발점.

새 프로젝트
pnpm create vite my-app --template react-ts
cd my-app
pnpm install
pnpm add -D vitest @vitest/ui

@vitest/ui는 watch 모드를 브라우저로 보여주는 부가 패키지 — 처음에는 한 번 켜보면 좋습니다. 강요는 아닙니다.

package.json에 스크립트 추가:

package.json
{
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview",
    "test": "vitest",
    "test:run": "vitest run",
    "test:ui": "vitest --ui",
    "coverage": "vitest run --coverage"
  }
}

스크립트의 의미:

  • vitest — watch 모드 (기본). 파일이 바뀔 때마다 영향받은 테스트만 다시 실행.
  • vitest run — 한 번만 실행 후 종료. CI에서 쓰는 명령입니다.
  • vitest --ui — 브라우저 UI.
  • vitest run --coverage — 커버리지 리포트. 별도 패키지 필요 (아래).

vitest.config.ts — 작게 시작 #

Vite와 같은 설정 파일을 쓸 수 있지만, 테스트 전용으로 분리하는 게 깔끔합니다.

vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,         // describe/it/expect 를 import 없이 쓸 수 있게
    environment: 'jsdom',  // DOM API 가 필요하면 (#3 부터 필수)
    setupFiles: ['./vitest.setup.ts'],
  },
});

옵션 의미:

  • globals: truedescribe, it, expect를 매 파일에서 import 하지 않아도 됨. Jest와 같은 모양을 다룹니다.
  • environment: 'jsdom' — 컴포넌트 테스트가 들어가면 필요. 순수 함수만 테스트한다면 'node'가 더 빠름.
  • setupFiles — 모든 테스트 시작 전 한 번만 도는 셋업 코드. matcher 확장이나 글로벌 모킹을 여기.

vitest.setup.ts는 일단 비워둡니다. #3 에서 @testing-library/jest-dom을 추가할 때 채워요.

globals: true를 쓰려면 TS가 글로벌 타입을 알아야 합니다. tsconfig.json에 한 줄 추가.

tsconfig.json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

가장 단순한 첫 테스트 #

테스트할 대상부터 짭니다. 슬러그 만드는 함수.

src/lib/slugify.ts
export function slugify(input: string): string {
  return input
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9가-힣\s-]/g, '')
    .replace(/\s+/g, '-');
}

테스트:

src/lib/slugify.test.ts
import { slugify } from './slugify';

describe('slugify', () => {
  it('공백을 하이픈으로 바꾼다', () => {
    expect(slugify('hello world')).toBe('hello-world');
  });

  it('대문자를 소문자로 바꾼다', () => {
    expect(slugify('Hello World')).toBe('hello-world');
  });

  it('특수문자를 제거한다', () => {
    expect(slugify('hello, world!')).toBe('hello-world');
  });

  it('한글은 보존한다', () => {
    expect(slugify('안녕 하세요')).toBe('안녕-하세요');
  });

  it('앞뒤 공백을 제거한다', () => {
    expect(slugify('  hello  ')).toBe('hello');
  });
});

pnpm test를 띄우면:

첫 실행
✓ src/lib/slugify.test.ts (5)
   ✓ slugify (5)
     ✓ 공백을 하이픈으로 바꾼다
     ✓ 대문자를 소문자로 바꾼다
     ✓ 특수문자를 제거한다
     ✓ 한글은 보존한다
     ✓ 앞뒤 공백을 제거한다

 Test Files  1 passed (1)
      Tests  5 passed (5)

describe / it / expect의 의미 #

세 개념이 거의 모든 테스트의 골격입니다.

  • describe(name, fn) — 테스트들을 묶는 그룹. 보통 한 모듈 / 한 함수 / 한 컴포넌트 단위입니다. 중첩할 수 있습니다.
  • it(name, fn) — 한 개의 테스트 케이스. 이름은 동작 을 한 문장으로. (test 로도 같은 동작.)
  • expect(value).matcher(expected) — 검증. 매처(matcher)는 toBe, toEqual, toContain, toThrow 등 수십 종.

이름의 컨벤션이 사실 진지합니다. 테스트 이름이 문서가 됩니다.

좋은 이름 vs 나쁜 이름
// X — 무엇을 테스트하는지 알기 어려움
it('test 1', ...)
it('slugify works', ...)

// O — 동작을 한 문장으로
it('공백을 하이픈으로 바꾼다', ...)
it('빈 문자열에 빈 문자열을 반환한다', ...)

뒤의 형식은 테스트가 깨졌을 때 콘솔만 봐도 무엇이 망가졌는지 알 수 있습니다.

자주 쓰는 매처 #

expect의 역할입니다. 외울 필요는 없고, 패턴으로 익히면 됩니다.

자주 쓰는 매처
// 동등 비교
expect(2 + 2).toBe(4);                    // === 비교 (primitive)
expect({ a: 1 }).toEqual({ a: 1 });       // 깊은 비교 (객체/배열)
expect({ a: 1, b: 2 }).toMatchObject({ a: 1 }); // 부분 일치

// 진위
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();

// 숫자
expect(0.1 + 0.2).toBeCloseTo(0.3);  // 부동소수 비교
expect(value).toBeGreaterThan(5);

// 문자열
expect('hello world').toContain('world');
expect('hello').toMatch(/^h/);

// 배열
expect([1, 2, 3]).toContain(2);
expect([1, 2, 3]).toHaveLength(3);

// 함수가 던지는 에러
expect(() => fn()).toThrow();
expect(() => fn()).toThrow('expected message');
expect(() => fn()).toThrow(MyError);

// 부정
expect(value).not.toBe(false);

toBetoEqual의 차이가 가장 자주 헷갈리는 부분입니다.

toBe vs toEqual
expect({ a: 1 }).toBe({ a: 1 });    // X — 다른 객체이므로 실패
expect({ a: 1 }).toEqual({ a: 1 }); // O — 깊은 동등성

expect(2).toBe(2);                  // O — primitive 는 그냥 toBe

watch 모드 — 진짜 즉각적 #

pnpm test (= vitest) 로 띄우면 watch 모드. 파일을 저장할 때마다 영향받은 테스트만 다시 실행합니다.

키 단축키:

watch 모드 단축키
a — 모든 테스트 다시 실행
f — 실패한 테스트만 다시 실행
p — 파일 패턴으로 필터
t — 테스트 이름 패턴으로 필터
q — 종료

큰 프로젝트에서 진짜 강력한 기능입니다. 깨진 테스트가 5개라면 f로 그 5개만 watch — 고치는 동안 다른 테스트가 도느라 노이즈가 안 생겨요.

UI 모드(pnpm test:ui) 는 같은 걸 브라우저로 보여줍니다. 한 번 띄워보세요.

UI 모드
pnpm test:ui
# http://localhost:51204/__vitest__/ 같은 주소가 열림

첫 실패와 첫 통과 — TDD 한 사이클 #

새 함수를 짤 때 흔한 흐름:

1. 먼저 깨지는 테스트
import { capitalize } from './capitalize';   // 아직 없음

it('첫 글자를 대문자로 만든다', () => {
  expect(capitalize('hello')).toBe('Hello');
});
실행 — 실패
× capitalize > 첫 글자를 대문자로 만든다
   Cannot find module './capitalize'
2. 가장 단순한 구현 — 통과
// src/lib/capitalize.ts
export function capitalize(s: string): string {
  return s[0].toUpperCase() + s.slice(1);
}
실행 — 통과
✓ capitalize > 첫 글자를 대문자로 만든다
3. edge case 추가 — 다시 실패
it('빈 문자열에 빈 문자열을 반환한다', () => {
  expect(capitalize('')).toBe('');
});
4. 구현 보강 — 통과
export function capitalize(s: string): string {
  if (!s) return s;
  return s[0].toUpperCase() + s.slice(1);
}

이 사이클 — 빨강 → 초록 → 리팩터 — 이 TDD의 골격입니다. 강요는 아니지만, 작은 함수에서 한 번 경험해 두면 감이 잡힙니다.

setup / teardown — beforeEach / afterEach #

테스트 사이에 상태를 초기화하거나 정리해야 할 때.

setup / teardown
describe('Counter', () => {
  let counter: Counter;

  beforeEach(() => {
    counter = new Counter();
  });

  it('초기값은 0', () => {
    expect(counter.value).toBe(0);
  });

  it('increment 후 1', () => {
    counter.increment();
    expect(counter.value).toBe(1);
  });
});

it가 독립적으로 도는 게 중요합니다 — 이전 테스트의 상태가 다음에 새지 않게. beforeEach가 매 테스트 직전 새로 초기화.

beforeAll / afterAll도 있습니다 (한 describe 시작/끝에 한 번만). 비싼 셋업(예: 임시 디렉터리 생성) 에 적합. 단, 테스트 간 격리가 깨지기 쉬워서 가능하면 beforeEach 우선.

expect.assertions — 비동기에서 잘못 빠지는 함정 #

비동기 테스트는 #4 에서 깊게 다루지만, 한 가지 함정만 미리.

문제 — 검증이 한 번도 안 도는데 통과
it('비동기 검증', async () => {
  try {
    await fetchData();
  } catch (e) {
    expect(e.message).toBe('expected');
  }
  // fetchData 가 안 던지면 catch 가 안 도는데, 테스트는 통과
});

해결:

expect.assertions 로 호출 횟수 강제
it('비동기 검증', async () => {
  expect.assertions(1);  // 정확히 1번 expect 가 도는 게 보장돼야 함
  try {
    await fetchData();
  } catch (e) {
    expect(e.message).toBe('expected');
  }
});

expect.assertions(N)은 “테스트 끝나는 시점에 N 번의 expect가 호출됐어야 한다” 를 강제합니다. catch가 안 돌면 0 번 — 테스트가 실패.

또는 더 깔끔하게 .rejects / .resolves 매처:

rejects 매처
it('비동기 검증', async () => {
  await expect(fetchData()).rejects.toThrow('expected');
});

모킹 — vi.fn() 첫 만남 #

모킹은 #4 의 본격 주제지만, 가장 단순한 것만 미리.

콜백을 모킹
import { vi } from 'vitest';

it('콜백이 호출됐는지 검증', () => {
  const callback = vi.fn();
  forEach([1, 2, 3], callback);

  expect(callback).toHaveBeenCalledTimes(3);
  expect(callback).toHaveBeenCalledWith(1);
  expect(callback).toHaveBeenCalledWith(2);
  expect(callback).toHaveBeenCalledWith(3);
});

vi.fn()은 “감시되는 가짜 함수”. 호출됐는지, 몇 번, 어떤 인자로 — 모두 추적됩니다. 반환값을 정의할 수도 있습니다.

반환값 정의
const fetchUser = vi.fn().mockReturnValue({ id: 1, name: 'Alice' });
expect(fetchUser()).toEqual({ id: 1, name: 'Alice' });

// 비동기
const fetchUser = vi.fn().mockResolvedValue({ id: 1 });
expect(await fetchUser()).toEqual({ id: 1 });

커버리지 — 켜는 법 #

@vitest/coverage-v8 한 패키지면 충분합니다.

설치
pnpm add -D @vitest/coverage-v8

vitest.config.ts에 coverage 옵션:

coverage 설정
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'json'],
      exclude: ['node_modules/', 'src/test/', 'src/**/*.test.{ts,tsx}'],
    },
  },
});
실행
pnpm coverage
# 콘솔에 표 + coverage/index.html 생성
open coverage/index.html

브라우저로 열면 어떤 줄이 안 도는지 색으로 구분됩니다. 숫자에 매달리지 말고 (#1 의 교훈) 의도하지 않게 빠진 부분만 보세요.

흔한 함정 #

테스트가 import 단계에서 실패vitest.config.tsenvironment 설정 확인. DOM API (window, document)를 쓰는 코드를 'node' 환경에서 import 하면 즉시 실패.

describe/it가 not definedglobals: true 빠뜨림 또는 tsconfig.jsontypes 미설정. 또는 명시적으로 import { describe, it, expect } from 'vitest'.

watch 모드가 멈춰 있음 — Docker / WSL / 일부 파일시스템에서 fs 변경 감지가 안 될 때. vitest --watch --watchExclude 또는 polling 옵션으로 해결.

테스트가 서로 영향을 준다 — 모듈 레벨 변수에 mutate 한 흔적. beforeEach로 초기화하거나, 모듈 레벨 mutable state 자체를 줄이세요.

ESM 패키지 import 에러 — 일부 라이브러리는 deps optimization이 필요. vitest.config.tsoptimizeDeps 설정으로 해결 (Vite와 같은 옵션).

정리 #

  • Vitest 셋업의 핵심은 vitest.config.ts 한 파일 — globals: true + environment: 'jsdom' + setupFiles 셋만 잡으면 됨.
  • describe / it / expect가 모든 테스트의 골격. 이름은 동작을 한 문장 으로 — 테스트 이름이 문서가 됨.
  • toBe는 primitive와 참조 동등성, toEqual은 깊은 동등성. 객체/배열은 거의 항상 toEqual.
  • watch 모드의 f (실패 테스트만 다시) 는 큰 프로젝트에서 매우 강력한 기능입니다.
  • vi.fn()으로 콜백 / 의존성 모킹. mockReturnValue / mockResolvedValue로 반환값 정의.
  • 비동기 테스트의 expect 누락 함정은 expect.assertions(N) 또는 .rejects / .resolves 매처로.
  • 커버리지는 v8 provider 한 줄로 켜짐. 숫자가 아니라 빠진 부분만 보기.

다음 글(#3 React Testing Library)에서는 컴포넌트로. RTL의 철학(“사용자처럼 본다”), render / screen / queries (getByRole, getByLabelText) 의 쓰임을 한 컴포넌트씩 익힙니다.

X