4편. Vitest로 Web API 테스트하기: Fastify inject() 활용법
📚 목차
1. Fastify(5.6) + Vitest 기본 구조 이해하기
2. 다양한 입력 유형 테스트 예시: Query, Path, Body, Header
3. 인증/인가 API 테스트 예시
4. 에러 응답 및 예외 상황 테스트 예시

📂 [GitHub 예시 코드 보러가기] (https://github.com/cericube/nodejs-tutorials) /vitest
1. Fastify(5.6) + Vitest 기본 구조 이해하기
1. Fastify 설치
# 1. 경로 이동
cd /nodejs-tutorials/vitest
# 2. fastify 설치
npm install fastify
# 3. 설치 확인
/nodejs-tutorials/vitest> npm list
nodejs-tutorials@1.0.0 D:\NodejsDevelope\workspace\nodejs-tutorials
└─┬ vitest-study@1.0.0 -> .\vitest
├── @vitest/ui@4.0.16
├── fastify@5.6.2
└── vitest@4.0.16
2. inject() 테스트 방식 이해
📌 inject()란 무엇인가?
inject()는 실제 TCP 포트로 HTTP 요청을 보내는 것이 아니라,
Fastify 내부의 라우팅 → 훅 → 핸들러 → 직렬화 파이프라인을 메모리 상에서 그대로 통과시키는 가상 요청 실행기
▸ Controller / Route / Validation / Auth 흐름 검증
▸ DB Mock 또는 Test DB 연동 통합 테스트
▸ 비즈니스 로직 검증 에 유리
| 항목 | HTTP 테스트 | inject 테스트 |
| 서버 listen 필요 | O | X |
| 포트 충돌 | 가능 | 없음 |
| 네트워크 스택 | 포함 | 없음 |
| 테스트 속도 | 느림 | 매우 빠름 |
| CI 안정성 | 환경 의존 | 매우 안정 |
3. Fastify 서버 기본 구조
Fastify 인스턴스를 별도의 빌더 함수로 분리합니다.
이 구조는 테스트에서 inject()로 요청을 처리하는 데 매우 중요합니다.
// /src/ch04/4-1-1.app.ts
// fastify 패키지의 default export를 가져온다.
import Fastify from 'fastify';
// 서버 인스턴스를 생성하는 팩토리 함수
export function buildApp() {
// Fastify 서버 인스턴스 생성
// 이 시점에는 아직 포트를 열지 않고, 라우트와 플러그인만 등록하는 단계
const app = Fastify();
// GET /health 엔드포인트 등록
app.get('/health', async () => {
// Fastify는 return 값을 자동으로 JSON 응답으로 직렬화하여 전송한다.
return { ok: true };
});
// 구성된 Fastify 인스턴스를 반환
return app;
}
실무에서는 라우트를 app.ts에 직접 선언하기보다는, 각 API를 Fastify Plugin 형태의 모듈로 분리한 후 register() 메서드를 통해 조립하는 방식을 일반적으로 사용합니다.
# 1) 라우트 플러그인 (routes/health.route.ts)
import { FastifyPluginAsync } from 'fastify';
export const healthRoute: FastifyPluginAsync = async (app) => {
app.get('/health', async () => {
return { ok: true };
});
};
# 2) 애플리케이션 구성 (app.ts)
import Fastify from 'fastify';
import { healthRoute } from './routes/health.route';
export function buildApp() {
const app = Fastify({
logger: true,
});
// 라우트 플러그인을 register()로 조립
app.register(healthRoute);
return app;
}
4. app.route() 제네릭 구조 이해
app.post() 등은 app.route()를 사용하기 편하게 줄여놓은 단축 문법(Shorthand)일 뿐입니다.
import type { FastifyInstance } from 'fastify';
/* ------------------------------------------------
* 1. Route 전용 타입 정의 (RouteGenericInterface)
* --------------- */
type MultiRoute = {
Querystring: { verbose?: boolean };
Body: { title: string };
Headers: { 'x-request-id': string };
Reply: { message: string };
};
export function registerMultiInputRoute(app: FastifyInstance) {
app.route<MultiRoute>({
method: 'POST',
url: '/multi',
/* --------------------------------------------
* JSON Schema 정의 영역
* 이 스키마는 런타임 Validation + 타입 coercion 담당
* -------------------------------------------- */
schema: {
// Query String Validation
querystring: {
type: 'object',
properties: {
verbose: { type: 'boolean' },
},
},
// Request Body Validation
body: {
type: 'object',
required: ['title'],
properties: {
title: { type: 'string' },
},
},
// Request Headers Validation
// Fastify는 기본적으로 schema에 없는 header를 거부하므로
// additionalProperties: true 필수
headers: {
type: 'object',
required: ['x-request-id'],
properties: {
'x-request-id': { type: 'string' },
},
additionalProperties: true,
},
// Response Schema(선택)
response: {
200: {
type: 'object',
properties: {
message: { type: 'string' },
},
},
},
},
// 실제 비즈니스 로직 Handler
// 타입 자동 추론
handler: async (request, reply) => {
const { verbose } = request.query;
const { title } = request.body;
const requestId = request.headers['x-request-id'];
return {
message: verbose ? `Verbose: ${title}` : title,
};
},
});
}
📌 TypeBox를 사용하면 하나의 정의로 타입과 스키마를 동시에 생성할 수 있어 Fastify 커뮤니티에서 매우 권장됩니다.
import { Type } from '@sinclair/typebox'
// 스키마와 타입을 동시에 생성
const BodySchema = Type.Object({
title: Type.String()
})
// 핸들러에서 사용
// const title = request.body.title -> 자동 추론
4. 테스트 기본 템플릿
Fastify 인스턴스는 테스트마다 생성하고 종료해야 합니다.
// /tests/ch04/4-1-1.health.test.ts
import { describe, beforeAll, afterAll, it, expect } from 'vitest';
import { buildApp } from '../../src/ch04/4-1-1.app';
import type { FastifyInstance } from 'fastify';
let app: FastifyInstance;
describe('API Tests', () => {
// Fastify 인스턴스를 생성하고 내부 플러그인 로딩을 완료한다.
beforeAll(async () => {
app = buildApp();
// register된 플러그인과 라우트가 모두 준비될 때까지 대기
await app.ready();
});
// Fastify 인스턴스를 정리하여 리소스(타이머, 커넥션 등)를 해제한다.
afterAll(async () => {
await app.close();
});
it('GET /health should work', async () => {
// 실제 HTTP 요청을 보내지 않고,
// Fastify 내부 라우터로 직접 요청을 주입(inject)한다.
const res = await app.inject({
method: 'GET',
url: '/health',
});
//
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toMatch(/application\/json/);
expect(res.json()).toEqual({ ok: true });
});
});
2. 다양한 입력 유형 테스트 예시: Query, Path, Body, Header
1956년 다트머스 회의에서 시작된 인공지능은, 여러 번의 ‘AI 겨울’을 겪으면서도 꾸준히 발전해 왔습니다. 특히 2010년대 이후 머
참고 소스:
src/4-2-1.다양한입력유형.app.ts
tests/4-2-1.다양한입력유형테스트.test.ts
1. Query Parameter 테스트 예시
Fastify는 각 요청에서 request.query를 통해 쿼리 파라미터를 읽을 수 있으며, 필요시 JSON Schema를 활용한 유효성 검사도 적용할 수 있습니다.
///////////////////////////////
// Query String 처리 예제
///////////////////////////////
export function registerSearchRoute(app: FastifyInstance) {
app.route({
method: 'GET', // HTTP 메서드
url: '/search', // 최종 라우트 경로: GET /search
handler: async (request: FastifyRequest, reply: FastifyReply) => {
// TypeScript 타입은 자동 추론되지 않으므로 수동 캐스팅
const query = request.query as { q: string };
// reply.send() 또는 return 둘 다 가능
return reply.send({ result: query.q });
},
});
}
///////////////////////////////
// 테스트 코드
///////////////////////////////
it('1. 쿼리 파라미터에 대해 올바른 결과를 반환한다', async () => {
const res = await app.inject({
method: 'GET',
url: '/search?q=fastify한글',
});
//
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ result: 'fastify한글' });
});
2. Path Parameter 테스트
///////////////////////////////
// Path Parameter 처리 예제
///////////////////////////////
export function registerUsersRoute(app: FastifyInstance) {
app.route({
method: 'GET',
url: '/users/:id', // :id 는 path parameter
handler: async (request: FastifyRequest) => {
const params = request.params as { id: string };
return { id: params.id };
},
});
}
///////////////////////////////
// 테스트 코드
///////////////////////////////
it('2. 경로 파라미터에 대해 올바른 결과를 반환한다.', async () => {
const res = await app.inject({
method: 'GET',
url: '/users/123',
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ id: '123' });
});
3. JSON Body(POST) 테스트
///////////////////////////////
// Request Body(JSON) 처리 예제
///////////////////////////////
export function registerEchoRoute(app: FastifyInstance) {
app.route({
method: 'POST',
url: '/echo',
handler: async (request: FastifyRequest) => {
const body = request.body as { name: string; age?: number };
return body; // 그대로 echo 응답
},
});
}
///////////////////////////////
// 테스트 코드
///////////////////////////////
it('3. JSON Body 에 대해 올바른 결과를 반환한다.', async () => {
const res = await app.inject({
method: 'POST',
url: '/echo',
body: { name: 'Alice한글', age: 30 },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ name: 'Alice한글', age: 30 });
});
4. Header 기반 테스트
///////////////////////////////
// Header 읽기 예제
///////////////////////////////
export function registerWhoamiRoute(app: FastifyInstance) {
app.route({
method: ['GET', 'POST'], // 여러 HTTP 메서드 허용 가능
url: '/whoami',
handler: async (request: FastifyRequest) => {
// 모든 헤더는 소문자로 normalize 되어 있음
const agent = request.headers['user-agent'] ?? 'unknown';
return { userAgent: agent };
},
});
}
///////////////////////////////
// 테스트 코드
///////////////////////////////
it('4. Custom Header 를 읽고 결과를 반환한다.', async () => {
const res = await app.inject({
method: 'POST',
url: '/whoami',
headers: {
'user-agent': 'VitestClient/1.0',
},
});
expect(res.statusCode).toBe(200);
expect((res.json()).userAgent).toBe('VitestClient/1.0');
});
3. 인증/인가 API 테스트 예시
📌 preHandler란?
preHandler는 요청이 실제 route handler에 도달하기 전에 실행되는 미들웨어 함수입니다.
Fastify에서는 인증/인가, 로깅, 요청 전처리 등을 이 위치에서 처리하는 것이 일반적입니다.
onRequest
→ preParsing
→ preValidation
→ preHandler ← 인증/인가 위치
→ handler
→ response
export async function verifyToken(request: FastifyRequest, reply: FastifyReply) {
const auth = request.headers.authorization;
if (!auth) {
return reply.status(401).send({ code: 'UNAUTHORIZED', message: '토큰이 필요합니다.' });
}
if (auth !== 'Bearer valid-token') {
return reply.status(403).send({ code: 'FORBIDDEN', message: '권한이 없습니다.' });
}
// 인증 통과 시 사용자 정보 설정
(request as any).user = { id: 1, name: 'Jane' };
}
//
export async function meRoutes(app: FastifyInstance) {
app.route({
method: 'GET',
url: '/me',
preHandler: verifyToken,
handler: async (req, reply) => {
// 인증된 사용자 정보 반환
return { user: (req as any).user };
},
});
}
테스트 코드 예시
it('401: Authorization 헤더가 없으면 인증 실패', async () => {
const res = await app.inject({
method: 'GET',
url: '/me',
});
expect(res.json()).toEqual({
code: 'UNAUTHORIZED',
message: '토큰이 필요합니다.',
});
});
//
it('403: 잘못된 토큰이면 접근 금지', async () => {
const res = await app.inject({
method: 'GET',
url: '/me',
headers: { authorization: 'Bearer wrong-token' },
});
expect(res.statusCode).toBe(403);
expect(await res.json()).toEqual({
code: 'FORBIDDEN',
message: '권한이 없습니다.',
});
});
4. 에러 응답 및 예외 상황 테스트 예시
1. 공통 에러 응답 포맷 설계
{
"error": "ValidationError",
"code": "VALIDATION_ERROR",
"message": "수량은 1 이상이어야 합니다",
"details": { ... } // optional
}
2. 모든 비즈니스 에러는 AppError를 상속받아 정의한다.
export class AppError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: unknown,
) {
super(message);
this.name = 'AppError';
}
}
3. 파생 에러 클래스 예시
| 클래스 | HTTP | code |
| ValidationError | 400 | VALIDATION_ERROR |
| NotFoundError | 404 | NOT_FOUND |
| ConflictError | 409 | CONFLICT |
| UnauthorizedError | 401 | UNAUTHORIZED |
| ForbiddenError | 403 | FORBIDDEN |
| InternalServerError | 500 | INTERNAL_SERVER_ERROR |
4. Fastify 전역 Error Handler 구조
Fastify는 전역 에러 핸들러를 통해 모든 예외를 한 곳에서 처리할 수 있다.
app.setErrorHandler(errorHandler);
app.setNotFoundHandler(notFoundHandler);
▸ AppError 처리
if (error instanceof AppError) {
const payload: Record<string, unknown> = {
error: error.name,
code: error.code,
message: error.message,
};
if (error.details !== undefined) {
payload.details = error.details;
}
return reply.code(error.statusCode).send(payload);
}
▸ Fastify Validation Error 처리
Fastify의 schema validation 실패 시 error.validation 필드가 존재한다.
Zod / JSON Schema 기반 검증과 수동 ValidationError를 동일한 에러 코드 체계로 통합
if (error.validation) {
return reply.code(400).send({
error: 'ValidationError',
code: 'VALIDATION_ERROR',
message: '입력 데이터가 유효하지 않습니다',
details: {
validation: error.validation,
validationContext: error.validationContext,
},
});
}
5. 테스트 코드 예시
it('quantity가 0 이하면 400을 반환합니다', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/orders',
payload: {
productId: 'prod-1',
quantity: 0,
userId: 'user-1',
},
});
expect(response.statusCode).toBe(400);
const body = response.json();
expect(body.message).toContain('1 이상');
expect(body.details.field).toBe('quantity');
expect(body.details.constraint).toContain('min: 1');
});
※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.
'4.Node.js > Vitest&TypeBox' 카테고리의 다른 글
| [Vitest] 6편. MSW(Mock Service Worker) + Vitest 실무 API 테스트 (0) | 2026.02.06 |
|---|---|
| [Vitest] 5편. Vitest로 외부 API 테스트하기: axios, undici (0) | 2026.02.05 |
| [Vitest] 3편. Vitest의 Lifecycle · Async · Execution Control API (0) | 2026.01.28 |
| [Vitest] 2편. Vitest 테스트 구조 설계와 Assertion, Matcher 이해하기 (0) | 2026.01.27 |
| [Vitest] 1편. Vitest 4로 Node.js 테스트 환경 구축하기 (0) | 2026.01.26 |