Skip to content

Split View: [DevOps] Stub과 Mocking 서버 완전 가이드: 개념부터 실전까지

|

[DevOps] Stub과 Mocking 서버 완전 가이드: 개념부터 실전까지


1. Test Double이란

소프트웨어 테스트에서 Test Double은 실제 의존 컴포넌트를 대체하는 객체를 총칭한다. Martin Fowler가 정리한 분류에 따르면 Test Double은 크게 다섯 가지로 나뉜다.

1.1 Dummy

테스트에서 파라미터를 채우기 위해 전달하지만 실제로 사용되지 않는 객체다. 메서드 시그니처를 만족시키기 위해서만 존재한다.

// Dummy 예시: 실제로 호출되지 않는 logger
public class DummyLogger implements Logger {
    @Override
    public void log(String message) {
        // 아무것도 하지 않음
    }
}

1.2 Stub

Stub은 미리 정해진 응답을 반환하는 Test Double이다. 호출에 대해 고정된 데이터를 돌려주며, 상태 검증(State Verification) 에 사용된다.

// Stub 예시: 항상 고정된 사용자를 반환
public class StubUserRepository implements UserRepository {
    @Override
    public User findById(Long id) {
        return new User(1L, "testuser", "test@example.com");
    }
}

핵심 특성:

  • 미리 프로그래밍된 응답만 반환
  • 호출 횟수나 순서를 검증하지 않음
  • 테스트 후 결과 상태를 확인하여 검증

1.3 Mock

Mock은 호출 자체를 검증하는 Test Double이다. 특정 메서드가 특정 인자로 호출되었는지, 몇 번 호출되었는지 등 행위 검증(Behavior Verification) 에 초점을 맞춘다.

// Mockito를 사용한 Mock 예시
@Test
void shouldSendWelcomeEmail() {
    EmailService mockEmailService = mock(EmailService.class);
    UserService userService = new UserService(mockEmailService);

    userService.registerUser("test@example.com");

    // 행위 검증: sendWelcomeEmail이 정확히 1번 호출되었는지 확인
    verify(mockEmailService, times(1))
        .sendWelcomeEmail("test@example.com");
}

핵심 특성:

  • 기대하는 호출(expectation)을 미리 설정
  • 호출 횟수, 순서, 인자를 검증
  • 기대와 다르게 호출되면 테스트 실패

1.4 Fake

Fake는 실제로 동작하는 경량 구현체다. 프로덕션에서는 사용하지 않지만 테스트 목적에는 충분히 동작한다.

// Fake 예시: In-Memory 데이터베이스
public class FakeUserRepository implements UserRepository {
    private final Map<Long, User> store = new HashMap<>();
    private long sequence = 1L;

    @Override
    public User save(User user) {
        user.setId(sequence++);
        store.put(user.getId(), user);
        return user;
    }

    @Override
    public User findById(Long id) {
        return store.get(id);
    }

    @Override
    public List<User> findAll() {
        return new ArrayList<>(store.values());
    }
}

대표적인 Fake 예시:

  • H2 같은 인메모리 데이터베이스
  • 로컬 파일 시스템 기반 스토리지
  • 인메모리 메시지 큐

1.5 Spy

Spy는 실제 객체를 감싸서 호출 기록을 남기는 Test Double이다. 실제 동작을 수행하면서 동시에 호출 정보를 기록한다.

// Mockito Spy 예시
@Test
void spyExample() {
    List<String> realList = new ArrayList<>();
    List<String> spyList = spy(realList);

    spyList.add("hello");  // 실제로 요소가 추가됨

    verify(spyList).add("hello");  // 호출 기록 확인
    assertEquals(1, spyList.size());  // 실제 동작 확인
}

1.6 비교 요약 표

구분동작검증 방식대표 사례
Dummy없음 (파라미터 채움용)없음빈 구현체
Stub고정 응답 반환상태 검증하드코딩된 응답
Mock호출 기대 설정행위 검증Mockito mock
Fake실제 경량 구현상태 검증In-memory DB
Spy실제 동작 + 기록행위 + 상태Mockito spy

2. Mocking 서버가 필요한 이유

마이크로서비스 아키텍처에서 외부 API에 의존하는 서비스를 테스트할 때 다음과 같은 문제가 발생한다.

  • 외부 서비스 불안정: 외부 API가 다운되면 내 테스트도 실패
  • Rate Limit: 외부 API의 호출 제한으로 테스트 반복 불가
  • 비용: 유료 API를 테스트마다 호출하면 비용 발생
  • 속도: 네트워크 지연으로 테스트 시간 증가
  • 엣지 케이스 재현: 특정 에러 응답, 타임아웃 등을 재현하기 어려움
  • 개발 환경 독립성: 외부 서비스 없이도 개발 가능해야 함

Mocking 서버는 이 모든 문제를 해결해 준다. 실제 외부 서비스와 동일한 인터페이스를 제공하면서 예측 가능한 응답을 반환한다.

┌─────────────┐      ┌──────────────┐      ┌──────────────┐
│  테스트코드   │─────>│  내 서비스     │─────>│  Mocking 서버  │
│             │      │              │      │  (WireMock등)  │
└─────────────┘      └──────────────┘      └──────────────┘
                                           미리 정의된 응답 반환

3. 주요 Mocking 서버 도구

3.1 WireMock

WireMock은 가장 강력하고 기능이 풍부한 오픈소스 Mock 서버다. Java 기반이지만 독립 실행형(Standalone)으로도 사용할 수 있다.

주요 기능:

  • JSON 기반 Stub 매핑 파일
  • 강력한 Request Matching (URL, 헤더, 바디, 쿼리 파라미터)
  • Response Templating (Handlebars 기반)
  • Record/Playback 모드 (실제 API 트래픽 녹화)
  • Stateful Behavior (시나리오 기반 상태 전이)
  • Fault Injection (지연, 연결 끊김 시뮬레이션)
  • Docker 이미지 제공

Docker로 WireMock 실행:

docker run -d --name wiremock \
  -p 8080:8080 \
  -v $(pwd)/stubs:/home/wiremock \
  wiremock/wiremock:latest \
  --global-response-templating \
  --verbose

기본 Stub 매핑 파일 구조:

stubs/mappings/get-user.json:

{
  "request": {
    "method": "GET",
    "urlPathPattern": "/api/users/[0-9]+",
    "headers": {
      "Accept": {
        "contains": "application/json"
      }
    }
  },
  "response": {
    "status": 200,
    "headers": {
      "Content-Type": "application/json"
    },
    "jsonBody": {
      "id": 1,
      "name": "John Doe",
      "email": "john@example.com"
    }
  }
}

Request Matching 패턴:

{
  "request": {
    "method": "POST",
    "urlPath": "/api/orders",
    "bodyPatterns": [
      {
        "matchesJsonPath": "$.items[?(@.quantity > 0)]"
      },
      {
        "matchesJsonPath": {
          "expression": "$.customerId",
          "regex": "^[A-Z]{2}[0-9]{6}$"
        }
      }
    ],
    "queryParameters": {
      "status": {
        "equalTo": "active"
      }
    }
  },
  "response": {
    "status": 201,
    "jsonBody": {
      "orderId": "ORD-001",
      "status": "created"
    }
  }
}

Response Templating 예제:

{
  "request": {
    "method": "GET",
    "urlPathTemplate": "/api/users/{userId}"
  },
  "response": {
    "status": 200,
    "headers": {
      "Content-Type": "application/json"
    },
    "jsonBody": {
      "id": "{{request.pathSegments.[2]}}",
      "requestedAt": "{{now}}",
      "userAgent": "{{request.headers.User-Agent}}"
    },
    "transformers": ["response-template"]
  }
}

Stateful Behavior (시나리오):

{
  "mappings": [
    {
      "scenarioName": "OrderFlow",
      "requiredScenarioState": "Started",
      "newScenarioState": "OrderCreated",
      "request": {
        "method": "POST",
        "urlPath": "/api/orders"
      },
      "response": {
        "status": 201,
        "jsonBody": { "status": "created" }
      }
    },
    {
      "scenarioName": "OrderFlow",
      "requiredScenarioState": "OrderCreated",
      "request": {
        "method": "GET",
        "urlPath": "/api/orders/1"
      },
      "response": {
        "status": 200,
        "jsonBody": { "status": "processing" }
      }
    }
  ]
}

Record/Playback 모드:

# 실제 API로 프록시하며 응답을 녹화
docker run -d --name wiremock \
  -p 8080:8080 \
  -v $(pwd)/stubs:/home/wiremock \
  wiremock/wiremock:latest \
  --proxy-all="https://api.example.com" \
  --record-mappings

3.2 MockServer

MockServer는 Java 기반의 Mock 서버로 WireMock과 유사하지만 차별화된 기능을 제공한다.

주요 특징:

  • 런타임 동적 Expectation 설정 (API 호출로 Stub 추가/수정)
  • Forward Proxy 모드 지원
  • 요청/응답 검증 API
  • OpenAPI 스펙 기반 자동 Mock

Docker 실행:

docker run -d --name mockserver \
  -p 1080:1080 \
  mockserver/mockserver:latest

Expectation 설정 (REST API):

curl -X PUT "http://localhost:1080/mockserver/expectation" \
  -H "Content-Type: application/json" \
  -d '{
    "httpRequest": {
      "method": "GET",
      "path": "/api/products",
      "queryStringParameters": {
        "category": ["electronics"]
      }
    },
    "httpResponse": {
      "statusCode": 200,
      "headers": {
        "Content-Type": ["application/json"]
      },
      "body": {
        "type": "JSON",
        "json": "[{\"id\": 1, \"name\": \"Laptop\", \"price\": 999.99}]"
      }
    },
    "times": {
      "unlimited": true
    }
  }'

Java 클라이언트로 Expectation 설정:

import org.mockserver.client.MockServerClient;
import static org.mockserver.model.HttpRequest.request;
import static org.mockserver.model.HttpResponse.response;

new MockServerClient("localhost", 1080)
    .when(
        request()
            .withMethod("GET")
            .withPath("/api/products")
            .withQueryStringParameter("category", "electronics")
    )
    .respond(
        response()
            .withStatusCode(200)
            .withHeader("Content-Type", "application/json")
            .withBody("[{\"id\": 1, \"name\": \"Laptop\"}]")
    );

3.3 json-server

json-server는 Node.js 기반의 경량 REST API 서버다. JSON 파일 하나만으로 즉시 REST API를 제공한다.

설치 및 실행:

npm install -g json-server

# db.json 파일 생성
cat > db.json << 'JSONEOF'
{
  "users": [
    { "id": 1, "name": "Alice", "email": "alice@example.com" },
    { "id": 2, "name": "Bob", "email": "bob@example.com" }
  ],
  "posts": [
    { "id": 1, "title": "Hello World", "userId": 1 },
    { "id": 2, "title": "Testing Guide", "userId": 2 }
  ]
}
JSONEOF

# 서버 실행
json-server --watch db.json --port 3001

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

# CRUD 자동 지원
GET    /users          # 전체 목록
GET    /users/1        # 단일 조회
POST   /users          # 생성
PUT    /users/1        # 전체 수정
PATCH  /users/1        # 부분 수정
DELETE /users/1        # 삭제

# 쿼리 기능
GET /users?name=Alice           # 필터링
GET /users?_sort=name&_order=asc  # 정렬
GET /users?_page=1&_limit=10    # 페이지네이션
GET /posts?_expand=user         # 관계 확장

커스텀 라우트와 미들웨어:

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

// 커스텀 미들웨어: 인증 헤더 검사
server.use((req, res, next) => {
  if (req.headers.authorization === 'Bearer test-token') {
    next()
  } else {
    res.status(401).json({ error: 'Unauthorized' })
  }
})

server.use(middlewares)

// 커스텀 라우트
server.get('/api/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() })
})

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

3.4 Prism (Stoplight)

Prism은 OpenAPI(Swagger) 스펙을 기반으로 자동으로 Mock 서버를 생성해 주는 도구다.

설치 및 실행:

npm install -g @stoplight/prism-cli

# OpenAPI 스펙 파일로 Mock 서버 실행
prism mock openapi.yaml --port 4010

# 동적 응답 생성 모드
prism mock openapi.yaml --dynamic --port 4010

# Validation Proxy 모드 (실제 서버 앞에서 요청/응답 검증)
prism proxy openapi.yaml https://api.example.com --port 4010

OpenAPI 스펙 예시:

openapi: 3.0.3
info:
  title: User API
  version: 1.0.0
paths:
  /users:
    get:
      summary: List users
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                      example: 1
                    name:
                      type: string
                      example: 'Alice'
                    email:
                      type: string
                      format: email
                      example: 'alice@example.com'
              examples:
                default:
                  value:
                    - id: 1
                      name: 'Alice'
                      email: 'alice@example.com'
        '401':
          description: Unauthorized

Prism은 스펙의 example 값을 우선 사용하고, --dynamic 모드에서는 스키마에 기반한 랜덤 데이터를 생성한다.

3.5 mitmproxy

mitmproxy는 HTTP/HTTPS 프록시로, 실제 트래픽을 가로채고 수정할 수 있다.

# 설치
pip install mitmproxy

# 인터셉트 프록시 실행
mitmproxy --listen-port 8888

# 스크립트 기반 응답 수정
mitmproxy -s modify_response.py
# modify_response.py
import json
from mitmproxy import http

def response(flow: http.HTTPFlow) -> None:
    if "/api/users" in flow.request.pretty_url:
        flow.response.status_code = 200
        flow.response.set_text(json.dumps([
            {"id": 1, "name": "Mocked User"}
        ]))
        flow.response.headers["Content-Type"] = "application/json"

4. 도구 비교 표

기능WireMockMockServerjson-serverPrismmitmproxy
언어JavaJavaNode.jsNode.jsPython
설정 방식JSON 파일/APIAPIJSON 파일OpenAPI 스펙Python 스크립트
Request Matching매우 강력매우 강력기본스펙 기반스크립트 기반
Response TemplatingHandlebarsVelocity/Mustache제한적스펙 기반자유로움
Record/PlaybackOOXXO
Stateful BehaviorO (시나리오)OO (CRUD)XO (스크립트)
OpenAPI 통합플러그인OX네이티브X
Docker 지원OOOOO
Fault InjectionOOXXO
HTTPS 지원OOXOO
학습 곡선중간중간매우 낮음낮음중간
적합한 용도범용 통합 테스트동적 API 테스트프론트엔드 개발API-First 개발디버깅/분석

5. WireMock 실전: Docker Compose 환경

5.1 프로젝트 구조

project/
  docker-compose.yaml
  wiremock/
    mappings/
      get-users.json
      post-order.json
      health-check.json
    __files/
      user-list.json
      error-response.json

5.2 Docker Compose 설정

version: '3.8'
services:
  wiremock:
    image: wiremock/wiremock:latest
    ports:
      - '8080:8080'
    volumes:
      - ./wiremock/mappings:/home/wiremock/mappings
      - ./wiremock/__files:/home/wiremock/__files
    command:
      - '--global-response-templating'
      - '--verbose'
      - '--disable-gzip'
    healthcheck:
      test: ['CMD', 'curl', '-f', 'http://localhost:8080/__admin/health']
      interval: 10s
      timeout: 5s
      retries: 3

  my-service:
    build: .
    environment:
      - EXTERNAL_API_URL=http://wiremock:8080
    depends_on:
      wiremock:
        condition: service_healthy

5.3 다양한 매핑 예제

지연 응답 시뮬레이션:

{
  "request": {
    "method": "GET",
    "urlPath": "/api/slow-endpoint"
  },
  "response": {
    "status": 200,
    "fixedDelayMilliseconds": 3000,
    "jsonBody": { "message": "delayed response" }
  }
}

랜덤 지연 (지터):

{
  "request": {
    "method": "GET",
    "urlPath": "/api/unstable"
  },
  "response": {
    "status": 200,
    "delayDistribution": {
      "type": "lognormal",
      "median": 1000,
      "sigma": 0.25
    },
    "jsonBody": { "message": "response with jitter" }
  }
}

Fault Injection (연결 끊김):

{
  "request": {
    "method": "GET",
    "urlPath": "/api/fault"
  },
  "response": {
    "fault": "CONNECTION_RESET_BY_PEER"
  }
}

우선순위 설정:

{
  "priority": 1,
  "request": {
    "method": "GET",
    "urlPath": "/api/users/999"
  },
  "response": {
    "status": 404,
    "jsonBody": { "error": "User not found" }
  }
}

6. API 계약 테스트(Contract Testing)

6.1 Pact

Pact는 소비자 주도 계약 테스트(Consumer-Driven Contract Testing)를 지원하는 프레임워크다.

┌──────────────┐    Pact 파일 생성    ┌──────────────┐
│   Consumer   │ ──────────────────> │  Pact Broker │
│  (프론트엔드)  │                     │              │
└──────────────┘                     └──────┬───────┘
                                    Pact 파일 검증
                                    ┌──────▼───────┐
                                    │   Provider   │
                                    │   (백엔드)    │
                                    └──────────────┘

Consumer 테스트 (JavaScript):

const { PactV3 } = require('@pact-foundation/pact')

const provider = new PactV3({
  consumer: 'FrontendApp',
  provider: 'UserService',
})

describe('User API', () => {
  it('should return user by ID', async () => {
    await provider
      .given('a user with ID 1 exists')
      .uponReceiving('a request for user 1')
      .withRequest({
        method: 'GET',
        path: '/api/users/1',
        headers: { Accept: 'application/json' },
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          id: 1,
          name: 'Alice',
          email: 'alice@example.com',
        },
      })

    await provider.executeTest(async (mockServer) => {
      const response = await fetch(`${mockServer.url}/api/users/1`, {
        headers: { Accept: 'application/json' },
      })
      const user = await response.json()
      expect(user.name).toBe('Alice')
    })
  })
})

6.2 Spring Cloud Contract

Spring Cloud Contract는 Java/Spring 생태계에서 사용하는 계약 테스트 도구다.

Contract 정의 (Groovy DSL):

// contracts/shouldReturnUser.groovy
Contract.make {
    description "should return user by ID"
    request {
        method GET()
        url "/api/users/1"
        headers {
            accept(applicationJson())
        }
    }
    response {
        status OK()
        headers {
            contentType(applicationJson())
        }
        body([
            id: 1,
            name: "Alice",
            email: "alice@example.com"
        ])
    }
}

7. CI/CD 통합

7.1 Testcontainers + WireMock

import org.wiremock.integrations.testcontainers.WireMockContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
@SpringBootTest
class ExternalApiIntegrationTest {

    @Container
    static WireMockContainer wiremock = new WireMockContainer(
        "wiremock/wiremock:latest"
    )
    .withMappingFromResource("mappings/get-user.json");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("external.api.url", wiremock::getBaseUrl);
    }

    @Autowired
    private UserClient userClient;

    @Test
    void shouldFetchUserFromExternalApi() {
        User user = userClient.getUser(1L);
        assertThat(user.getName()).isEqualTo("John Doe");
    }
}

7.2 GitHub Actions에서 WireMock 사용

name: Integration Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      wiremock:
        image: wiremock/wiremock:latest
        ports:
          - 8080:8080
        volumes:
          - ./wiremock/mappings:/home/wiremock/mappings
        options: >-
          --health-cmd "curl -f http://localhost:8080/__admin/health"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 3

    steps:
      - uses: actions/checkout@v4

      - name: Run Integration Tests
        run: |
          ./gradlew integrationTest
        env:
          EXTERNAL_API_URL: http://localhost:8080

7.3 Testcontainers + MockServer

import org.mockserver.client.MockServerClient;
import org.testcontainers.containers.MockServerContainer;
import org.testcontainers.utility.DockerImageName;

@Testcontainers
class MockServerIntegrationTest {

    @Container
    static MockServerContainer mockServer = new MockServerContainer(
        DockerImageName.parse("mockserver/mockserver:latest")
    );

    @BeforeEach
    void setUp() {
        new MockServerClient(
            mockServer.getHost(),
            mockServer.getServerPort()
        )
        .when(
            request().withMethod("GET").withPath("/api/data")
        )
        .respond(
            response()
                .withStatusCode(200)
                .withBody("{\"key\": \"value\"}")
        );
    }
}

8. Best Practices

8.1 Stub 파일 관리

  • Stub 매핑 파일을 Git으로 버전 관리한다
  • 환경별로 다른 매핑 세트를 준비한다
  • 매핑 파일명에 의미 있는 이름을 사용한다 (예: get-user-by-id.json)

8.2 현실적인 Mock 데이터 만들기

// Faker.js로 현실적인 테스트 데이터 생성
const { faker } = require('@faker-js/faker')

function generateMockUsers(count) {
  return Array.from({ length: count }, (_, i) => ({
    id: i + 1,
    name: faker.person.fullName(),
    email: faker.internet.email(),
    phone: faker.phone.number(),
    address: faker.location.streetAddress(),
    createdAt: faker.date.past().toISOString(),
  }))
}

8.3 에러 시나리오 테스트

항상 다음 에러 상황에 대한 Stub을 준비해야 한다:

  • 4xx 에러: 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 429 Too Many Requests
  • 5xx 에러: 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable
  • 네트워크 에러: Connection timeout, Connection reset, 빈 응답
  • 느린 응답: 타임아웃 임계값을 초과하는 지연

8.4 Contract Testing과 Mock 서버 조합

┌─────────────────────────────────────────────────┐
│              테스트 전략 피라미드                    │
│                                                 │
│           ┌───────────────┐                     │
│           │  E2E Tests    │  ← 실제 환경          │
│          ┌┴───────────────┴┐                    │
│          │ Contract Tests  │  ← Pact/SCC        │
│         ┌┴─────────────────┴┐                   │
│         │ Integration Tests │  ← Mock Server    │
│        ┌┴───────────────────┴┐                  │
│        │   Unit Tests        │  ← Mockito 등    │
│        └─────────────────────┘                  │
└─────────────────────────────────────────────────┘

8.5 Mock 서버 선택 가이드라인

  • 프론트엔드 개발: json-server (빠른 시작, CRUD 자동 지원)
  • API-First 개발: Prism (OpenAPI 스펙 기반 자동 Mock)
  • Java/Spring 통합 테스트: WireMock (Testcontainers 통합, 강력한 매칭)
  • 동적 시나리오 테스트: MockServer (런타임 Expectation 변경)
  • 트래픽 분석/디버깅: mitmproxy (실제 트래픽 인터셉트)
  • 계약 테스트: Pact (Consumer-Driven) 또는 Spring Cloud Contract (Provider-Driven)

9. 고급 패턴: Service Virtualization

Mocking 서버를 넘어서 Service Virtualization은 실제 서비스의 복잡한 동작을 완전히 시뮬레이션하는 개념이다.

9.1 WireMock + Stateful Scenario로 워크플로 시뮬레이션

{
  "mappings": [
    {
      "scenarioName": "PaymentFlow",
      "requiredScenarioState": "Started",
      "newScenarioState": "PaymentPending",
      "request": { "method": "POST", "urlPath": "/api/payments" },
      "response": {
        "status": 201,
        "jsonBody": { "paymentId": "PAY-001", "status": "pending" }
      }
    },
    {
      "scenarioName": "PaymentFlow",
      "requiredScenarioState": "PaymentPending",
      "newScenarioState": "PaymentCompleted",
      "request": { "method": "POST", "urlPath": "/api/payments/PAY-001/confirm" },
      "response": {
        "status": 200,
        "jsonBody": { "paymentId": "PAY-001", "status": "completed" }
      }
    },
    {
      "scenarioName": "PaymentFlow",
      "requiredScenarioState": "PaymentCompleted",
      "request": { "method": "GET", "urlPath": "/api/payments/PAY-001" },
      "response": {
        "status": 200,
        "jsonBody": { "paymentId": "PAY-001", "status": "completed", "amount": 99.99 }
      }
    }
  ]
}

9.2 WireMock Admin API 활용

# 모든 매핑 조회
curl http://localhost:8080/__admin/mappings

# 요청 로그 조회
curl http://localhost:8080/__admin/requests

# 특정 패턴의 요청 카운트 확인
curl -X POST http://localhost:8080/__admin/requests/count \
  -H "Content-Type: application/json" \
  -d '{"method": "GET", "url": "/api/users"}'

# 시나리오 상태 초기화
curl -X POST http://localhost:8080/__admin/scenarios/reset

# 모든 매핑 초기화
curl -X POST http://localhost:8080/__admin/mappings/reset

10. 결론

Mocking 서버는 현대 소프트웨어 개발에서 필수적인 도구다. 올바른 도구를 선택하고 적절히 활용하면 개발 속도를 높이고, 테스트 신뢰성을 확보하며, CI/CD 파이프라인을 안정화할 수 있다.

핵심 정리:

  • Stub vs Mock의 차이를 이해하고 상황에 맞게 사용한다
  • WireMock은 가장 범용적이고 강력한 선택지다
  • json-server는 프론트엔드 개발에 빠르게 적용할 수 있다
  • Prism은 API-First 워크플로에 최적이다
  • Contract Testing과 Mock 서버를 조합하면 마이크로서비스 테스트 전략이 완성된다
  • CI/CD에서는 Testcontainers를 활용해 Mock 서버를 자동으로 관리한다

[DevOps] Complete Guide to Stub and Mocking Servers: From Concepts to Practice


1. What Are Test Doubles

In software testing, a Test Double is a generic term for any object that replaces a real dependency component. According to Martin Fowler's classification, Test Doubles fall into five categories.

1.1 Dummy

An object passed to fill parameters but never actually used. It exists solely to satisfy method signatures.

// Dummy example: a logger that's never called
public class DummyLogger implements Logger {
    @Override
    public void log(String message) {
        // does nothing
    }
}

1.2 Stub

A Stub is a Test Double that returns pre-determined responses. It returns fixed data for calls and is used for State Verification.

// Stub example: always returns a fixed user
public class StubUserRepository implements UserRepository {
    @Override
    public User findById(Long id) {
        return new User(1L, "testuser", "test@example.com");
    }
}

Key characteristics:

  • Returns only pre-programmed responses
  • Does not verify call count or order
  • Verification is done by checking the result state after the test

1.3 Mock

A Mock is a Test Double that verifies the calls themselves. It focuses on Behavior Verification -- whether a specific method was called with specific arguments and how many times.

// Mock example using Mockito
@Test
void shouldSendWelcomeEmail() {
    EmailService mockEmailService = mock(EmailService.class);
    UserService userService = new UserService(mockEmailService);

    userService.registerUser("test@example.com");

    // Behavior verification: check sendWelcomeEmail called exactly once
    verify(mockEmailService, times(1))
        .sendWelcomeEmail("test@example.com");
}

Key characteristics:

  • Set expectations for expected calls in advance
  • Verify call count, order, and arguments
  • Test fails if calls don't match expectations

1.4 Fake

A Fake is a lightweight implementation that actually works. Not used in production, but functional enough for testing purposes.

// Fake example: In-Memory database
public class FakeUserRepository implements UserRepository {
    private final Map<Long, User> store = new HashMap<>();
    private long sequence = 1L;

    @Override
    public User save(User user) {
        user.setId(sequence++);
        store.put(user.getId(), user);
        return user;
    }

    @Override
    public User findById(Long id) {
        return store.get(id);
    }

    @Override
    public List<User> findAll() {
        return new ArrayList<>(store.values());
    }
}

Representative Fake examples:

  • In-memory databases like H2
  • Local filesystem-based storage
  • In-memory message queues

1.5 Spy

A Spy wraps a real object and records call information. It performs actual operations while simultaneously logging call details.

// Mockito Spy example
@Test
void spyExample() {
    List<String> realList = new ArrayList<>();
    List<String> spyList = spy(realList);

    spyList.add("hello");  // element is actually added

    verify(spyList).add("hello");  // verify call record
    assertEquals(1, spyList.size());  // verify actual behavior
}

1.6 Comparison Summary

TypeBehaviorVerificationExample
DummyNone (parameter filler)NoneEmpty implementation
StubReturns fixed responseState verificationHardcoded response
MockSets call expectationsBehavior verificationMockito mock
FakeReal lightweight implState verificationIn-memory DB
SpyReal behavior + recordingBehavior + StateMockito spy

2. Why You Need Mocking Servers

When testing services that depend on external APIs in a microservices architecture, the following problems arise:

  • External service instability: If the external API goes down, your tests fail too
  • Rate limits: External API call limits prevent test repetition
  • Cost: Calling paid APIs for every test generates costs
  • Speed: Network latency increases test time
  • Edge case reproduction: Difficult to reproduce specific error responses, timeouts, etc.
  • Development environment independence: Must be able to develop without external services

Mocking servers solve all these problems by providing the same interface as the actual external service while returning predictable responses.

┌──────────────┐      ┌──────────────┐      ┌───────────────┐
│  Test Code   │─────>│  My Service  │─────>│ Mocking Server │
│              │      │              │      │ (WireMock etc) │
└──────────────┘      └──────────────┘      └───────────────┘
                                           Returns pre-defined responses

3. Major Mocking Server Tools

3.1 WireMock

WireMock is the most powerful and feature-rich open-source Mock server. While Java-based, it can also run as a standalone server.

Key features:

  • JSON-based stub mapping files
  • Powerful Request Matching (URL, headers, body, query parameters)
  • Response Templating (Handlebars-based)
  • Record/Playback mode (records actual API traffic)
  • Stateful Behavior (scenario-based state transitions)
  • Fault Injection (delay, connection drop simulation)
  • Docker image available

Running WireMock with Docker:

docker run -d --name wiremock \
  -p 8080:8080 \
  -v $(pwd)/stubs:/home/wiremock \
  wiremock/wiremock:latest \
  --global-response-templating \
  --verbose

Basic Stub Mapping File Structure:

stubs/mappings/get-user.json:

{
  "request": {
    "method": "GET",
    "urlPathPattern": "/api/users/[0-9]+",
    "headers": {
      "Accept": {
        "contains": "application/json"
      }
    }
  },
  "response": {
    "status": 200,
    "headers": {
      "Content-Type": "application/json"
    },
    "jsonBody": {
      "id": 1,
      "name": "John Doe",
      "email": "john@example.com"
    }
  }
}

Request Matching Patterns:

{
  "request": {
    "method": "POST",
    "urlPath": "/api/orders",
    "bodyPatterns": [
      {
        "matchesJsonPath": "$.items[?(@.quantity > 0)]"
      },
      {
        "matchesJsonPath": {
          "expression": "$.customerId",
          "regex": "^[A-Z]{2}[0-9]{6}$"
        }
      }
    ],
    "queryParameters": {
      "status": {
        "equalTo": "active"
      }
    }
  },
  "response": {
    "status": 201,
    "jsonBody": {
      "orderId": "ORD-001",
      "status": "created"
    }
  }
}

Response Templating Example:

{
  "request": {
    "method": "GET",
    "urlPathTemplate": "/api/users/{userId}"
  },
  "response": {
    "status": 200,
    "headers": {
      "Content-Type": "application/json"
    },
    "jsonBody": {
      "id": "{{request.pathSegments.[2]}}",
      "requestedAt": "{{now}}",
      "userAgent": "{{request.headers.User-Agent}}"
    },
    "transformers": ["response-template"]
  }
}

Stateful Behavior (Scenarios):

{
  "mappings": [
    {
      "scenarioName": "OrderFlow",
      "requiredScenarioState": "Started",
      "newScenarioState": "OrderCreated",
      "request": {
        "method": "POST",
        "urlPath": "/api/orders"
      },
      "response": {
        "status": 201,
        "jsonBody": { "status": "created" }
      }
    },
    {
      "scenarioName": "OrderFlow",
      "requiredScenarioState": "OrderCreated",
      "request": {
        "method": "GET",
        "urlPath": "/api/orders/1"
      },
      "response": {
        "status": 200,
        "jsonBody": { "status": "processing" }
      }
    }
  ]
}

Record/Playback Mode:

# Proxy to real API and record responses
docker run -d --name wiremock \
  -p 8080:8080 \
  -v $(pwd)/stubs:/home/wiremock \
  wiremock/wiremock:latest \
  --proxy-all="https://api.example.com" \
  --record-mappings

3.2 MockServer

MockServer is a Java-based mock server that offers differentiated features from WireMock.

Key features:

  • Runtime dynamic Expectation configuration (add/modify stubs via API calls)
  • Forward Proxy mode support
  • Request/Response verification API
  • OpenAPI spec-based auto mock

Docker execution:

docker run -d --name mockserver \
  -p 1080:1080 \
  mockserver/mockserver:latest

Setting Expectations (REST API):

curl -X PUT "http://localhost:1080/mockserver/expectation" \
  -H "Content-Type: application/json" \
  -d '{
    "httpRequest": {
      "method": "GET",
      "path": "/api/products",
      "queryStringParameters": {
        "category": ["electronics"]
      }
    },
    "httpResponse": {
      "statusCode": 200,
      "headers": {
        "Content-Type": ["application/json"]
      },
      "body": {
        "type": "JSON",
        "json": "[{\"id\": 1, \"name\": \"Laptop\", \"price\": 999.99}]"
      }
    },
    "times": {
      "unlimited": true
    }
  }'

Setting Expectations with Java Client:

import org.mockserver.client.MockServerClient;
import static org.mockserver.model.HttpRequest.request;
import static org.mockserver.model.HttpResponse.response;

new MockServerClient("localhost", 1080)
    .when(
        request()
            .withMethod("GET")
            .withPath("/api/products")
            .withQueryStringParameter("category", "electronics")
    )
    .respond(
        response()
            .withStatusCode(200)
            .withHeader("Content-Type", "application/json")
            .withBody("[{\"id\": 1, \"name\": \"Laptop\"}]")
    );

3.3 json-server

json-server is a lightweight Node.js-based REST API server. It provides an instant REST API with just a single JSON file.

Installation and execution:

npm install -g json-server

# Create db.json file
cat > db.json << 'JSONEOF'
{
  "users": [
    { "id": 1, "name": "Alice", "email": "alice@example.com" },
    { "id": 2, "name": "Bob", "email": "bob@example.com" }
  ],
  "posts": [
    { "id": 1, "title": "Hello World", "userId": 1 },
    { "id": 2, "title": "Testing Guide", "userId": 2 }
  ]
}
JSONEOF

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

Auto-generated endpoints:

# Full CRUD support
GET    /users          # List all
GET    /users/1        # Get by ID
POST   /users          # Create
PUT    /users/1        # Full update
PATCH  /users/1        # Partial update
DELETE /users/1        # Delete

# Query features
GET /users?name=Alice              # Filtering
GET /users?_sort=name&_order=asc   # Sorting
GET /users?_page=1&_limit=10       # Pagination
GET /posts?_expand=user            # Relation expansion

Custom routes and middleware:

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

// Custom middleware: check auth header
server.use((req, res, next) => {
  if (req.headers.authorization === 'Bearer test-token') {
    next()
  } else {
    res.status(401).json({ error: 'Unauthorized' })
  }
})

server.use(middlewares)

// Custom route
server.get('/api/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() })
})

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

3.4 Prism (Stoplight)

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

Installation and execution:

npm install -g @stoplight/prism-cli

# Run mock server from OpenAPI spec
prism mock openapi.yaml --port 4010

# Dynamic response generation mode
prism mock openapi.yaml --dynamic --port 4010

# Validation Proxy mode (validates requests/responses in front of real server)
prism proxy openapi.yaml https://api.example.com --port 4010

OpenAPI spec example:

openapi: 3.0.3
info:
  title: User API
  version: 1.0.0
paths:
  /users:
    get:
      summary: List users
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                      example: 1
                    name:
                      type: string
                      example: 'Alice'
                    email:
                      type: string
                      format: email
                      example: 'alice@example.com'
              examples:
                default:
                  value:
                    - id: 1
                      name: 'Alice'
                      email: 'alice@example.com'
        '401':
          description: Unauthorized

Prism prioritizes example values from the spec, and in --dynamic mode generates random data based on the schema.

3.5 mitmproxy

mitmproxy is an HTTP/HTTPS proxy that can intercept and modify actual traffic.

# Install
pip install mitmproxy

# Start intercept proxy
mitmproxy --listen-port 8888

# Script-based response modification
mitmproxy -s modify_response.py
# modify_response.py
import json
from mitmproxy import http

def response(flow: http.HTTPFlow) -> None:
    if "/api/users" in flow.request.pretty_url:
        flow.response.status_code = 200
        flow.response.set_text(json.dumps([
            {"id": 1, "name": "Mocked User"}
        ]))
        flow.response.headers["Content-Type"] = "application/json"

4. Tool Comparison Table

FeatureWireMockMockServerjson-serverPrismmitmproxy
LanguageJavaJavaNode.jsNode.jsPython
Config MethodJSON files/APIAPIJSON fileOpenAPI specPython script
Request MatchingVery powerfulVery powerfulBasicSpec-basedScript-based
Response TemplatingHandlebarsVelocity/MustacheLimitedSpec-basedFlexible
Record/PlaybackYesYesNoNoYes
Stateful BehaviorYes (scenarios)YesYes (CRUD)NoYes (script)
OpenAPI IntegrationPluginYesNoNativeNo
Docker SupportYesYesYesYesYes
Fault InjectionYesYesNoNoYes
HTTPS SupportYesYesNoYesYes
Learning CurveMediumMediumVery LowLowMedium
Best ForGeneral integration testingDynamic API testingFrontend developmentAPI-First developmentDebugging/analysis

5. WireMock in Practice: Docker Compose Environment

5.1 Project Structure

project/
  docker-compose.yaml
  wiremock/
    mappings/
      get-users.json
      post-order.json
      health-check.json
    __files/
      user-list.json
      error-response.json

5.2 Docker Compose Configuration

version: '3.8'
services:
  wiremock:
    image: wiremock/wiremock:latest
    ports:
      - '8080:8080'
    volumes:
      - ./wiremock/mappings:/home/wiremock/mappings
      - ./wiremock/__files:/home/wiremock/__files
    command:
      - '--global-response-templating'
      - '--verbose'
      - '--disable-gzip'
    healthcheck:
      test: ['CMD', 'curl', '-f', 'http://localhost:8080/__admin/health']
      interval: 10s
      timeout: 5s
      retries: 3

  my-service:
    build: .
    environment:
      - EXTERNAL_API_URL=http://wiremock:8080
    depends_on:
      wiremock:
        condition: service_healthy

5.3 Various Mapping Examples

Delayed response simulation:

{
  "request": {
    "method": "GET",
    "urlPath": "/api/slow-endpoint"
  },
  "response": {
    "status": 200,
    "fixedDelayMilliseconds": 3000,
    "jsonBody": { "message": "delayed response" }
  }
}

Random delay (jitter):

{
  "request": {
    "method": "GET",
    "urlPath": "/api/unstable"
  },
  "response": {
    "status": 200,
    "delayDistribution": {
      "type": "lognormal",
      "median": 1000,
      "sigma": 0.25
    },
    "jsonBody": { "message": "response with jitter" }
  }
}

Fault Injection (connection reset):

{
  "request": {
    "method": "GET",
    "urlPath": "/api/fault"
  },
  "response": {
    "fault": "CONNECTION_RESET_BY_PEER"
  }
}

Priority setting:

{
  "priority": 1,
  "request": {
    "method": "GET",
    "urlPath": "/api/users/999"
  },
  "response": {
    "status": 404,
    "jsonBody": { "error": "User not found" }
  }
}

6. API Contract Testing

6.1 Pact

Pact is a framework that supports Consumer-Driven Contract Testing.

┌──────────────┐    Generates Pact file    ┌──────────────┐
│   Consumer   │ ────────────────────────> │  Pact Broker │
│  (Frontend)  │                           │              │
└──────────────┘                           └──────┬───────┘
                                           Verifies Pact file
                                           ┌──────▼───────┐
                                           │   Provider   │
                                           │   (Backend)  │
                                           └──────────────┘

Consumer Test (JavaScript):

const { PactV3 } = require('@pact-foundation/pact')

const provider = new PactV3({
  consumer: 'FrontendApp',
  provider: 'UserService',
})

describe('User API', () => {
  it('should return user by ID', async () => {
    await provider
      .given('a user with ID 1 exists')
      .uponReceiving('a request for user 1')
      .withRequest({
        method: 'GET',
        path: '/api/users/1',
        headers: { Accept: 'application/json' },
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          id: 1,
          name: 'Alice',
          email: 'alice@example.com',
        },
      })

    await provider.executeTest(async (mockServer) => {
      const response = await fetch(`${mockServer.url}/api/users/1`, {
        headers: { Accept: 'application/json' },
      })
      const user = await response.json()
      expect(user.name).toBe('Alice')
    })
  })
})

6.2 Spring Cloud Contract

Spring Cloud Contract is a contract testing tool for the Java/Spring ecosystem.

Contract definition (Groovy DSL):

// contracts/shouldReturnUser.groovy
Contract.make {
    description "should return user by ID"
    request {
        method GET()
        url "/api/users/1"
        headers {
            accept(applicationJson())
        }
    }
    response {
        status OK()
        headers {
            contentType(applicationJson())
        }
        body([
            id: 1,
            name: "Alice",
            email: "alice@example.com"
        ])
    }
}

7. CI/CD Integration

7.1 Testcontainers + WireMock

import org.wiremock.integrations.testcontainers.WireMockContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
@SpringBootTest
class ExternalApiIntegrationTest {

    @Container
    static WireMockContainer wiremock = new WireMockContainer(
        "wiremock/wiremock:latest"
    )
    .withMappingFromResource("mappings/get-user.json");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("external.api.url", wiremock::getBaseUrl);
    }

    @Autowired
    private UserClient userClient;

    @Test
    void shouldFetchUserFromExternalApi() {
        User user = userClient.getUser(1L);
        assertThat(user.getName()).isEqualTo("John Doe");
    }
}

7.2 WireMock in GitHub Actions

name: Integration Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      wiremock:
        image: wiremock/wiremock:latest
        ports:
          - 8080:8080
        options: >-
          --health-cmd "curl -f http://localhost:8080/__admin/health"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 3

    steps:
      - uses: actions/checkout@v4

      - name: Run Integration Tests
        run: |
          ./gradlew integrationTest
        env:
          EXTERNAL_API_URL: http://localhost:8080

7.3 Testcontainers + MockServer

import org.mockserver.client.MockServerClient;
import org.testcontainers.containers.MockServerContainer;
import org.testcontainers.utility.DockerImageName;

@Testcontainers
class MockServerIntegrationTest {

    @Container
    static MockServerContainer mockServer = new MockServerContainer(
        DockerImageName.parse("mockserver/mockserver:latest")
    );

    @BeforeEach
    void setUp() {
        new MockServerClient(
            mockServer.getHost(),
            mockServer.getServerPort()
        )
        .when(
            request().withMethod("GET").withPath("/api/data")
        )
        .respond(
            response()
                .withStatusCode(200)
                .withBody("{\"key\": \"value\"}")
        );
    }
}

8. Best Practices

8.1 Stub File Management

  • Version control stub mapping files with Git
  • Prepare different mapping sets for different environments
  • Use meaningful names for mapping files (e.g., get-user-by-id.json)

8.2 Creating Realistic Mock Data

// Generate realistic test data with Faker.js
const { faker } = require('@faker-js/faker')

function generateMockUsers(count) {
  return Array.from({ length: count }, (_, i) => ({
    id: i + 1,
    name: faker.person.fullName(),
    email: faker.internet.email(),
    phone: faker.phone.number(),
    address: faker.location.streetAddress(),
    createdAt: faker.date.past().toISOString(),
  }))
}

8.3 Testing Error Scenarios

Always prepare stubs for the following error situations:

  • 4xx errors: 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 429 Too Many Requests
  • 5xx errors: 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable
  • Network errors: Connection timeout, Connection reset, Empty response
  • Slow responses: Delays exceeding timeout thresholds

8.4 Combining Contract Testing and Mock Servers

┌─────────────────────────────────────────────────┐
│           Test Strategy Pyramid                  │
│                                                 │
│           ┌───────────────┐                     │
│           │  E2E Tests    │  <- Real environment │
│          ┌┴───────────────┴┐                    │
│          │ Contract Tests  │  <- Pact/SCC        │
│         ┌┴─────────────────┴┐                   │
│         │ Integration Tests │  <- Mock Server    │
│        ┌┴───────────────────┴┐                  │
│        │   Unit Tests        │  <- Mockito etc   │
│        └─────────────────────┘                  │
└─────────────────────────────────────────────────┘

8.5 Mock Server Selection Guidelines

  • Frontend development: json-server (quick start, auto CRUD support)
  • API-First development: Prism (auto mock based on OpenAPI spec)
  • Java/Spring integration testing: WireMock (Testcontainers integration, powerful matching)
  • Dynamic scenario testing: MockServer (runtime Expectation changes)
  • Traffic analysis/debugging: mitmproxy (real traffic interception)
  • Contract testing: Pact (Consumer-Driven) or Spring Cloud Contract (Provider-Driven)

9. Advanced Pattern: Service Virtualization

Beyond mocking servers, Service Virtualization is the concept of fully simulating the complex behavior of real services.

9.1 Simulating Workflows with WireMock + Stateful Scenarios

{
  "mappings": [
    {
      "scenarioName": "PaymentFlow",
      "requiredScenarioState": "Started",
      "newScenarioState": "PaymentPending",
      "request": { "method": "POST", "urlPath": "/api/payments" },
      "response": {
        "status": 201,
        "jsonBody": { "paymentId": "PAY-001", "status": "pending" }
      }
    },
    {
      "scenarioName": "PaymentFlow",
      "requiredScenarioState": "PaymentPending",
      "newScenarioState": "PaymentCompleted",
      "request": { "method": "POST", "urlPath": "/api/payments/PAY-001/confirm" },
      "response": {
        "status": 200,
        "jsonBody": { "paymentId": "PAY-001", "status": "completed" }
      }
    },
    {
      "scenarioName": "PaymentFlow",
      "requiredScenarioState": "PaymentCompleted",
      "request": { "method": "GET", "urlPath": "/api/payments/PAY-001" },
      "response": {
        "status": 200,
        "jsonBody": { "paymentId": "PAY-001", "status": "completed", "amount": 99.99 }
      }
    }
  ]
}

9.2 Leveraging the WireMock Admin API

# List all mappings
curl http://localhost:8080/__admin/mappings

# View request logs
curl http://localhost:8080/__admin/requests

# Check request count for specific pattern
curl -X POST http://localhost:8080/__admin/requests/count \
  -H "Content-Type: application/json" \
  -d '{"method": "GET", "url": "/api/users"}'

# Reset scenario states
curl -X POST http://localhost:8080/__admin/scenarios/reset

# Reset all mappings
curl -X POST http://localhost:8080/__admin/mappings/reset

10. Conclusion

Mocking servers are essential tools in modern software development. Choosing the right tool and using it properly can speed up development, ensure test reliability, and stabilize CI/CD pipelines.

Key takeaways:

  • Understand the difference between Stub vs Mock and use them appropriately
  • WireMock is the most versatile and powerful choice
  • json-server can be quickly applied to frontend development
  • Prism is optimal for API-First workflows
  • Combining Contract Testing with mock servers completes your microservices testing strategy
  • Use Testcontainers in CI/CD to automatically manage mock servers