4.Node.js/Vitest&TypeBox

[Vitest] 4편. Vitest로 Web API 테스트하기: Fastify inject() 활용법

쿼드큐브 2026. 1. 30. 06:44
반응형
반응형

 

4편. Vitest로 Web API 테스트하기: Fastify inject() 활용법

 

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

 

fastify inject 활용법 삽화 이미지
fastify inject 활용법 삽화 이미지

 

📂 [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 도구의 도움을 받아 생성되거나 다듬어졌습니다.

반응형

 

반응형