4.Node.js/Vitest&TypeBox

[Vitest] 6편. MSW(Mock Service Worker) + Vitest 실무 API 테스트

쿼드큐브 2026. 2. 6. 07:46
반응형
반응형

 

6편. MSW(Mock Service Worker) + Vitest 실무 API 테스트

 

📚 목차
1. 테스트에서 네트워크 흐름을 제어하는 MSW의 역할
2. Vitest 환경에 MSW 설치 및 설정
3. 핸들러 설계: 외부 API 응답 코드 구현 예시
4. Fastify 서버와 함께하는 End-to-End 통합 테스트 예시

 

📂 [GitHub 예시 코드 보러가기] (https://github.com/cericube/nodejs-tutorials) /vitest

 

1. 테스트에서 네트워크 흐름을 제어하는 MSW의 역할

MSW(Mock Service Worker)는 이름 때문에 흔히 "가짜 API 서버를 띄워주는 도구"로 오해받곤 합니다.

하지만 MSW의 본질은 서버 구현이 아니라, 네트워크 요청을 가로채(Intercept) 응답을 교체하는 '스위치' 역할을 수행하는 데 있습니다.

 

1. 네트워크 요청 가로채기(Interception) 구조 이해

일반적인 Node.js 환경에서 HTTP 요청이 발생하는 흐름은 다음과 같습니다.
▸ Application Code: 개발자가 작성한 비즈니스 로직
▸ HTTP Client: axios, fetch, undici 등
▸ Node.js HTTP Module / Socket: 저수준 네트워크 처리
▸ OS Network Stack / Actual Network: 실제 외부 세계로의 송출

 

MSW가 개입하는 지점

MSW는 HTTP Client 아래, 실제 네트워크 소켓으로 나가기 직전 지점을 가로챕니다.

애플리케이션 입장에서는 요청을 정상적으로 보냈고 응답을 받은 것처럼 보이지만, 실제로 데이터는 물리적인 네트워크 선을 타지 않습니다.

[Fastify API]
   ↓
[Service / Domain]
   ↓
[HTTP Client]
   ↓
[MSW]

 

2. vi.mock() vs MSW 비교

구분 vi.mock MSW
개입 지점 함수 호출 레벨 네트워크 레벨
HTTP 요청 발생하지 않음 실제로 발생
검증 대상 로직 중심 통신 포함 통합 흐름
코드 침투 import 구조에 영향 전혀 없음
역할 내부 의존성 단위 테스트 외부 API 의존성 통합 테스트

 

🔸 vi.mock() : 함수 호출 자체를 교체
모듈 로딩 단계에서 특정 함수 자체를 가짜(Mock)로 갈아끼웁니다. 실제 HTTP 클라이언트 함수는 호출조차 되지 않습니다.
▸ 검증 범위: 비즈니스 로직 위주.

▸ 단점: URL 파라미터 구성, 헤더 설정, 응답 파싱 로직 등 '통신 과정' 자체는 검증할 수 없습니다.

vi.mock('../userApi', () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'test' })
}))

 

🔸 MSW : 실제 HTTP 요청을 가로채 응답만 교체
HTTP 요청 함수는 실제로 실행됩니다. 요청 객체가 생성되고 헤더가 붙으며 전송되기 직전까지의 모든 과정이 실제와 동일합니다.
▸ 검증 범위: 로직 + HTTP Client 설정 + Request/Response 처리 전 과정.
▸ 장점: 실제 서비스의 통신 흐름을 가장 가깝게 재현합니다.

await axios.get('https://api.external.com/users/1')

 

3. 네트워크 스위치 모델

MSW(Node)의 제어는 매우 단순한 API로 이루어집니다. 이 API들은 테스트 환경 전체의 네트워크 경로를 결정하는 '스위치' 역할을 합니다.
🔸server.listen() (Switch ON): 네트워크 가로채기 활성화. 모든 요청은 MSW 핸들러로 향합니다.
🔸server.close() (Switch OFF): 가로채기 종료. 다시 실제 인터넷 세상으로 요청이 나갑니다.

 

이 구조의 핵심 가치는 "테스트 대상 코드는 환경에 따라 단 한 줄도 바뀌지 않는다"는 것입니다.

이는 리팩토링 시 테스트 수정 비용을 최소화하고, 환경 차이로 인해 발생하는 '통과했는데 운영에선 안 돼요' 같은 버그를 획기적으로 줄여줍니다.

 

2. Vitest 환경에 MSW 설치 및 설정

네트워크 요청을 가로채는 MSW는 테스트 환경에서 '절대 고장 나지 않는 스위치' 역할을 합니다.

 

1. 패키지 설치 (Mock Service Worker)

cd /nodejs-tutorials/vitest

npm install -D msw

PS D:\NodejsDevelope\workspace\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
  ├── axios@1.13.2
  ├── fastify@5.6.2
  ├── msw@2.12.7
  ├── undici@7.18.2
  └── vitest@4.0.16

 

2. Node 전용 MSW 서버 생성

Node.js 환경에서는 브라우저의 Service Worker 대신 네트워크 모듈 자체를 확장하는 방식을 사용합니다.

 

🔸 실행 환경별 MSW 초기화 방식

환경 사용 API
Browser setupWorker()
Node (Test) setupServer()

Node.js 환경(Vitest, Jest 등)에서는 setupServer를 사용합니다. 이는 JSDOM 같은 브라우저 환경 모사 여부와 상관없이, Node의 http, https 모듈을 직접 가로채기 때문에 가장 강력하고 안정적입니다.

 

🔸MSW 서버 인스턴스 생성 - server.ts

MSW 서버는 테스트 전체에서 단 하나의 인스턴스만 생성하는 것이 원칙입니다.
그리고 이 서버는 실제 포트를 열지 않습니다. 오직 네트워크 interception만 담당합니다.

// msw/node 패키지에서 Node 전용 MSW 서버 생성 함수를 가져옵니다.
// 이 서버는 실제 HTTP 포트를 열지 않고,
// Node의 네트워크 계층에서 요청을 가로채는(intercept) 역할만 수행합니다.
import { setupServer } from 'msw/node'

// 외부 API 역할을 할 요청 핸들러들입니다.
import { userHandlers } from './5-2-1.example.handlers'

// setupServer에 핸들러 배열 전달
export const server = setupServer(...userHandlers)

 

🔸핸들러 - handlers.ts

import { http, HttpResponse } from 'msw';

export const userHandlers = [
  // 1. GET 요청 및 Query Parameter 처리
  http.get('https://api.example.com/users', ({ request }) => {
    const url = new URL(request.url);
    const role = url.searchParams.get('role');
    return HttpResponse.json([{ id: 1, name: 'Alice', role: role || 'user' }]);
  }),

  // 2. POST 요청 및 Request Body 검증
  http.post('https://api.example.com/users', async ({ request }) => {
    const newUser = await request.json();
    if (!(newUser as any).name) {
      return new HttpResponse(null, { status: 400 });
    }
    return HttpResponse.json({ id: 2, ...(newUser as any) }, { status: 201 });
  }),
];

 

🔸테스트 전역에서 스위치 제어 - setup.ts

MSW 서버는 각 테스트 파일에서 개별적으로 켜고 끄지 않습니다.
반드시 전역 테스트 Lifecycle에서 한 번만 제어해야 합니다

import { beforeAll, afterAll, afterEach } from 'vitest';
import { server } from './5-2-1.server';

//네트워크 interception ON
beforeAll(() => {
  server.listen({
    // onUnhandledRequest: 'error'
    // → Mock 안 된 요청이 실제 네트워크로 나가는 사고를 원천 차단
    onUnhandledRequest: 'error', // ★ 실무 필수
  });
});

//핸들러 override 초기화
afterEach(() => {
  server.resetHandlers();
});

//네트워크 interception OFF
afterAll(() => {
  server.close();
});

 

🔸 onUnhandledRequest: 'error' → Mock 안 된 요청이 실제 네트워크로 나가는 사고를 원천 차단

▸ Mock 핸들러가 없는 HTTP 요청이 발생했는데, onUnhandledRequest: 'error' 설정 때문에 허용하지 않고 즉시 에러를 발생시킵니다.

잘못된 외부 요청 오류 화면
잘못된 외부 요청 오류 화면

 

🔸Vitest 설정과 자동 초기화 연결 - vite.config.ts

MSW 서버가 모든 테스트 전에 자동으로 켜지려면 Vitest가 setup.ts 파일을 전역 setup으로 인식해야 합니다.

이 설정이 있어야 모든 테스트 전에 MSW 서버가 자동으로 켜집니다

▸ 각 테스트 파일 실행 전에 tests/setup.ts가 자동으로 로드됨
▸ MSW 서버가 항상 먼저 listen 상태가 됨

import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environment: 'node',
    setupFiles: ['./tests/setup.ts'],
  },
})

 

3. 테스트 인프라 구조

Vitest 실행
   ↓
setup.ts 실행
   → server.listen (네트워크 차단 ON)
   ↓
각 테스트 실행
   → axios / fetch 실제 호출
   → MSW가 네트워크 레벨에서 가로채기
   ↓
테스트 종료
   → server.resetHandlers (상태 초기화)
   ↓
전체 종료
   → server.close (차단 해제)
반응형

 

3. 핸들러 설계: 외부 API 응답 코드 구현 예시

MSW 핸들러는 단순한 가짜 데이터가 아니라, "외부 API가 특정 요청에 대해 어떻게 응답할 것인가"라는 약속(Contract)을 코드로 표현한 것입니다.

 

1. API 명세를 코드로 표현하기: 기본 구조

MSW v2는 http와 HttpResponse를 사용하여 선언적인 인터페이스를 제공합니다.

핸들러의 목적은 요청을 해석하고, 그에 맞는 HTTP 응답을 반환하는 것입니다.

request.json()은 비동기로 동작하며, 필요에 따라 사용자 정의 헤더를 포함할 수 있습니다.

 

▸ GET 응답: 데이터 조회

import { http, HttpResponse } from 'msw'

export const userHandlers = [
  http.get('https://api.external.com/users/:id', ({ params }) => {
    const { id } = params // Path Parameter 추출
    return HttpResponse.json(
      {
        id: Number(id),
        name: 'Test User',
        email: 'test@example.com',
      },
      { status: 200 },
    )
  }),
]

 

▸ POST 응답: Body 처리 및 헤더 설정

http.post('https://api.external.com/users', async ({ request }) => {
  const body = await request.json() // 요청 바디 파싱
  return HttpResponse.json(
    { id: 100, ...body },
    { 
      status: 201,
      headers: { 'X-Request-Id': 'mock-123' } 
    }
  )
})

 

2. 동적 요청 처리: 비즈니스 로직 시뮬레이션

단순 고정 데이터와 MSW의 차이점은 요청 값에 따라 응답을 분기할 수 있다는 점입니다.

구분 처리 방식 예시
Path Param URL 경로의 변수 값을 추출 id === '404' 인 경우 404 Not Found 에러 반환
Query String URL 객체 또는 프레임워크 제공 API searchParams.get('q') 값이 없으면 400 Bad Request 반환
Request Body Body 데이터 검증 결제 금액이 0 이하이면 실패 응답 (400 또는 422) 반환
import { http, HttpResponse } from 'msw'

export const orderHandlers = [
  http.patch('https://api.shop.com/orders/:id', async ({ params, request }) => {
    // 1. Path Parameter 체크
    const { id } = params
    if (id === 'invalid-id') {
      return HttpResponse.json({ error: '존재하지 않는 주문입니다.' }, { status: 404 })
    }

    // 2. Query String 체크
    const url = new URL(request.url)
    const confirm = url.searchParams.get('confirm')
    if (confirm !== 'true') {
      return HttpResponse.json({ error: '확인 플래그(confirm)가 필요합니다.' }, { status: 400 })
    }

    // 3. Request Body 체크
    const body = await request.json() as { amount: number }
    if (body.amount <= 0) {
      return HttpResponse.json({ error: '금액은 0보다 커야 합니다.' }, { status: 422 })
    }

    // 모든 조건 통과 시 성공 응답
    return HttpResponse.json({
      orderId: id,
      status: 'SUCCESS',
      paidAmount: body.amount,
      message: '주문이 성공적으로 처리되었습니다.'
    }, { status: 200 })
  }),
]

 

4. Fastify 서버와 함께하는 End-to-End 통합 테스트 예시

Fastify의 강력한 테스트 유틸리티인 app.inject()와 네트워크 모킹 도구인 MSW(Mock Service Worker)를 조합해 견고한 테스트 환경을 구축해 보겠습니다.

이 코드 실행시 vite.config.ts 에서 setupFiles를 주석해야 합니다.

// vite.config.ts
//setupFiles: ['./tests/ch06/5-2-1.setup.ts'],

 

1. API Chain 테스트 구조

실제 외부 서버에 요청을 보내는 대신, MSW가 중간에서 요청을 가로채어 미리 정의된 응답을 돌려줍니다.
▸ Fastify Route: 클라이언트의 요청을 받는 진입점.
▸ Service: 비즈니스 로직 처리 및 외부 데이터 요청 명령.
▸ External Client: axios나 undici를 이용해 실제 외부 API와 통신.
▸ MSW: 실제 네트워크 레이어에서 요청을 가로채 모의 응답 반환.

 

2. app.inject() 기반 서버 내부 테스트 : 서버 및 라우트 설정

Fastify의 app.inject()는 실제 HTTP 서버를 구동(listen)하지 않고도 서버 내부 엔진에 직접 요청을 주입합니다.

// /tests/ch06/6-4.fastify.server.ts
import Fastify from 'fastify';
import axios from 'axios';

export function createApp() {
  const app = Fastify({
    logger: true,
  });

  app.get('/user-profile/:id', async (request, reply) => {
    // Fastify의 request.params는 기본적으로 unknown 타입
    // 따라서 타입 단언을 통해 id 추출
    const rid = (request.params as { id: string }).id;

    // 외부 API 호출
    // 테스트 환경에서는 이 요청을 MSW가 가로채서 가짜 응답을 반환
    const { data } = await axios.get(`https://api.external.com/users/${rid}`);

    // 외부 API 응답을 내부 API 규격에 맞게 변환하여 반환
    return {
      userId: data.id,
      displayName: data.name.toUpperCase(), // 비즈니스 로직: 대문자 변환
      email: data.email,
    };
  });

  return app;
}

 

3. 데이터 정합성 검증 전략 (MSW 활용) : MSW 설정 및 통합 테스트

외부 API의 응답이 우리 시스템의 DTO(Data Transfer Object)로 잘 변환되는지 확인해야 합니다.

// tests/ch06/6-4.msw.server.test
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { it, expect, beforeAll, afterAll, afterEach, describe } from 'vitest';
import { createApp } from './6-4.fastify.server';

// Path Parameter 타입을 명시해야 params.id 접근 시 TypeScript 에러가 나지 않는다.
type UserParams = {
  id: string;
};

// 1. MSW 서버 설정
// setupServer(): Node 환경에서 HTTP 요청을 가로채는 MSW 서버 생성
const externalServer = setupServer(
  http.get<UserParams>('https://api.external.com/users/:id', ({ params }) => {
    const { id } = params;
    // 실제 외부 API와 동일한 형태의 JSON 응답 반환
    return HttpResponse.json(
      {
        id,
        name: 'External User 123',
        email: 'test#test.com',
      },
      { status: 200 },
    );
  }),
);

/* ------------------------------------------------------------------
   2. 테스트 환경 라이프사이클 관리
   ------------------------------------------------------------------ */
// typeof createApp → 함수 타입 추출
// ReturnType<...> → 그 함수의 반환 타입만 추출
let app: ReturnType<typeof createApp>;

// 모든 테스트 실행 전 1회 호출
beforeAll(async () => {
  externalServer.listen({
    onUnhandledRequest: 'error',
  });
  app = createApp();
  await app.ready();
});

// 각 테스트 종료 후 호출 → 핸들러 override 상태 초기화
afterEach(() => {
  externalServer.resetHandlers();
});

// 모든 테스트 종료 후 1회 호출
afterAll(async () => {
  externalServer.close();
  await app.close();
});

/* ------------------------------------------------------------------
   3. Fastify API 통합 테스트
   ------------------------------------------------------------------ */
it('외부 사용자 API를 호출하여 가공된 응답을 반환한다', async () => {
  // Fastify 내부 요청 시뮬레이션 (실제 네트워크 사용 없음)
  const response = await app.inject({
    method: 'GET',
    url: '/user-profile/user_123',
  });
  // HTTP 상태 코드 검증
  expect(response.statusCode).toBe(200);
  // JSON 응답 파싱
  const json = response.json();
  // 내부 비즈니스 로직 결과 검증
  expect(json).toEqual({
    userId: 'user_123',
    displayName: 'EXTERNAL USER 123', // name을 대문자로 변환한 결과
    email: 'test#test.com',
  });
});

 

4. 인증 컨텍스트 전파 테스트 : 인증 헤더 전파 검증

실무에서는 로그인된 사용자의 토큰을 외부 API로 전달해야 하는 경우가 많습니다. 따라서 인증 컨텍스트가 올바르게 전파되는지 검증하는 테스트는 통합 테스트 단계에서 반드시 포함되어야 할 항목입니다.

// /tests/ch06/6-4.fastify.server.ts
app.get('/secure-data', async (request, reply) => {
  // Fastify는 모든 헤더를 소문자로 정규화해서 제공
  const authHeader = request.headers['authorization'];

  // 외부 API 호출 시 동일한 Authorization 헤더 전달
  const { data } = await axios.get(`https://api.external.com/data`, {
    headers: {
      Authorization: authHeader,
    },
  });

  // 외부 API 응답을 그대로 클라이언트에 반환
  return data;
});
// tests/ch06/6-4.msw.server.test.ts  
// Authorization 헤더가 필요한 보호된 외부 API 시뮬레이션
http.get('https://api.external.com/data', ({ request }) => {
  // 요청 헤더에서 인증 토큰 추출
  const authHeader = request.headers.get('authorization');
  // 토큰이 올바르지 않으면 접근 거부
  if (authHeader !== 'Bearer valid_token_123') {
    return new HttpResponse(null, { status: 403 });
  }
  // 인증 성공 시 데이터 반환
  return HttpResponse.json(
    {
      secretData: 'This is very secure data.',
    },
    { status: 200 },
  );
}),
  
// Authorization 헤더에 따라 외부 API 접근이 제어되는지 검증
it('Authorization 헤더에 따라 외부 API 접근이 제어된다', async () => {
  // 정상 토큰으로 요청
  const validResponse = await app.inject({
    method: 'GET',
    url: '/secure-data',
    headers: {
      // Fastify는 내부적으로 헤더를 소문자로 정규화
      authorization: 'Bearer valid_token_123',
    },
  });
  expect(validResponse.statusCode).toBe(200);
  expect(validResponse.json()).toEqual({
    secretData: 'This is very secure data.',
  });
  // 잘못된 토큰으로 요청
  const invalidResponse = await app.inject({
    method: 'GET',
    url: '/secure-data',
    headers: {
      authorization: 'Bearer invalid_token_456',
    },
  });
   expect(invalidResponse.statusCode).toBe(403);
});

 


※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.

반응형

 

반응형