iBetter Books
수정

Ch 06. Vitest로 타입 안전한 테스트 작성

코드를 짰다고 끝이 아닙니다. 코드가 의도대로 동작하는지 확인해야 합니다. 테스트는 그 확인입니다. TypeScript 프로젝트에서 테스트도 타입 안전해야 합니다. 테스트 코드에서 타입 오류가 발생하면 그것도 버그입니다.

Vitest는 Vite 기반의 테스트 프레임워크입니다. TypeScript를 네이티브로 지원하고, Jest와 API가 거의 같아서 Jest를 써봤다면 바로 쓸 수 있습니다. 실행 속도는 훨씬 빠릅니다.

Vitest 설치 및 설정

npm install -D vitest supertest @types/supertest

supertest는 HTTP 요청을 실제 네트워크 없이 테스트할 수 있는 라이브러리입니다. Express 앱을 직접 넘기면 포트 없이 요청을 보낼 수 있습니다.

// 새 파일: vitest.config.tsimport { defineConfig } from 'vitest/config';export default defineConfig({  test: {    globals: true,  // describe, it, expect 등을 import 없이 전역으로 사용    environment: 'node',    setupFiles: ['./src/test/setup.ts'],    coverage: {      provider: 'v8',      reporter: ['text', 'html'],    },  },});
// 새 파일: src/test/setup.tsimport { prisma } from '../db/prisma.js';beforeEach(async () => {  // 각 테스트 전 데이터 초기화  await prisma.todo.deleteMany();});afterAll(async () => {  await prisma.$disconnect();});

beforeEach로 각 테스트 전에 데이터베이스를 초기화합니다. 테스트 간 데이터가 공유되면 순서에 따라 결과가 달라지는 불안정한 테스트가 됩니다. 각 테스트는 독립적이어야 합니다.

package.json에 테스트 스크립트를 추가합니다.

// 수정: package.json{  "name": "todo-api",  "version": "1.0.0",  "scripts": {    "dev": "nodemon --watch src --ext ts --exec tsx src/index.ts",    "build": "tsc",    "start": "node dist/index.js",    "typecheck": "tsc --noEmit",    "test": "vitest",    "test:run": "vitest run",    "test:coverage": "vitest run --coverage"  },  "dependencies": {    "express": "^5.0.0",    "@prisma/client": "^5.0.0"  },  "devDependencies": {    "@types/express": "^5.0.0",    "@types/node": "^22.0.0",    "@types/supertest": "^6.0.0",    "nodemon": "^3.0.0",    "prisma": "^5.0.0",    "supertest": "^7.0.0",    "tsx": "^4.0.0",    "typescript": "^5.0.0",    "vitest": "^2.0.0"  }}

서비스 레이어 단위 테스트

서비스 함수를 직접 테스트합니다. HTTP 레이어를 거치지 않아 빠릅니다.

// 새 파일: src/test/todoService.test.tsimport { findAllTodos, findTodoById, createTodo, updateTodo, deleteTodo } from '../services/todoService.js';import { NotFoundError } from '../errors/AppError.js';describe('todoService', () => {  describe('createTodo', () => {    it('새 할 일을 생성한다', async () => {      const result = await createTodo({ title: '테스트 할 일' });      expect(result.ok).toBe(true);      if (!result.ok) return;      expect(result.value.title).toBe('테스트 할 일');      expect(result.value.completed).toBe(false);      expect(result.value.id).toBeTypeOf('number');    });  });  describe('findTodoById', () => {    it('존재하는 id로 할 일을 조회한다', async () => {      const created = await createTodo({ title: '조회 테스트' });      if (!created.ok) throw new Error('생성 실패');      const result = await findTodoById(created.value.id);      expect(result.ok).toBe(true);      if (!result.ok) return;      expect(result.value.id).toBe(created.value.id);      expect(result.value.title).toBe('조회 테스트');    });    it('존재하지 않는 id로 조회하면 NotFoundError를 반환한다', async () => {      const result = await findTodoById(99999);      expect(result.ok).toBe(false);      if (result.ok) return;      expect(result.error).toBeInstanceOf(NotFoundError);      expect(result.error.statusCode).toBe(404);    });  });  describe('updateTodo', () => {    it('할 일 완료 상태를 변경한다', async () => {      const created = await createTodo({ title: '수정 테스트' });      if (!created.ok) throw new Error('생성 실패');      const result = await updateTodo(created.value.id, { completed: true });      expect(result.ok).toBe(true);      if (!result.ok) return;      expect(result.value.completed).toBe(true);    });  });  describe('deleteTodo', () => {    it('할 일을 삭제하면 다시 조회할 수 없다', async () => {      const created = await createTodo({ title: '삭제 테스트' });      if (!created.ok) throw new Error('생성 실패');      const deleted = await deleteTodo(created.value.id);      expect(deleted.ok).toBe(true);      const found = await findTodoById(created.value.id);      expect(found.ok).toBe(false);    });  });});

Result 패턴 덕분에 테스트 코드가 명확합니다. result.okexpect로 검증하고, narrowing 후에 result.valueresult.error에 접근합니다. TypeScript가 result.ok를 확인하지 않으면 result.value에 접근하지 못하게 막기 때문에, 테스트 코드도 타입 안전합니다.

API 엔드포인트 통합 테스트

// 새 파일: src/test/todos.api.test.tsimport request from 'supertest';import app from '../index.js';describe('GET /todos', () => {  it('빈 배열을 반환한다', async () => {    const res = await request(app).get('/todos');    expect(res.status).toBe(200);    expect(res.body).toEqual({ data: [] });  });  it('생성한 할 일 목록을 반환한다', async () => {    await request(app)      .post('/todos')      .send({ title: '첫 번째 할 일' });    const res = await request(app).get('/todos');    expect(res.status).toBe(200);    expect(res.body.data).toHaveLength(1);    expect(res.body.data[0].title).toBe('첫 번째 할 일');  });});describe('POST /todos', () => {  it('새 할 일을 생성하고 201을 반환한다', async () => {    const res = await request(app)      .post('/todos')      .send({ title: '새로운 할 일' });    expect(res.status).toBe(201);    expect(res.body.data.title).toBe('새로운 할 일');    expect(res.body.data.completed).toBe(false);    expect(res.body.message).toBe('할 일이 생성되었습니다.');  });  it('title이 없으면 400을 반환한다', async () => {    const res = await request(app)      .post('/todos')      .send({});    expect(res.status).toBe(400);    expect(res.body.error).toBe('VALIDATION_ERROR');  });});describe('GET /todos/:id', () => {  it('존재하는 할 일을 반환한다', async () => {    const created = await request(app)      .post('/todos')      .send({ title: '조회할 할 일' });    const { id } = created.body.data;    const res = await request(app).get(`/todos/${id}`);    expect(res.status).toBe(200);    expect(res.body.data.id).toBe(id);  });  it('존재하지 않는 id는 404를 반환한다', async () => {    const res = await request(app).get('/todos/99999');    expect(res.status).toBe(404);    expect(res.body.error).toBe('NOT_FOUND');  });});describe('PATCH /todos/:id', () => {  it('할 일 완료 상태를 변경한다', async () => {    const created = await request(app)      .post('/todos')      .send({ title: '수정할 할 일' });    const { id } = created.body.data;    const res = await request(app)      .patch(`/todos/${id}`)      .send({ completed: true });    expect(res.status).toBe(200);    expect(res.body.data.completed).toBe(true);  });});describe('DELETE /todos/:id', () => {  it('할 일을 삭제하고 204를 반환한다', async () => {    const created = await request(app)      .post('/todos')      .send({ title: '삭제할 할 일' });    const { id } = created.body.data;    const res = await request(app).delete(`/todos/${id}`);    expect(res.status).toBe(204);  });});

supertestrequest(app)은 실제 포트를 열지 않습니다. Express 앱 인스턴스를 직접 받아서 HTTP 요청을 시뮬레이션합니다. 테스트 실행이 빠르고 포트 충돌 걱정이 없습니다.

타입 테스트

Vitest는 expectTypeOf로 타입 자체를 테스트할 수 있습니다. 타입이 의도한 대로 추론되는지 검증합니다.

// 새 파일: src/test/types.test.tsimport { expectTypeOf } from 'vitest';import type { Todo, ApiResponse } from '@todo/types';import { ok, err } from '../utils/result.js';describe('타입 테스트', () => {  it('ok()는 Result<T, never>를 반환한다', () => {    const result = ok(42);    expectTypeOf(result).toMatchTypeOf<{ ok: true; value: number }>();  });  it('err()는 Result<never, E>를 반환한다', () => {    const result = err(new Error('오류'));    expectTypeOf(result).toMatchTypeOf<{ ok: false; error: Error }>();  });  it('ApiResponse<Todo[]>의 data는 Todo 배열이다', () => {    expectTypeOf<ApiResponse<Todo[]>>().toHaveProperty('data');    expectTypeOf<ApiResponse<Todo[]>>().toMatchTypeOf<{ data: Todo[] }>();  });  it('Todo의 id는 number 타입이다', () => {    expectTypeOf<Todo>().toHaveProperty('id').toBeNumber();  });  it('Todo의 completed는 boolean 타입이다', () => {    expectTypeOf<Todo>().toHaveProperty('completed').toBeBoolean();  });});

expectTypeOf는 런타임이 아니라 컴파일 타임에 동작합니다. 타입이 맞지 않으면 테스트가 컴파일 단계에서 실패합니다. 제네릭 유틸리티 타입이나 타입 변환 함수를 작성할 때 의도한 대로 타입이 추론되는지 검증하는 데 유용합니다.

테스트 실행

# 감시 모드 (파일 변경 시 자동 재실행)npm test# 한 번만 실행npm run test:run# 커버리지 포함npm run test:coverage

테스트가 모두 통과하면 터미널에 녹색 체크가 표시됩니다.

✓ src/test/types.test.ts (5)
✓ src/test/todoService.test.ts (5)
✓ src/test/todos.api.test.ts (8)

Test Files  3 passed (3)
Tests      18 passed (18)

프로젝트 완성

PART 08에서 Express + TypeScript 서버를 처음부터 완성했습니다. 각 챕터가 하나의 레이어를 담당했습니다.

  • Ch 01 — 프로젝트 초기화, tsconfig, 핫 리로드
  • Ch 02 — Request 제네릭으로 라우트 타이핑, 미들웨어 체인
  • Ch 03 — Prisma 스키마에서 타입 자동 생성
  • Ch 04 — 공유 타입 패키지로 프론트-백 타입 연결
  • Ch 05 — 커스텀 에러 클래스, Result 패턴
  • Ch 06 — Vitest로 단위/통합/타입 테스트

PART 07의 React 앱과 이 API 서버를 연결하면 프론트엔드와 백엔드가 같은 타입을 공유하는 풀스택 TypeScript 앱이 완성됩니다. 타입이 바뀌면 양쪽 모두 컴파일러가 알려줍니다.