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.ok를 expect로 검증하고, narrowing 후에 result.value나 result.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); });});
supertest의 request(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 앱이 완성됩니다. 타입이 바뀌면 양쪽 모두 컴파일러가 알려줍니다.