Skip to content

Split View: 모의 서버(Mock Server) 구축 완벽 가이드

|

모의 서버(Mock Server) 구축 완벽 가이드

개요

현대 소프트웨어 개발에서 프론트엔드와 백엔드는 대부분 별도 팀이 동시에 작업합니다. 백엔드 API가 완성될 때까지 프론트엔드 개발이 블로킹되거나, 외부 서드파티 API에 의존하는 기능을 테스트할 때 실제 서비스에 접근할 수 없는 상황이 자주 발생합니다.

**모의 서버(Mock Server)**는 이런 문제를 해결하는 핵심 도구입니다. API 계약을 먼저 정의하고, 실제 백엔드 없이도 프론트엔드 개발과 테스트를 진행할 수 있게 해줍니다.


1. 모의 서버가 필요한 이유

1.1 프론트엔드-백엔드 병렬 개발

전통적인 개발 방식 (순차):
  백엔드 API 완성 → 프론트엔드 개발 시작
  └── 지연: 프론트엔드가 백엔드를 기다림

모의 서버를 활용한 방식 (병렬):
  API 스펙 정의 → 백엔드 개발 || 프론트엔드 개발
                              ↑ 모의 서버로 블로킹 없음

두 팀이 API 계약(Contract)만 먼저 합의하면 동시에 개발할 수 있습니다.

1.2 외부 API 의존성 제거

외부 결제 API, 지도 서비스, 날씨 API 등 서드파티 서비스는 다음 문제가 있습니다:

  • 비용 발생: 호출마다 과금되는 API
  • 속도 제한: Rate Limiting으로 테스트 실행이 느려짐
  • 서비스 불안정: 외부 서비스 장애로 CI/CD 파이프라인이 실패
  • 테스트 데이터 오염: 실제 데이터에 테스트 데이터가 섞임

1.3 에러 케이스 재현

실제 서비스에서 재현하기 어려운 상황을 모의 서버로 쉽게 재현할 수 있습니다:

  • 네트워크 타임아웃
  • 500 Internal Server Error
  • 429 Too Many Requests
  • 네트워크 연결 끊김
  • 응답 지연 (느린 네트워크 시뮬레이션)

1.4 테스트 환경 안정성

CI/CD 파이프라인에서 외부 의존성 없애기:
  실제 DB 없이 → InMemory DB Fake
  실제 API 없이 → Mock Server
  실제 메시지 큐 없이 → In-process Fake

2. json-server (가장 빠른 시작)

json-server는 JSON 파일 하나로 30초 만에 REST API 서버를 만들 수 있는 도구입니다.

2.1 설치와 기본 사용법

# 전역 설치
npm install -g json-server

# 또는 프로젝트에 설치
npm install --save-dev json-server

2.2 db.json 구조 설계

{
  "users": [
    { "id": 1, "name": "홍길동", "email": "hong@example.com", "role": "admin" },
    { "id": 2, "name": "이순신", "email": "lee@example.com", "role": "user" }
  ],
  "posts": [
    { "id": 1, "title": "첫 번째 포스트", "body": "내용입니다", "userId": 1, "published": true },
    { "id": 2, "title": "두 번째 포스트", "body": "내용입니다", "userId": 2, "published": false }
  ],
  "comments": [{ "id": 1, "text": "좋은 글이네요", "postId": 1, "userId": 2 }],
  "profile": {
    "name": "관리자",
    "version": "1.0.0"
  }
}

json-server 실행:

json-server --watch db.json --port 3001

자동으로 생성되는 엔드포인트:

GET    /users
GET    /users/1
POST   /users
PUT    /users/1
PATCH  /users/1
DELETE /users/1
GET    /posts?userId=1
GET    /posts?published=true
GET    /posts?_page=1&_limit=10
GET    /posts?_sort=title&_order=asc

2.3 커스텀 라우팅 (routes.json)

{
  "/api/*": "/$1",
  "/api/v1/users/:id": "/users/:id",
  "/blog/:id/comments": "/comments?postId=:id"
}

실행 시 라우팅 파일 적용:

json-server --watch db.json --routes routes.json --port 3001

2.4 미들웨어 추가

// middleware.js
module.exports = (req, res, next) => {
  // 인증 헤더 검사
  if (req.headers.authorization !== 'Bearer valid-token') {
    if (req.method !== 'GET') {
      return res.status(401).json({ error: 'Unauthorized' })
    }
  }

  // 응답 지연 시뮬레이션 (500ms)
  setTimeout(next, 500)
}
json-server --watch db.json --middlewares middleware.js

2.5 관계형 데이터 모델링

json-server는 _embed_expand를 통해 관계형 데이터를 지원합니다:

# 댓글 포함한 포스트 조회
GET /posts/1?_embed=comments

# 작성자 정보 포함
GET /posts?_expand=user

# 중첩 관계
GET /users?_embed=posts

2.6 페이지네이션, 정렬, 필터링

# 페이지네이션 (10개씩, 2페이지)
GET /posts?_page=2&_limit=10

# 정렬 (제목 내림차순)
GET /posts?_sort=title&_order=desc

# 필터링 (published=true인 포스트)
GET /posts?published=true

# 검색 (전체 텍스트)
GET /posts?q=검색어

# 범위 필터
GET /posts?id_gte=5&id_lte=10

2.7 커스텀 서버 스크립트

// server.js - 더 세밀한 제어가 필요할 때
const jsonServer = require('json-server')
const server = jsonServer.create()
const router = jsonServer.router('db.json')
const middlewares = jsonServer.defaults()

server.use(middlewares)

// 커스텀 라우트
server.post('/auth/login', (req, res) => {
  const { email, password } = req.body
  if (email === 'admin@example.com' && password === 'password') {
    res.json({ token: 'fake-jwt-token', userId: 1 })
  } else {
    res.status(401).json({ error: 'Invalid credentials' })
  }
})

server.use(router)
server.listen(3001, () => {
  console.log('JSON Server is running on port 3001')
})

3. MSW (Mock Service Worker) - 현대적 방법

MSW는 Service Worker를 활용해 네트워크 레벨에서 요청을 가로채는 현대적인 API 모킹 라이브러리입니다. 브라우저와 Node.js 양쪽에서 동일한 핸들러를 사용할 수 있습니다.

3.1 설치 및 설정

npm install msw --save-dev

# Service Worker 파일 생성 (public 폴더에)
npx msw init public/ --save

3.2 REST API 핸들러 작성

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  // GET 사용자 목록
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: '홍길동', email: 'hong@example.com' },
      { id: 2, name: '이순신', email: 'lee@example.com' },
    ])
  }),

  // GET 특정 사용자
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params

    if (id === '999') {
      return new HttpResponse(null, { status: 404 })
    }

    return HttpResponse.json({
      id: Number(id),
      name: '홍길동',
      email: 'hong@example.com',
    })
  }),

  // POST 사용자 생성
  http.post('/api/users', async ({ request }) => {
    const newUser = await request.json()

    return HttpResponse.json({ id: Date.now(), ...newUser }, { status: 201 })
  }),

  // PUT 사용자 수정
  http.put('/api/users/:id', async ({ params, request }) => {
    const { id } = params
    const updates = await request.json()

    return HttpResponse.json({ id: Number(id), ...updates })
  }),

  // DELETE 사용자 삭제
  http.delete('/api/users/:id', ({ params }) => {
    return new HttpResponse(null, { status: 204 })
  }),
]

3.3 GraphQL 핸들러

import { graphql, HttpResponse } from 'msw'

export const graphqlHandlers = [
  graphql.query('GetUser', ({ variables }) => {
    const { id } = variables

    return HttpResponse.json({
      data: {
        user: {
          id,
          name: '홍길동',
          email: 'hong@example.com',
          posts: [{ id: 1, title: '첫 번째 포스트' }],
        },
      },
    })
  }),

  graphql.mutation('CreateUser', ({ variables }) => {
    const { name, email } = variables

    return HttpResponse.json({
      data: {
        createUser: {
          id: String(Date.now()),
          name,
          email,
        },
      },
    })
  }),
]

3.4 동적 응답 (요청 파라미터 활용)

import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/products', ({ request }) => {
    const url = new URL(request.url)
    const category = url.searchParams.get('category')
    const page = Number(url.searchParams.get('page') ?? '1')
    const limit = Number(url.searchParams.get('limit') ?? '10')

    const allProducts = [
      { id: 1, name: '노트북', category: 'electronics', price: 1200000 },
      { id: 2, name: '마우스', category: 'electronics', price: 30000 },
      { id: 3, name: '책상', category: 'furniture', price: 250000 },
    ]

    let filtered = allProducts
    if (category) {
      filtered = allProducts.filter((p) => p.category === category)
    }

    const start = (page - 1) * limit
    const data = filtered.slice(start, start + limit)

    return HttpResponse.json({
      data,
      total: filtered.length,
      page,
      limit,
    })
  }),
]

3.5 에러 응답 시나리오

import { http, HttpResponse } from 'msw'

// 에러 시나리오를 동적으로 제어하는 패턴
let shouldFailNext = false

export const handlers = [
  // 에러 시뮬레이션 제어 엔드포인트 (테스트 전용)
  http.post('/test/simulate-error', async ({ request }) => {
    const { fail } = await request.json()
    shouldFailNext = fail
    return new HttpResponse(null, { status: 200 })
  }),

  http.post('/api/payment', async ({ request }) => {
    if (shouldFailNext) {
      shouldFailNext = false
      return HttpResponse.json(
        { error: 'PAYMENT_DECLINED', message: '카드가 거절되었습니다' },
        { status: 402 }
      )
    }

    const { amount } = await request.json()
    return HttpResponse.json({
      paymentId: `PAY-${Date.now()}`,
      amount,
      status: 'SUCCESS',
    })
  }),

  // 네트워크 에러 시뮬레이션
  http.get('/api/unstable', () => {
    if (Math.random() > 0.7) {
      return HttpResponse.error()
    }
    return HttpResponse.json({ data: 'success' })
  }),
]

3.6 브라우저 환경 설정

// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

export const worker = setupWorker(...handlers)
// src/main.tsx (React 앱 진입점)
async function enableMocking() {
  if (process.env.NODE_ENV !== 'development') {
    return
  }

  const { worker } = await import('./mocks/browser')
  return worker.start({
    onUnhandledRequest: 'bypass' // 처리되지 않은 요청은 그대로 통과
  })
}

enableMocking().then(() => {
  ReactDOM.createRoot(document.getElementById('root')!).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  )
})

3.7 Node.js 환경 설정 (테스트)

// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)
// jest.setup.ts
import { server } from './src/mocks/server'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// 테스트에서 핸들러 오버라이드
import { server } from '../mocks/server'
import { http, HttpResponse } from 'msw'

test('API 에러 처리 테스트', async () => {
  server.use(
    http.get('/api/users', () => {
      return HttpResponse.json({ error: 'Server Error' }, { status: 500 })
    })
  )

  const result = await fetchUsers()
  expect(result.error).toBe('Server Error')
})

3.8 Next.js에서 MSW 설정

// next.config.js
const nextConfig = {
  // Service Worker를 위한 헤더 설정
  async headers() {
    return [
      {
        source: '/mockServiceWorker.js',
        headers: [{ key: 'Service-Worker-Allowed', value: '/' }],
      },
    ]
  },
}
// src/app/providers.tsx (App Router)
'use client'

import { useEffect } from 'react'

export function MSWProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled') {
      import('../mocks/browser').then(({ worker }) => {
        worker.start({ onUnhandledRequest: 'bypass' })
      })
    }
  }, [])

  return children
}

4. WireMock - Java/JVM 환경

WireMock은 Java/JVM 생태계에서 가장 널리 사용되는 HTTP 모의 서버입니다.

4.1 WireMock Standalone 서버 실행

# WireMock 다운로드
curl -o wiremock.jar https://repo1.maven.org/maven2/com/github/tomakehurst/wiremock-jre8-standalone/2.35.0/wiremock-jre8-standalone-2.35.0.jar

# 실행
java -jar wiremock.jar --port 8080 --verbose

디렉토리 구조:

wiremock/
├── mappings/          # Stub 정의 JSON 파일
│   ├── users.json
│   └── orders.json
└── __files/           # 응답 파일 (body 파일)
    ├── users.json
    └── order-template.json

4.2 JSON 파일로 Stub 정의

{
  "request": {
    "method": "GET",
    "url": "/api/users"
  },
  "response": {
    "status": 200,
    "headers": {
      "Content-Type": "application/json"
    },
    "jsonBody": [
      { "id": 1, "name": "홍길동", "email": "hong@example.com" },
      { "id": 2, "name": "이순신", "email": "lee@example.com" }
    ]
  }
}

4.3 Request Matching (URL, headers, body)

{
  "request": {
    "method": "POST",
    "url": "/api/orders",
    "headers": {
      "Content-Type": {
        "equalTo": "application/json"
      },
      "Authorization": {
        "matches": "Bearer .+"
      }
    },
    "bodyPatterns": [
      {
        "matchesJsonPath": "$.items[*].productId"
      },
      {
        "equalToJson": {
          "userId": 1
        },
        "ignoreArrayOrder": true,
        "ignoreExtraElements": true
      }
    ]
  },
  "response": {
    "status": 201,
    "jsonBody": {
      "orderId": "ORDER-001",
      "status": "PENDING"
    }
  }
}

4.4 Response Templating (Handlebars)

{
  "request": {
    "method": "GET",
    "urlPathPattern": "/api/users/([0-9]+)"
  },
  "response": {
    "status": 200,
    "headers": {
      "Content-Type": "application/json"
    },
    "body": "{ \"id\": {{request.pathSegments.[2]}}, \"name\": \"사용자-{{request.pathSegments.[2]}}\", \"timestamp\": \"{{now}}\" }",
    "transformers": ["response-template"]
  }
}

4.5 Scenarios (상태 기반 모킹)

시나리오를 사용하면 상태에 따라 다른 응답을 반환할 수 있습니다:

{
  "scenarioName": "주문 처리 흐름",
  "requiredScenarioState": "Started",
  "newScenarioState": "결제 완료",
  "request": {
    "method": "POST",
    "url": "/api/orders/pay"
  },
  "response": {
    "status": 200,
    "jsonBody": { "status": "PAID" }
  }
}
{
  "scenarioName": "주문 처리 흐름",
  "requiredScenarioState": "결제 완료",
  "newScenarioState": "배송 중",
  "request": {
    "method": "GET",
    "url": "/api/orders/ORDER-001"
  },
  "response": {
    "status": 200,
    "jsonBody": { "status": "SHIPPING" }
  }
}

4.6 Record and Playback

실제 API 응답을 기록하고 재생하는 기능:

# 기록 모드로 실행 (실제 API로 프록시)
java -jar wiremock.jar --proxy-all="https://api.example.com" --record-mappings --port 8080

# 기록된 응답 재생 (기본 모드)
java -jar wiremock.jar --port 8080

5. Prism - OpenAPI 기반 모의 서버

Prism은 OpenAPI(Swagger) 스펙에서 자동으로 모의 서버를 생성하는 도구입니다.

5.1 설치와 실행

npm install -g @stoplight/prism-cli

# OpenAPI 스펙으로 모의 서버 실행
npx @stoplight/prism-cli mock api.yaml --port 4010

5.2 OpenAPI 스펙 예시

openapi: 3.0.0
info:
  title: User API
  version: 1.0.0
paths:
  /users:
    get:
      operationId: getUsers
      responses:
        '200':
          description: 사용자 목록
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/User'
              examples:
                default:
                  value:
                    - id: 1
                      name: 홍길동
                      email: hong@example.com
    post:
      operationId: createUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          description: 사용자 생성됨
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
components:
  schemas:
    User:
      type: object
      required: [id, name, email]
      properties:
        id:
          type: integer
        name:
          type: string
        email:
          type: string
          format: email
    CreateUserRequest:
      type: object
      required: [name, email]
      properties:
        name:
          type: string
        email:
          type: string
          format: email

5.3 동적 응답 생성

Prism은 스펙에서 예시값이 없으면 스키마를 기반으로 자동으로 값을 생성합니다:

# 동적 생성 모드 (스키마 기반 랜덤 응답)
npx @stoplight/prism-cli mock api.yaml --dynamic

5.4 Validation 모드

# Validation 모드 - 요청/응답을 스펙과 대조 검증
npx @stoplight/prism-cli proxy api.yaml https://api.example.com --validate-request --validate-response

6. Go로 모의 서버 구현

Go 표준 라이브러리만으로 완전한 모의 서버를 구현할 수 있습니다.

6.1 기본 모의 서버 구조

package mockserver

import (
    "encoding/json"
    "log"
    "net/http"
    "sync"
    "time"
)

type MockServer struct {
    server   *http.Server
    mux      *http.ServeMux
    mu       sync.RWMutex
    stubs    map[string]*Stub
    calls    []RequestLog
}

type Stub struct {
    Method      string
    Path        string
    StatusCode  int
    Body        interface{}
    Headers     map[string]string
    Delay       time.Duration
}

type RequestLog struct {
    Method    string
    Path      string
    Headers   map[string][]string
    Body      []byte
    Timestamp time.Time
}

func NewMockServer(port string) *MockServer {
    ms := &MockServer{
        mux:   http.NewServeMux(),
        stubs: make(map[string]*Stub),
    }

    ms.server = &http.Server{
        Addr:    ":" + port,
        Handler: ms.mux,
    }

    ms.mux.HandleFunc("/", ms.handleRequest)

    // 테스트 제어 엔드포인트
    ms.mux.HandleFunc("/__admin/stubs", ms.handleAdminStubs)
    ms.mux.HandleFunc("/__admin/calls", ms.handleAdminCalls)
    ms.mux.HandleFunc("/__admin/reset", ms.handleAdminReset)

    return ms
}

func (ms *MockServer) Start() {
    go func() {
        log.Printf("MockServer starting on %s", ms.server.Addr)
        if err := ms.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("MockServer failed: %v", err)
        }
    }()
    // 서버 시작 대기
    time.Sleep(100 * time.Millisecond)
}

func (ms *MockServer) Stop() {
    ms.server.Close()
}

func (ms *MockServer) AddStub(stub *Stub) {
    ms.mu.Lock()
    defer ms.mu.Unlock()
    key := stub.Method + ":" + stub.Path
    ms.stubs[key] = stub
}

func (ms *MockServer) handleRequest(w http.ResponseWriter, r *http.Request) {
    // 요청 로깅
    ms.mu.Lock()
    ms.calls = append(ms.calls, RequestLog{
        Method:    r.Method,
        Path:      r.URL.Path,
        Headers:   r.Header,
        Timestamp: time.Now(),
    })
    ms.mu.Unlock()

    // Stub 조회
    ms.mu.RLock()
    key := r.Method + ":" + r.URL.Path
    stub, ok := ms.stubs[key]
    ms.mu.RUnlock()

    if !ok {
        http.Error(w, "No stub found for "+r.Method+" "+r.URL.Path, http.StatusNotFound)
        return
    }

    // 응답 지연 시뮬레이션
    if stub.Delay > 0 {
        time.Sleep(stub.Delay)
    }

    // 헤더 설정
    for k, v := range stub.Headers {
        w.Header().Set(k, v)
    }
    if w.Header().Get("Content-Type") == "" {
        w.Header().Set("Content-Type", "application/json")
    }

    w.WriteHeader(stub.StatusCode)

    if stub.Body != nil {
        json.NewEncoder(w).Encode(stub.Body)
    }
}

func (ms *MockServer) handleAdminReset(w http.ResponseWriter, r *http.Request) {
    ms.mu.Lock()
    ms.stubs = make(map[string]*Stub)
    ms.calls = nil
    ms.mu.Unlock()
    w.WriteHeader(http.StatusOK)
}

func (ms *MockServer) handleAdminCalls(w http.ResponseWriter, r *http.Request) {
    ms.mu.RLock()
    defer ms.mu.RUnlock()
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(ms.calls)
}

func (ms *MockServer) handleAdminStubs(w http.ResponseWriter, r *http.Request) {
    if r.Method == http.MethodPost {
        var stub Stub
        if err := json.NewDecoder(r.Body).Decode(&stub); err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }
        ms.AddStub(&stub)
        w.WriteHeader(http.StatusCreated)
        return
    }

    ms.mu.RLock()
    defer ms.mu.RUnlock()
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(ms.stubs)
}

6.2 테스트에서 모의 서버 사용

func TestUserService_GetUser(t *testing.T) {
    // 모의 서버 시작
    ms := NewMockServer("9999")
    ms.Start()
    defer ms.Stop()

    // Stub 설정
    ms.AddStub(&Stub{
        Method:     "GET",
        Path:       "/api/users/1",
        StatusCode: 200,
        Body: map[string]interface{}{
            "id":    1,
            "name":  "홍길동",
            "email": "hong@example.com",
        },
    })

    // 테스트 대상 서비스 (모의 서버 URL 주입)
    client := NewUserServiceClient("http://localhost:9999")
    user, err := client.GetUser(1)

    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if user.Name != "홍길동" {
        t.Errorf("expected 홍길동, got %s", user.Name)
    }
}

func TestUserService_GetUser_NotFound(t *testing.T) {
    ms := NewMockServer("9998")
    ms.Start()
    defer ms.Stop()

    ms.AddStub(&Stub{
        Method:     "GET",
        Path:       "/api/users/999",
        StatusCode: 404,
        Body:       map[string]string{"error": "User not found"},
    })

    client := NewUserServiceClient("http://localhost:9998")
    _, err := client.GetUser(999)

    if err == nil {
        t.Error("expected error, got nil")
    }
}

7. TypeScript/Node.js로 모의 서버 구현

7.1 Express.js + TypeScript 모의 서버

// mock-server/index.ts
import express, { Request, Response, NextFunction } from 'express'
import fs from 'fs'
import path from 'path'

interface StubDefinition {
  method: string
  path: string
  statusCode: number
  body?: unknown
  headers?: Record<string, string>
  delay?: number
}

const app = express()
app.use(express.json())

const stubs: Map<string, StubDefinition> = new Map()
const requestLog: Array<{ method: string; path: string; timestamp: string }> = []

// 요청 로깅 미들웨어
app.use((req: Request, res: Response, next: NextFunction) => {
  requestLog.push({
    method: req.method,
    path: req.path,
    timestamp: new Date().toISOString(),
  })
  next()
})

// 관리 API
app.post('/__admin/stubs', (req: Request, res: Response) => {
  const stub: StubDefinition = req.body
  const key = `${stub.method}:${stub.path}`
  stubs.set(key, stub)
  res.status(201).json({ message: 'Stub registered', key })
})

app.delete('/__admin/stubs', (req: Request, res: Response) => {
  stubs.clear()
  requestLog.length = 0
  res.status(200).json({ message: 'All stubs cleared' })
})

app.get('/__admin/calls', (req: Request, res: Response) => {
  res.json(requestLog)
})

// 동적 응답 핸들러
app.use(async (req: Request, res: Response) => {
  const key = `${req.method}:${req.path}`
  const stub = stubs.get(key)

  if (!stub) {
    return res.status(404).json({
      error: 'No stub found',
      request: { method: req.method, path: req.path },
    })
  }

  // 응답 지연
  if (stub.delay && stub.delay > 0) {
    await new Promise((resolve) => setTimeout(resolve, stub.delay))
  }

  // 헤더 설정
  if (stub.headers) {
    Object.entries(stub.headers).forEach(([key, value]) => {
      res.setHeader(key, value)
    })
  }

  res.status(stub.statusCode).json(stub.body)
})

const PORT = process.env.MOCK_PORT ?? '3001'
app.listen(PORT, () => {
  console.log(`Mock server running on port ${PORT}`)
})

export { app }

7.2 파일 기반 응답 라우팅

// 응답 파일을 디렉토리 구조로 관리
// responses/
//   GET/
//     api/
//       users/
//         index.json    -> GET /api/users
//         1.json        -> GET /api/users/1
//   POST/
//     api/
//       users/
//         index.json    -> POST /api/users (응답 템플릿)

app.use((req: Request, res: Response) => {
  const responsePath = path.join(__dirname, 'responses', req.method, req.path, 'index.json')

  if (fs.existsSync(responsePath)) {
    const responseData = JSON.parse(fs.readFileSync(responsePath, 'utf-8'))
    res.json(responseData)
  } else {
    res.status(404).json({ error: 'No response file found' })
  }
})

7.3 미들웨어로 지연, 에러 주입

// chaos-middleware.ts - 혼돈 엔지니어링을 위한 미들웨어
interface ChaosConfig {
  errorRate: number // 0.0 ~ 1.0
  minDelay: number // ms
  maxDelay: number // ms
}

let chaosConfig: ChaosConfig = {
  errorRate: 0,
  minDelay: 0,
  maxDelay: 0,
}

export const chaosMiddleware = async (req: Request, res: Response, next: NextFunction) => {
  // 응답 지연
  const delay = chaosConfig.minDelay + Math.random() * (chaosConfig.maxDelay - chaosConfig.minDelay)
  if (delay > 0) {
    await new Promise((resolve) => setTimeout(resolve, delay))
  }

  // 무작위 에러 주입
  if (Math.random() < chaosConfig.errorRate) {
    return res.status(500).json({
      error: 'Chaos monkey strikes!',
      timestamp: new Date().toISOString(),
    })
  }

  next()
}

// 설정 변경 API
app.post('/__admin/chaos', (req: Request, res: Response) => {
  chaosConfig = { ...chaosConfig, ...req.body }
  res.json({ message: 'Chaos config updated', config: chaosConfig })
})

8. Docker Compose로 모의 서버 환경 구성

8.1 docker-compose.yml 예시

version: '3.8'

services:
  # json-server 모의 서버
  json-server:
    image: clue/json-server
    volumes:
      - ./mock-data/db.json:/data/db.json
      - ./mock-data/routes.json:/data/routes.json
    ports:
      - '3001:80'
    command: --watch db.json --routes routes.json
    healthcheck:
      test: ['CMD', 'wget', '-qO-', 'http://localhost/users']
      interval: 10s
      timeout: 5s
      retries: 3

  # WireMock 서버
  wiremock:
    image: wiremock/wiremock:latest
    ports:
      - '8080:8080'
    volumes:
      - ./wiremock/mappings:/home/wiremock/mappings
      - ./wiremock/__files:/home/wiremock/__files
    command:
      - '--verbose'
      - '--global-response-templating'
    healthcheck:
      test: ['CMD', 'wget', '-qO-', 'http://localhost:8080/__admin/health']
      interval: 10s
      timeout: 5s
      retries: 3

  # 실제 애플리케이션 (모의 서버를 의존성으로 사용)
  app:
    build: .
    ports:
      - '8000:8000'
    environment:
      - USER_SERVICE_URL=http://json-server:80
      - PAYMENT_SERVICE_URL=http://wiremock:8080
    depends_on:
      json-server:
        condition: service_healthy
      wiremock:
        condition: service_healthy

  # E2E 테스트 실행
  e2e-tests:
    build:
      context: .
      dockerfile: Dockerfile.test
    environment:
      - APP_URL=http://app:8000
    depends_on:
      - app
    command: npm run test:e2e
    profiles:
      - test

8.2 테스트 실행 스크립트

#!/bin/bash
# run-tests.sh

echo "모의 서버 환경 시작..."
docker-compose up -d json-server wiremock

echo "헬스체크 대기..."
docker-compose exec json-server wget -qO- http://localhost/users
docker-compose exec wiremock wget -qO- http://localhost:8080/__admin/health

echo "애플리케이션 시작..."
docker-compose up -d app

echo "E2E 테스트 실행..."
docker-compose run --rm e2e-tests

echo "정리..."
docker-compose down

8.3 모의 데이터 초기화 스크립트

// scripts/setup-mock-data.ts
const WIREMOCK_URL = 'http://localhost:8080/__admin/mappings'

const stubs = [
  {
    request: {
      method: 'POST',
      url: '/api/payment/charge',
    },
    response: {
      status: 200,
      headers: { 'Content-Type': 'application/json' },
      jsonBody: {
        paymentId: 'TEST-PAYMENT-001',
        status: 'SUCCESS',
        amount: 10000,
      },
    },
  },
  {
    request: {
      method: 'POST',
      url: '/api/payment/charge',
      headers: {
        'X-Simulate-Failure': { equalTo: 'true' },
      },
    },
    response: {
      status: 402,
      headers: { 'Content-Type': 'application/json' },
      jsonBody: {
        error: 'PAYMENT_DECLINED',
        message: '카드가 거절되었습니다',
      },
    },
  },
]

async function setupMockData() {
  for (const stub of stubs) {
    const response = await fetch(WIREMOCK_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(stub),
    })
    console.log(`Stub registered: ${response.status}`)
  }
}

setupMockData().catch(console.error)

9. 퀴즈

퀴즈 1: MSW(Mock Service Worker)가 다른 모킹 방법과 다른 핵심적인 차이점은 무엇인가요?

정답: MSW는 Service Worker를 활용해 네트워크 레벨에서 요청을 가로채기 때문에, 애플리케이션 코드를 수정하지 않고도 실제 네트워크 요청을 인터셉트할 수 있습니다. 또한 브라우저와 Node.js 양쪽에서 동일한 핸들러를 재사용할 수 있습니다.

설명: axios-mock-adapter나 fetch-mock 같은 라이브러리는 특정 HTTP 클라이언트 라이브러리를 모킹하므로, HTTP 클라이언트가 바뀌면 모킹 코드도 변경해야 합니다. MSW는 실제 HTTP 스택을 통해 요청이 발생하므로 어떤 HTTP 라이브러리를 사용해도 동일하게 동작합니다.

퀴즈 2: json-server의 가장 큰 제약 사항은 무엇이고, 어떻게 극복할 수 있나요?

정답: json-server의 가장 큰 제약 사항은 복잡한 비즈니스 로직 구현이 어렵다는 점입니다. 단순 CRUD 외의 커스텀 응답 로직이 필요할 때 한계가 있습니다.

설명: 이를 극복하려면 json-server를 라이브러리로 사용하여 커스텀 라우터를 추가(server.post('/auth/login', ...))하거나, 미들웨어를 작성하여 복잡한 로직을 처리할 수 있습니다. 더 복잡한 시나리오가 필요하다면 WireMock이나 직접 구현한 모의 서버로 전환하는 것이 좋습니다.

퀴즈 3: WireMock의 Scenarios 기능은 어떤 상황에서 유용한가요?

정답: Scenarios는 상태에 따라 다른 응답을 반환해야 하는 워크플로우 테스트에 유용합니다. 예를 들어 주문 상태(결제 대기 → 결제 완료 → 배송 중 → 배송 완료)나 재시도 로직(첫 번째 요청은 실패, 두 번째는 성공) 테스트에 적합합니다.

설명: 일반적인 Stub은 항상 동일한 응답을 반환하지만, Scenario를 사용하면 요청이 들어올 때마다 상태가 전이되며 각 상태에 맞는 응답을 반환합니다. 이는 멱등성이 없는 API나 상태 기반 워크플로우를 테스트할 때 필수적입니다.

퀴즈 4: Prism을 사용할 때의 주요 장점은 무엇인가요?

정답: Prism의 주요 장점은 OpenAPI 스펙이 있으면 별도의 모킹 코드 없이 자동으로 모의 서버를 생성한다는 점입니다. 또한 Validation 모드에서 실제 API 요청/응답을 스펙과 대조하여 계약 준수 여부를 자동으로 검증할 수 있습니다.

설명: API 스펙 우선 개발(API-First Design) 방식에서 특히 유용합니다. 백엔드 구현 전에 API 스펙을 먼저 정의하면, 프론트엔드는 Prism으로 즉시 개발을 시작할 수 있고, 백엔드가 완성된 후에는 Validation 모드로 스펙 준수 여부를 검증할 수 있습니다.

퀴즈 5: 모의 서버를 사용할 때 "계약 테스트(Contract Testing)"와 결합해야 하는 이유는 무엇인가요?

정답: 모의 서버는 실제 API와 동기화되지 않을 위험이 있습니다. 계약 테스트를 결합하면 모의 서버가 실제 API와 일치하는지 자동으로 검증할 수 있어 "모의 서버와 실제 API가 달라서 발생하는 버그"를 방지할 수 있습니다.

설명: 모의 서버를 사용해 개발했는데 실제 API가 변경되면, 개발 환경에서는 테스트가 통과하지만 실제 환경에서는 실패하는 문제가 발생할 수 있습니다. Pact와 같은 계약 테스트 도구를 사용하면 Consumer(프론트엔드)가 기대하는 API 계약을 Provider(백엔드)가 준수하는지 자동으로 검증할 수 있습니다.

Mock Server Building Complete Guide

Overview

In modern software development, frontend and backend teams almost always work in parallel. Waiting for backend APIs to be ready before starting frontend work, or being unable to access third-party services during testing, is a constant friction point.

A Mock Server is the key tool for solving these problems. By defining the API contract first, you can develop and test the frontend without a real backend.


1. Why You Need a Mock Server

1.1 Parallel Frontend-Backend Development

Traditional approach (sequential):
  Backend API complete → Frontend development begins
  └── Delay: frontend blocked waiting for backend

With a mock server (parallel):
  API spec defined → Backend dev || Frontend dev
No blocking via mock server

Both teams can work simultaneously as long as they agree on the API contract first.

1.2 Removing External API Dependencies

Third-party services like payment APIs, mapping services, or weather APIs come with problems:

  • Costs: APIs that charge per call
  • Rate Limiting: Slows down test execution
  • Service Instability: External outages break CI/CD pipelines
  • Test Data Pollution: Test data mixing into production data

1.3 Reproducing Error Scenarios

A mock server makes it easy to reproduce situations that are hard to trigger in real services:

  • Network timeouts
  • 500 Internal Server Error
  • 429 Too Many Requests
  • Network disconnection
  • Response delays (slow network simulation)

1.4 Test Environment Stability

Eliminating external dependencies in CI/CD:
  No real DBInMemory DB Fake
  No real APIMock Server
  No real queue    → In-process Fake

2. json-server (Fastest Start)

json-server lets you create a full REST API server from a single JSON file in under 30 seconds.

2.1 Installation and Basic Usage

# Global install
npm install -g json-server

# Or as a dev dependency
npm install --save-dev json-server

2.2 db.json Structure

{
  "users": [
    { "id": 1, "name": "Alice", "email": "alice@example.com", "role": "admin" },
    { "id": 2, "name": "Bob", "email": "bob@example.com", "role": "user" }
  ],
  "posts": [
    { "id": 1, "title": "First Post", "body": "Content here", "userId": 1, "published": true },
    { "id": 2, "title": "Second Post", "body": "Content here", "userId": 2, "published": false }
  ],
  "comments": [{ "id": 1, "text": "Great post!", "postId": 1, "userId": 2 }],
  "profile": {
    "name": "Admin",
    "version": "1.0.0"
  }
}

Start json-server:

json-server --watch db.json --port 3001

Auto-generated endpoints:

GET    /users
GET    /users/1
POST   /users
PUT    /users/1
PATCH  /users/1
DELETE /users/1
GET    /posts?userId=1
GET    /posts?published=true
GET    /posts?_page=1&_limit=10
GET    /posts?_sort=title&_order=asc

2.3 Custom Routing (routes.json)

{
  "/api/*": "/$1",
  "/api/v1/users/:id": "/users/:id",
  "/blog/:id/comments": "/comments?postId=:id"
}

Apply routing on startup:

json-server --watch db.json --routes routes.json --port 3001

2.4 Adding Middleware

// middleware.js
module.exports = (req, res, next) => {
  // Check auth header
  if (req.headers.authorization !== 'Bearer valid-token') {
    if (req.method !== 'GET') {
      return res.status(401).json({ error: 'Unauthorized' })
    }
  }

  // Simulate response delay (500ms)
  setTimeout(next, 500)
}
json-server --watch db.json --middlewares middleware.js

2.5 Relational Data Modeling

json-server supports relational data via _embed and _expand:

# Get post with embedded comments
GET /posts/1?_embed=comments

# Get post with expanded user info
GET /posts?_expand=user

# Nested relationships
GET /users?_embed=posts

2.6 Pagination, Sorting, and Filtering

# Pagination (10 per page, page 2)
GET /posts?_page=2&_limit=10

# Sort by title descending
GET /posts?_sort=title&_order=desc

# Filter published posts
GET /posts?published=true

# Full-text search
GET /posts?q=search-term

# Range filter
GET /posts?id_gte=5&id_lte=10

2.7 Custom Server Script

// server.js - for finer control
const jsonServer = require('json-server')
const server = jsonServer.create()
const router = jsonServer.router('db.json')
const middlewares = jsonServer.defaults()

server.use(middlewares)

// Custom routes
server.post('/auth/login', (req, res) => {
  const { email, password } = req.body
  if (email === 'admin@example.com' && password === 'password') {
    res.json({ token: 'fake-jwt-token', userId: 1 })
  } else {
    res.status(401).json({ error: 'Invalid credentials' })
  }
})

server.use(router)
server.listen(3001, () => {
  console.log('JSON Server is running on port 3001')
})

3. MSW (Mock Service Worker) - The Modern Approach

MSW uses Service Workers to intercept requests at the network level. It lets you reuse the same handlers in both the browser and Node.js.

3.1 Installation and Setup

npm install msw --save-dev

# Generate Service Worker file in the public directory
npx msw init public/ --save

3.2 Writing REST API Handlers

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  // GET list of users
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'Alice', email: 'alice@example.com' },
      { id: 2, name: 'Bob', email: 'bob@example.com' },
    ])
  }),

  // GET specific user
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params

    if (id === '999') {
      return new HttpResponse(null, { status: 404 })
    }

    return HttpResponse.json({
      id: Number(id),
      name: 'Alice',
      email: 'alice@example.com',
    })
  }),

  // POST create user
  http.post('/api/users', async ({ request }) => {
    const newUser = await request.json()

    return HttpResponse.json({ id: Date.now(), ...newUser }, { status: 201 })
  }),

  // PUT update user
  http.put('/api/users/:id', async ({ params, request }) => {
    const { id } = params
    const updates = await request.json()

    return HttpResponse.json({ id: Number(id), ...updates })
  }),

  // DELETE user
  http.delete('/api/users/:id', ({ params }) => {
    return new HttpResponse(null, { status: 204 })
  }),
]

3.3 GraphQL Handlers

import { graphql, HttpResponse } from 'msw'

export const graphqlHandlers = [
  graphql.query('GetUser', ({ variables }) => {
    const { id } = variables

    return HttpResponse.json({
      data: {
        user: {
          id,
          name: 'Alice',
          email: 'alice@example.com',
          posts: [{ id: 1, title: 'First Post' }],
        },
      },
    })
  }),

  graphql.mutation('CreateUser', ({ variables }) => {
    const { name, email } = variables

    return HttpResponse.json({
      data: {
        createUser: {
          id: String(Date.now()),
          name,
          email,
        },
      },
    })
  }),
]

3.4 Dynamic Responses Using Request Parameters

import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/products', ({ request }) => {
    const url = new URL(request.url)
    const category = url.searchParams.get('category')
    const page = Number(url.searchParams.get('page') ?? '1')
    const limit = Number(url.searchParams.get('limit') ?? '10')

    const allProducts = [
      { id: 1, name: 'Laptop', category: 'electronics', price: 1200 },
      { id: 2, name: 'Mouse', category: 'electronics', price: 30 },
      { id: 3, name: 'Desk', category: 'furniture', price: 250 },
    ]

    let filtered = allProducts
    if (category) {
      filtered = allProducts.filter((p) => p.category === category)
    }

    const start = (page - 1) * limit
    const data = filtered.slice(start, start + limit)

    return HttpResponse.json({
      data,
      total: filtered.length,
      page,
      limit,
    })
  }),
]

3.5 Error Response Scenarios

import { http, HttpResponse } from 'msw'

// Pattern to dynamically control error scenarios
let shouldFailNext = false

export const handlers = [
  // Test-only control endpoint
  http.post('/test/simulate-error', async ({ request }) => {
    const { fail } = await request.json()
    shouldFailNext = fail
    return new HttpResponse(null, { status: 200 })
  }),

  http.post('/api/payment', async ({ request }) => {
    if (shouldFailNext) {
      shouldFailNext = false
      return HttpResponse.json(
        { error: 'PAYMENT_DECLINED', message: 'Card was declined' },
        { status: 402 }
      )
    }

    const { amount } = await request.json()
    return HttpResponse.json({
      paymentId: `PAY-${Date.now()}`,
      amount,
      status: 'SUCCESS',
    })
  }),

  // Network error simulation
  http.get('/api/unstable', () => {
    if (Math.random() > 0.7) {
      return HttpResponse.error()
    }
    return HttpResponse.json({ data: 'success' })
  }),
]

3.6 Browser Environment Setup

// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

export const worker = setupWorker(...handlers)
// src/main.tsx (React entry point)
async function enableMocking() {
  if (process.env.NODE_ENV !== 'development') {
    return
  }

  const { worker } = await import('./mocks/browser')
  return worker.start({
    onUnhandledRequest: 'bypass' // Pass through unhandled requests
  })
}

enableMocking().then(() => {
  ReactDOM.createRoot(document.getElementById('root')!).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  )
})

3.7 Node.js Environment Setup (for Tests)

// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)
// jest.setup.ts
import { server } from './src/mocks/server'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// Override handlers in specific tests
import { server } from '../mocks/server'
import { http, HttpResponse } from 'msw'

test('handles API error gracefully', async () => {
  server.use(
    http.get('/api/users', () => {
      return HttpResponse.json({ error: 'Server Error' }, { status: 500 })
    })
  )

  const result = await fetchUsers()
  expect(result.error).toBe('Server Error')
})

3.8 MSW in Next.js

// next.config.js
const nextConfig = {
  async headers() {
    return [
      {
        source: '/mockServiceWorker.js',
        headers: [{ key: 'Service-Worker-Allowed', value: '/' }],
      },
    ]
  },
}
// src/app/providers.tsx (App Router)
'use client'

import { useEffect } from 'react'

export function MSWProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled') {
      import('../mocks/browser').then(({ worker }) => {
        worker.start({ onUnhandledRequest: 'bypass' })
      })
    }
  }, [])

  return children
}

4. WireMock - Java/JVM Environment

WireMock is the most widely used HTTP mock server in the Java/JVM ecosystem.

4.1 Running WireMock Standalone

# Download WireMock
curl -o wiremock.jar https://repo1.maven.org/maven2/com/github/tomakehurst/wiremock-jre8-standalone/2.35.0/wiremock-jre8-standalone-2.35.0.jar

# Run
java -jar wiremock.jar --port 8080 --verbose

Directory structure:

wiremock/
├── mappings/          # Stub definition JSON files
│   ├── users.json
│   └── orders.json
└── __files/           # Response body files
    ├── users.json
    └── order-template.json

4.2 Defining Stubs with JSON Files

{
  "request": {
    "method": "GET",
    "url": "/api/users"
  },
  "response": {
    "status": 200,
    "headers": {
      "Content-Type": "application/json"
    },
    "jsonBody": [
      { "id": 1, "name": "Alice", "email": "alice@example.com" },
      { "id": 2, "name": "Bob", "email": "bob@example.com" }
    ]
  }
}

4.3 Request Matching (URL, Headers, Body)

{
  "request": {
    "method": "POST",
    "url": "/api/orders",
    "headers": {
      "Content-Type": {
        "equalTo": "application/json"
      },
      "Authorization": {
        "matches": "Bearer .+"
      }
    },
    "bodyPatterns": [
      {
        "matchesJsonPath": "$.items[*].productId"
      },
      {
        "equalToJson": {
          "userId": 1
        },
        "ignoreArrayOrder": true,
        "ignoreExtraElements": true
      }
    ]
  },
  "response": {
    "status": 201,
    "jsonBody": {
      "orderId": "ORDER-001",
      "status": "PENDING"
    }
  }
}

4.4 Response Templating (Handlebars)

{
  "request": {
    "method": "GET",
    "urlPathPattern": "/api/users/([0-9]+)"
  },
  "response": {
    "status": 200,
    "headers": {
      "Content-Type": "application/json"
    },
    "body": "{ \"id\": {{request.pathSegments.[2]}}, \"name\": \"User-{{request.pathSegments.[2]}}\", \"timestamp\": \"{{now}}\" }",
    "transformers": ["response-template"]
  }
}

4.5 Scenarios (State-Based Mocking)

Scenarios let you return different responses based on current state:

{
  "scenarioName": "Order Processing Flow",
  "requiredScenarioState": "Started",
  "newScenarioState": "Payment Complete",
  "request": {
    "method": "POST",
    "url": "/api/orders/pay"
  },
  "response": {
    "status": 200,
    "jsonBody": { "status": "PAID" }
  }
}
{
  "scenarioName": "Order Processing Flow",
  "requiredScenarioState": "Payment Complete",
  "newScenarioState": "Shipping",
  "request": {
    "method": "GET",
    "url": "/api/orders/ORDER-001"
  },
  "response": {
    "status": 200,
    "jsonBody": { "status": "SHIPPING" }
  }
}

4.6 Record and Playback

Record real API responses and replay them:

# Run in record mode (proxy to real API)
java -jar wiremock.jar --proxy-all="https://api.example.com" --record-mappings --port 8080

# Replay recorded responses (default mode)
java -jar wiremock.jar --port 8080

5. Prism - OpenAPI-Based Mock Server

Prism automatically generates a mock server from an OpenAPI (Swagger) specification.

5.1 Installation and Usage

npm install -g @stoplight/prism-cli

# Run a mock server from an OpenAPI spec
npx @stoplight/prism-cli mock api.yaml --port 4010

5.2 OpenAPI Spec Example

openapi: 3.0.0
info:
  title: User API
  version: 1.0.0
paths:
  /users:
    get:
      operationId: getUsers
      responses:
        '200':
          description: List of users
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/User'
              examples:
                default:
                  value:
                    - id: 1
                      name: Alice
                      email: alice@example.com
    post:
      operationId: createUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          description: User created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
components:
  schemas:
    User:
      type: object
      required: [id, name, email]
      properties:
        id:
          type: integer
        name:
          type: string
        email:
          type: string
          format: email
    CreateUserRequest:
      type: object
      required: [name, email]
      properties:
        name:
          type: string
        email:
          type: string
          format: email

5.3 Dynamic Response Generation

Prism generates values automatically from schemas when no example is provided:

# Dynamic mode (schema-based random responses)
npx @stoplight/prism-cli mock api.yaml --dynamic

5.4 Validation Mode

# Validation mode - validates requests and responses against the spec
npx @stoplight/prism-cli proxy api.yaml https://api.example.com --validate-request --validate-response

6. Building a Mock Server in Go

The Go standard library is sufficient to build a complete mock server.

6.1 Core Mock Server Structure

package mockserver

import (
    "encoding/json"
    "log"
    "net/http"
    "sync"
    "time"
)

type MockServer struct {
    server *http.Server
    mux    *http.ServeMux
    mu     sync.RWMutex
    stubs  map[string]*Stub
    calls  []RequestLog
}

type Stub struct {
    Method     string
    Path       string
    StatusCode int
    Body       interface{}
    Headers    map[string]string
    Delay      time.Duration
}

type RequestLog struct {
    Method    string
    Path      string
    Headers   map[string][]string
    Body      []byte
    Timestamp time.Time
}

func NewMockServer(port string) *MockServer {
    ms := &MockServer{
        mux:   http.NewServeMux(),
        stubs: make(map[string]*Stub),
    }

    ms.server = &http.Server{
        Addr:    ":" + port,
        Handler: ms.mux,
    }

    ms.mux.HandleFunc("/", ms.handleRequest)

    // Admin control endpoints
    ms.mux.HandleFunc("/__admin/stubs", ms.handleAdminStubs)
    ms.mux.HandleFunc("/__admin/calls", ms.handleAdminCalls)
    ms.mux.HandleFunc("/__admin/reset", ms.handleAdminReset)

    return ms
}

func (ms *MockServer) Start() {
    go func() {
        log.Printf("MockServer starting on %s", ms.server.Addr)
        if err := ms.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("MockServer failed: %v", err)
        }
    }()
    time.Sleep(100 * time.Millisecond)
}

func (ms *MockServer) Stop() {
    ms.server.Close()
}

func (ms *MockServer) AddStub(stub *Stub) {
    ms.mu.Lock()
    defer ms.mu.Unlock()
    key := stub.Method + ":" + stub.Path
    ms.stubs[key] = stub
}

func (ms *MockServer) handleRequest(w http.ResponseWriter, r *http.Request) {
    // Log the request
    ms.mu.Lock()
    ms.calls = append(ms.calls, RequestLog{
        Method:    r.Method,
        Path:      r.URL.Path,
        Headers:   r.Header,
        Timestamp: time.Now(),
    })
    ms.mu.Unlock()

    // Look up stub
    ms.mu.RLock()
    key := r.Method + ":" + r.URL.Path
    stub, ok := ms.stubs[key]
    ms.mu.RUnlock()

    if !ok {
        http.Error(w, "No stub found for "+r.Method+" "+r.URL.Path, http.StatusNotFound)
        return
    }

    // Simulate response delay
    if stub.Delay > 0 {
        time.Sleep(stub.Delay)
    }

    // Set headers
    for k, v := range stub.Headers {
        w.Header().Set(k, v)
    }
    if w.Header().Get("Content-Type") == "" {
        w.Header().Set("Content-Type", "application/json")
    }

    w.WriteHeader(stub.StatusCode)

    if stub.Body != nil {
        json.NewEncoder(w).Encode(stub.Body)
    }
}

func (ms *MockServer) handleAdminReset(w http.ResponseWriter, r *http.Request) {
    ms.mu.Lock()
    ms.stubs = make(map[string]*Stub)
    ms.calls = nil
    ms.mu.Unlock()
    w.WriteHeader(http.StatusOK)
}

func (ms *MockServer) handleAdminCalls(w http.ResponseWriter, r *http.Request) {
    ms.mu.RLock()
    defer ms.mu.RUnlock()
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(ms.calls)
}

func (ms *MockServer) handleAdminStubs(w http.ResponseWriter, r *http.Request) {
    if r.Method == http.MethodPost {
        var stub Stub
        if err := json.NewDecoder(r.Body).Decode(&stub); err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }
        ms.AddStub(&stub)
        w.WriteHeader(http.StatusCreated)
        return
    }

    ms.mu.RLock()
    defer ms.mu.RUnlock()
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(ms.stubs)
}

6.2 Using the Mock Server in Tests

func TestUserService_GetUser(t *testing.T) {
    ms := NewMockServer("9999")
    ms.Start()
    defer ms.Stop()

    ms.AddStub(&Stub{
        Method:     "GET",
        Path:       "/api/users/1",
        StatusCode: 200,
        Body: map[string]interface{}{
            "id":    1,
            "name":  "Alice",
            "email": "alice@example.com",
        },
    })

    client := NewUserServiceClient("http://localhost:9999")
    user, err := client.GetUser(1)

    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if user.Name != "Alice" {
        t.Errorf("expected Alice, got %s", user.Name)
    }
}

func TestUserService_GetUser_NotFound(t *testing.T) {
    ms := NewMockServer("9998")
    ms.Start()
    defer ms.Stop()

    ms.AddStub(&Stub{
        Method:     "GET",
        Path:       "/api/users/999",
        StatusCode: 404,
        Body:       map[string]string{"error": "User not found"},
    })

    client := NewUserServiceClient("http://localhost:9998")
    _, err := client.GetUser(999)

    if err == nil {
        t.Error("expected error, got nil")
    }
}

7. Building a Mock Server in TypeScript/Node.js

7.1 Express.js + TypeScript Mock Server

// mock-server/index.ts
import express, { Request, Response, NextFunction } from 'express'
import fs from 'fs'
import path from 'path'

interface StubDefinition {
  method: string
  path: string
  statusCode: number
  body?: unknown
  headers?: Record<string, string>
  delay?: number
}

const app = express()
app.use(express.json())

const stubs: Map<string, StubDefinition> = new Map()
const requestLog: Array<{ method: string; path: string; timestamp: string }> = []

// Request logging middleware
app.use((req: Request, res: Response, next: NextFunction) => {
  requestLog.push({
    method: req.method,
    path: req.path,
    timestamp: new Date().toISOString(),
  })
  next()
})

// Admin API
app.post('/__admin/stubs', (req: Request, res: Response) => {
  const stub: StubDefinition = req.body
  const key = `${stub.method}:${stub.path}`
  stubs.set(key, stub)
  res.status(201).json({ message: 'Stub registered', key })
})

app.delete('/__admin/stubs', (req: Request, res: Response) => {
  stubs.clear()
  requestLog.length = 0
  res.status(200).json({ message: 'All stubs cleared' })
})

app.get('/__admin/calls', (req: Request, res: Response) => {
  res.json(requestLog)
})

// Dynamic response handler
app.use(async (req: Request, res: Response) => {
  const key = `${req.method}:${req.path}`
  const stub = stubs.get(key)

  if (!stub) {
    return res.status(404).json({
      error: 'No stub found',
      request: { method: req.method, path: req.path },
    })
  }

  // Response delay
  if (stub.delay && stub.delay > 0) {
    await new Promise((resolve) => setTimeout(resolve, stub.delay))
  }

  // Set headers
  if (stub.headers) {
    Object.entries(stub.headers).forEach(([key, value]) => {
      res.setHeader(key, value)
    })
  }

  res.status(stub.statusCode).json(stub.body)
})

const PORT = process.env.MOCK_PORT ?? '3001'
app.listen(PORT, () => {
  console.log(`Mock server running on port ${PORT}`)
})

export { app }

7.2 File-Based Response Routing

// Manage responses via directory structure
// responses/
//   GET/
//     api/
//       users/
//         index.json    -> GET /api/users
//         1.json        -> GET /api/users/1
//   POST/
//     api/
//       users/
//         index.json    -> POST /api/users (response template)

app.use((req: Request, res: Response) => {
  const responsePath = path.join(__dirname, 'responses', req.method, req.path, 'index.json')

  if (fs.existsSync(responsePath)) {
    const responseData = JSON.parse(fs.readFileSync(responsePath, 'utf-8'))
    res.json(responseData)
  } else {
    res.status(404).json({ error: 'No response file found' })
  }
})

7.3 Middleware for Delay and Error Injection

// chaos-middleware.ts - middleware for chaos engineering
interface ChaosConfig {
  errorRate: number // 0.0 to 1.0
  minDelay: number // ms
  maxDelay: number // ms
}

let chaosConfig: ChaosConfig = {
  errorRate: 0,
  minDelay: 0,
  maxDelay: 0,
}

export const chaosMiddleware = async (req: Request, res: Response, next: NextFunction) => {
  // Response delay
  const delay = chaosConfig.minDelay + Math.random() * (chaosConfig.maxDelay - chaosConfig.minDelay)
  if (delay > 0) {
    await new Promise((resolve) => setTimeout(resolve, delay))
  }

  // Random error injection
  if (Math.random() < chaosConfig.errorRate) {
    return res.status(500).json({
      error: 'Chaos monkey strikes!',
      timestamp: new Date().toISOString(),
    })
  }

  next()
}

// Config update API
app.post('/__admin/chaos', (req: Request, res: Response) => {
  chaosConfig = { ...chaosConfig, ...req.body }
  res.json({ message: 'Chaos config updated', config: chaosConfig })
})

8. Docker Compose Environment for Mock Servers

8.1 docker-compose.yml

version: '3.8'

services:
  # json-server mock
  json-server:
    image: clue/json-server
    volumes:
      - ./mock-data/db.json:/data/db.json
      - ./mock-data/routes.json:/data/routes.json
    ports:
      - '3001:80'
    command: --watch db.json --routes routes.json
    healthcheck:
      test: ['CMD', 'wget', '-qO-', 'http://localhost/users']
      interval: 10s
      timeout: 5s
      retries: 3

  # WireMock server
  wiremock:
    image: wiremock/wiremock:latest
    ports:
      - '8080:8080'
    volumes:
      - ./wiremock/mappings:/home/wiremock/mappings
      - ./wiremock/__files:/home/wiremock/__files
    command:
      - '--verbose'
      - '--global-response-templating'
    healthcheck:
      test: ['CMD', 'wget', '-qO-', 'http://localhost:8080/__admin/health']
      interval: 10s
      timeout: 5s
      retries: 3

  # Application (uses mock servers as dependencies)
  app:
    build: .
    ports:
      - '8000:8000'
    environment:
      - USER_SERVICE_URL=http://json-server:80
      - PAYMENT_SERVICE_URL=http://wiremock:8080
    depends_on:
      json-server:
        condition: service_healthy
      wiremock:
        condition: service_healthy

  # E2E test runner
  e2e-tests:
    build:
      context: .
      dockerfile: Dockerfile.test
    environment:
      - APP_URL=http://app:8000
    depends_on:
      - app
    command: npm run test:e2e
    profiles:
      - test

8.2 Test Execution Script

#!/bin/bash
# run-tests.sh

echo "Starting mock server environment..."
docker-compose up -d json-server wiremock

echo "Waiting for health checks..."
docker-compose exec json-server wget -qO- http://localhost/users
docker-compose exec wiremock wget -qO- http://localhost:8080/__admin/health

echo "Starting application..."
docker-compose up -d app

echo "Running E2E tests..."
docker-compose run --rm e2e-tests

echo "Cleaning up..."
docker-compose down

8.3 Mock Data Initialization Script

// scripts/setup-mock-data.ts
const WIREMOCK_URL = 'http://localhost:8080/__admin/mappings'

const stubs = [
  {
    request: {
      method: 'POST',
      url: '/api/payment/charge',
    },
    response: {
      status: 200,
      headers: { 'Content-Type': 'application/json' },
      jsonBody: {
        paymentId: 'TEST-PAYMENT-001',
        status: 'SUCCESS',
        amount: 100,
      },
    },
  },
  {
    request: {
      method: 'POST',
      url: '/api/payment/charge',
      headers: {
        'X-Simulate-Failure': { equalTo: 'true' },
      },
    },
    response: {
      status: 402,
      headers: { 'Content-Type': 'application/json' },
      jsonBody: {
        error: 'PAYMENT_DECLINED',
        message: 'Card was declined',
      },
    },
  },
]

async function setupMockData() {
  for (const stub of stubs) {
    const response = await fetch(WIREMOCK_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(stub),
    })
    console.log(`Stub registered: ${response.status}`)
  }
}

setupMockData().catch(console.error)

9. Quiz

Quiz 1: What is the key difference between MSW and other mocking approaches like axios-mock-adapter?

Answer: MSW uses a Service Worker to intercept requests at the network level, so it works regardless of which HTTP library the application uses. You can also reuse the same handlers in both the browser and Node.js.

Explanation: Libraries like axios-mock-adapter mock the HTTP client itself, so changing the HTTP client requires rewriting your mock code. MSW intercepts at the actual network layer, meaning it works with fetch, axios, or any other HTTP client transparently. This also makes your mocks more realistic because the full HTTP stack is exercised.

Quiz 2: What is the main limitation of json-server, and how can you overcome it?

Answer: The main limitation of json-server is that it is difficult to implement complex business logic beyond simple CRUD operations.

Explanation: To overcome this, you can use json-server as a library and attach custom routes (server.post('/auth/login', ...)), or write custom middleware to handle more complex logic. For significantly complex scenarios, switching to WireMock or a custom-built mock server is a better approach.

Quiz 3: In what situations is WireMock's Scenarios feature most useful?

Answer: Scenarios are most useful for testing workflows where different responses should be returned based on current state — for example, an order status flow (payment pending → paid → shipping → delivered) or retry logic (first request fails, second succeeds).

Explanation: A standard stub always returns the same response. With Scenarios, each incoming request transitions the state machine to the next state and returns a response appropriate for that state. This is essential for testing non-idempotent APIs and stateful workflows.

Quiz 4: What is the primary advantage of using Prism?

Answer: Prism's main advantage is that it automatically generates a mock server from an OpenAPI spec with no additional mocking code. In Validation mode, it also verifies that real API requests and responses conform to the spec.

Explanation: Prism is particularly valuable in an API-First Design workflow. Once the API spec is defined, the frontend can immediately start development using Prism, and once the backend is complete, Validation mode can automatically verify spec compliance. This eliminates spec drift between documentation and implementation.

Quiz 5: Why should mock servers be combined with Contract Testing?

Answer: Mock servers can drift out of sync with real APIs. Combining with Contract Testing automatically verifies that the mock server matches the real API, preventing bugs caused by mismatches between the mock and the real service.

Explanation: If you develop against a mock server but the real API changes, tests pass in development but fail in production. Tools like Pact let the consumer (frontend) define its expectations as a contract, and the provider (backend) automatically verifies it satisfies those expectations. This creates a safety net that catches integration bugs before deployment.