---
title: "Модуль Mockserver: подмена внешних API"
description: "Модуль testcontainers mockserver: mockserver.Run и URL, ожидание через PUT /mockserver/expectation, верификация вызовов, тест «сервис пережил 500 от платёжки»."
course: "Testcontainers в Go: интеграционные тесты с Docker"
url: "https://golangmaster.ru/courses/testcontainers/lessons/10-mockserver-module/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Модуль Mockserver: подмена внешних API

*Курс «Testcontainers в Go: интеграционные тесты с Docker», урок 10 из 25. ~16 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/testcontainers/lessons/10-mockserver-module/*

Мок внешнего api go — задача, которая всплывает, как только сервис начинает ходить наружу: платёжный шлюз, смс-провайдер, карта. Дёргать настоящий биллинг из теста нельзя: деньги настоящие, лимиты чужие, а ответы зависят от инфраструктуры, которую вы не контролируете. Модуль testcontainers mockserver решает это честно: он поднимает в Docker-контейнере настоящий HTTP-сервер MockServer, отдаёт готовый URL и позволяет назначить на любой запрос нужный ответ — от 200 с JSON до 500 с задержкой. В уроке соберём такой тест с нуля: запуск mockserver.Run, регистрация ожидания через PUT на /mockserver/expectation, ответ клиенту, верификация вызовов и тест «сервис пережил 500 от платёжки», плюс таблица выбора между моком и httptest.

Сервисы не живут в изоляции: платёжный шлюз, смс-провайдер, карта, почта — почти каждый продакшен-код ходит наружу, и интеграционный тест обязан проверить, как код переживает ответы этого наружного мира. Реальный биллинг из тестов дёргать нельзя — деньги настоящие, лимиты чужие, а поведение зависит от чужой инфраструктуры, на которую вы не влияете: сегодня шлюз отвечает за секунду, завтра завис на реконсиляции, и ваш тест красный без всякой вины. Заглушить зависимость интерфейсом можно, но тогда тест не увидит ни заголовков, ни статусов, ни реального HTTP-стека — а именно там чаще всего и живут баги. Между этими полюсами лежит честный вариант: настоящий HTTP-сервер, поднятый в Docker-контейнере рядом с тестом, который отвечает ровно так, как вы ему сказали, и помнит каждый пришедший запрос. Модуль testcontainers mockserver — это и есть такой сервер: MockServer в контейнере, готовый к работе через пару секунд, без биллинга, без песочниц провайдера и без ночных сюрпризов. Локально и в CI он ведёт себя одинаково, потому что это один и тот же образ. Стартует он за секунды, и у каждого теста пакета может быть свой мок — параллельные прогоны не дерутся за один общий сервер.

### Мок внешнего api go: где httptest не справляется

Стандартный httptest.Server — первый кандидат на роль мока, и для простых случаев он хорош: поднять сервер в процессе теста, навесить пару хендлеров, проверить ответ. Но как только сценариев становится больше трёх, ручной учёт начинает съедать тест. Во-первых, матчинг: метод, путь, тело, заголовки — всё сравниваете руками, и каждая новая ветка ответов — новый if. Во-вторых, верификация: сколько раз дёрнули эндпоинт, с каким телом, был ли второй вызов — считаете сами на счётчике. В-третьих, состояние: ожидания одного теста подтекают в другой, если забыли почистить. MockServer закрывает всё это из коробки: правила задаются JSON-ом через REST, вызовы записываются и проверяются готовым эндпоинтом, состояние сбрасывается одной командой. А модуль testcontainers mockserver добавляет то, чего httptest не умеет в принципе: мок — отдельный процесс со своим HTTP-стеком, а не горутина внутри теста. Там, где мок внешнего api go нужен на каждый тест пакета, эта разница превращается в часы.

### Каркас: mockserver.Run и URL

```go
import (
    "context"
    "testing"

    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/mockserver"
)

func startMockserver(t *testing.T) string {
    t.Helper()

    ctx := context.Background()
    ms, err := mockserver.Run(ctx, "mockserver/mockserver:5.15.0")
    if err != nil {
        t.Fatalf("mockserver не поднялся: %v", err)
    }
    t.Cleanup(func() {
        if err := testcontainers.TerminateContainer(ms); err != nil {
            t.Logf("terminate: %v", err)
        }
    })

    url, err := ms.URL(ctx)
    if err != nil {
        t.Fatalf("url: %v", err)
    }
    return url
}
```

startMockserver — хелпер с t.Helper: каждый тест получает свежий мок, а уборка через t.Cleanup и TerminateContainer сработает даже при панике. mockserver.Run возвращает типизированный MockServerContainer; wait-стратегию модуль ставит сам, поэтому после Run мок уже принимает запросы. URL отдаёт адрес с маппированным портом — его и передаём клиенту, который подменяем.

### mockserver.Run: что модуль делает за вас

Вход в модуль — mockserver.Run(ctx, образ, opts ...testcontainers.ContainerCustomizer): тот же принцип, что у postgres и redis, — образ первым аргументом, дальше опции. Сам модуль минимальный, и это его сила: за вас он делает ровно две вещи. Первая — открытый порт 1080/tcp: MockServer слушает управление и трафик на одном порту, отдельно настраивать ничего не нужно. Вторая — wait-стратегия из двух условий: в логах должна появиться строка started on port: 1080, а порт 1080/tcp должен начать принимать соединения, причём проверка порта идёт с SkipInternalCheck, без лишнего exec внутрь контейнера. Когда Run вернулся, мок готов принимать и ожидания, и трафик. Дальше остаётся узнать адрес: метод URL(ctx) вызывает PortEndpoint по 1080/tcp со схемой http и возвращает готовый адрес на хосте с маппированным портом. Старый вход RunContainer без аргумента образа помечен Deprecated — новые тесты пишите через Run: сигнатура одинаковая во всех модулях курса, и мозг не переключается. Свои опции модуль принимает те же, что и ядро: переменные окружения, лимиты ресурсов, имя контейнера — они применяются поверх дефолтов, а не вместо них.

> **Живое место: 1080 остаётся в контейнере.** Порт 1080 — это порт внутри контейнера, и именно его ждёт wait-стратегия. Но клиент в тесте должен ходить не туда: URL(ctx) отдаёт маппированный порт хоста, и он меняется от запуска к запуску. Однажды кто-то захардкодит адрес с 1080 — на его машине всё заработает, а в CI упадёт с отказом в соединении, потому что там на этом порту никто не слушает. Правило одно: адрес берите только из URL и передавайте дальше в клиент. Проверяется за минуту: выведите URL в лог теста и запустите дважды — порты будут разными. Спорное упрощение: собирать адрес самому через MappedPort — работает, но это те же две строки, что внутри модуля, только теперь их поддерживаете вы.

### Expectation: PUT на /mockserver/expectation

Ожидание (expectation) — правило вида «на такой запрос отвечай таким ответом». Регистрируется оно не вызовом Go-метода, а обычным HTTP-запросом к самому моку: PUT на /mockserver/expectation с JSON-телом из двух половин. httpRequest описывает, что ловить: метод, путь, тело, заголовки — матчинг по умолчанию точный, лишний пробел в теле уже не совпадение. httpResponse описывает, что отдать: statusCode, тело, заголовки; можно добавить delay — и мок начнёт отвечать с задержкой, чтобы проверить таймаут клиента. Тело ответа годится любое — JSON, XML или просто строка: мок отдаст его с тем Content-Type, который вы задали в заголовках httpResponse. Пока ожидание не зарегистрировано, на любой запрос мок отвечает 404 — это не баг, а честное поведение по умолчанию: мок делает ровно то, что ему сказали, и ничего больше. Правил можно зарегистрировать сколько нужно; полем times ограничивают число срабатываний, а двумя правилами на один путь собирают сценарий «первый вызов — ошибка, второй — успех». Для уборки у мока есть PUT /mockserver/clear, стирающий ожидания и историю, и PUT /mockserver/reset, возвращающий исходное состояние.

### Регистрируем ожидание и дёргаем мок

```go
func TestPaymentClient_Charge(t *testing.T) {
    url := startMockserver(t)

    expectation := map[string]any{
        "httpRequest": map[string]any{
            "method": http.MethodPost,
            "path":   "/api/payments",
        },
        "httpResponse": map[string]any{
            "statusCode": 200,
            "body":       map[string]any{"status": "paid", "id": "pay_1"},
        },
    }
    payload, err := json.Marshal(expectation)
    if err != nil {
        t.Fatalf("marshal: %v", err)
    }

    put, err := http.NewRequest(http.MethodPut, url+"/mockserver/expectation", bytes.NewReader(payload))
    if err != nil {
        t.Fatalf("new request: %v", err)
    }
    put.Header.Set("Content-Type", "application/json")
    resp, err := http.DefaultClient.Do(put)
    if err != nil || resp.StatusCode/100 != 2 {
        t.Fatalf("expectation не зарегистрирован: %v", err)
    }
    resp.Body.Close()

    out, err := http.Post(url+"/api/payments", "application/json",
        strings.NewReader(`{"order": "42"}`))
    if err != nil {
        t.Fatalf("запрос к моку: %v", err)
    }
    defer out.Body.Close()

    if out.StatusCode != http.StatusOK {
        t.Fatalf("код: %d", out.StatusCode)
    }
}
```

Ожидание собирается картами и уходит в json.Marshal: httpRequest ловит POST /api/payments, httpResponse отвечает 200 с JSON-телом. Регистрация — обычный PUT на /mockserver/expectation с Content-Type: application/json; не-2xx статус валит тест сразу, а не на 404 клиента. Потом мок дёргают как настоящий API: тот же URL, тот же http.Post — клиент даже не знает, что ходит в контейнер.

### Проверка: что вернёт URL

**Вопрос:** mockserver.Run отработал без ошибки. Что вернёт ms.URL(ctx)?

- http://localhost:1080 — порт, который MockServer слушает внутри контейнера
- ✅ Адрес на хосте с маппированным портом: PortEndpoint по 1080/tcp со схемой http
- Пустую строку: у MockServerContainer нет метода URL
- Внутренний адрес контейнера в Docker-сети

URL вызывает PortEndpoint по порту 1080/tcp со схемой http: Docker маппирует порт контейнера на свободный порт хоста, и он меняется от запуска к запуску. Ждать готовности отдельно не нужно — Run возвращается, когда отработали обе стратегии: лог started on port: 1080 и слушающий порт.

### Верификация: мок помнит каждый запрос

Вторая половина ценности MockServer — память. Все входящие запросы записываются, и их можно проверить: PUT на /mockserver/verify с JSON-телом — httpRequest, который ищем, плюс times с atLeast и atMost. Сошлось — мок отвечает 202 Accepted; не сошлось — 406 Not Acceptable с текстом, что именно разошлось: запросов нет, их больше, чем ожидалось, тело не то. Это закрывает главный вопрос интеграционного теста с внешней зависимостью: не «клиент что-то отправил», а «клиент отправил ровно один запрос ровно с таким телом». Ретрай, который вы забыли отключить, всплывёт в verify с atMost — и это фича, а не шум. Если шаблона verify мало, запросы достаются целиком: PUT на /mockserver/retrieve возвращает JSON со всеми записанными вызовами, и дальше их парсит ваш тест. Правило гигиены то же, что у базы: чистите состояние между тестами — clear или reset в t.Cleanup, иначе вчерашние вызовы перепроверятся в сегодняшнем прогоне.

### Тест: сервис пережил 500 от платёжки

```go
func TestService_SurvivesBilling500(t *testing.T) {
    url := startMockserver(t)

    // платёжка отвечает 500 — хелпер из прошлого блока, statusCode стал параметром
    registerExpectation(t, url, http.MethodPost, "/api/payments", http.StatusInternalServerError)

    service := NewPaymentsClient(url)
    if err := service.Charge("order-42"); err == nil {
        t.Fatal("ожидали ошибку от платёжки, а сервис промолчал")
    }
    // сервис вернул понятную ошибку и не упал — это и проверяет тест

    verify := map[string]any{
        "httpRequest": map[string]any{
            "method": http.MethodPost,
            "path":   "/api/payments",
        },
        "times": map[string]any{"atLeast": 1, "atMost": 1},
    }
    payload, _ := json.Marshal(verify)
    put, _ := http.NewRequest(http.MethodPut, url+"/mockserver/verify", bytes.NewReader(payload))
    put.Header.Set("Content-Type", "application/json")

    resp, err := http.DefaultClient.Do(put)
    if err != nil {
        t.Fatalf("verify: %v", err)
    }
    resp.Body.Close()
    if resp.StatusCode != http.StatusAccepted {
        t.Fatalf("verify не сошёлся: %d", resp.StatusCode)
    }
}
```

Сценарий сбоя: ожидание отвечает 500 на POST /api/payments, и сервис обязан вернуть ошибку, а не паниковать и не молчать. Корректность трафика проверяют фактом: PUT на /mockserver/verify с times atLeast и atMost — запрос был ровно один, ретраев нет. Успех — 202 Accepted; 406 с описанием расхождения провалит тест и покажет, где поведение разошлось с планом.

### Что увидит клиент

```go
// ожидание: statusCode 500 на POST /api/payments
// регистрация прошла, мок готов

out, err := http.Post(url+"/api/payments", "application/json",
    strings.NewReader(`{"order": "42"}`))
// err == nil

fmt.Println(out.StatusCode)
```

**Что выведет программа?**

- ✅ 500 — мок вернёт ровно то, что настроено в httpResponse
- 200 — MockServer смягчает настроенные ошибки до успешных ответов
- 502 — мок сигнализирует, что ответ настроен как ошибка
- Соединение будет разорвано: 500 мок отдаёт обрывом

MockServer делает ровно то, что сказано в ожидании: вернёт 500 с настроенным телом. В этом смысл теста на сбой — проверяется не сам факт ошибки, а реакция вашего кода: ретрай, фолбэк, понятная ошибка пользователю. Настоящая платёжка ничего смягчать не будет, и мок не должен.

### Проверка: запрос без ожидания

**Вопрос:** Тест отправил POST /api/payments раньше, чем зарегистрировал ожидание. Что вернёт мок?

- 200 с пустым телом: без правил MockServer отвечает успехом
- ✅ 404: подходящее ожидание не найдено — мок делает только то, что ему сказали
- 500: незарегистрированный запрос — внутренняя ошибка мока
- Запрос зависнет до регистрации ожидания

Без совпавшего ожидания MockServer отвечает 404 с сообщением, что правило не найдено. Отсюда порядок любого теста: сначала expectations, потом трафик — и clear или reset в cleanup, чтобы чужие правила не пережили свой тест.

### Подход → когда применять

| Подход | Когда применять | Слабое место |
| --- | --- | --- |
| httptest.Server | мок в процессе теста: пара маршрутов, мгновенный старт, Docker не нужен | матчинг и учёт вызовов пишете руками; состояние между тестами — тоже |
| MockServer в контейнере | внешний API с матчингом запросов, верификацией вызовов и сбросом состояния через REST | нужен Docker и пара секунд на старт контейнера |
| Стаб интерфейса | юнит-тесты логики, где HTTP не участвует вовсе | сетевой слой не проверяется: заголовки, статусы и таймауты мимо |

### Типовые ошибки: чего мок не прощает

Первая ошибка — забытый Content-Type: application/json на PUT /mockserver/expectation. Мок не разберёт тело, ожидание не зарегистрируется, а тест через минуту падает на 404 — связь между этими событиями ищется мучительно. Вторая — порядок: ожидание после первого запроса бесполезно, клиент уже получил 404; сначала правила, потом трафик. Третья — хардкод порта вместо URL(ctx): на локальной машине с одним прогоном это работает ровно до первого параллельного запуска. Четвёртая — матчинг тела: точное сравнение строк не прощает ни пробела, ни другого порядка полей — сложные тела ловите частями или по подстроке. Пятая — чужие ожидания: без clear и reset между тестами правила одного кейса срабатывают в другом, и тест становится зависимым от порядка прогонов. Шестая — verify без times: проверили «был запрос» и пропустили «было пять ретраев». И общая: мок проверяет вашу сторону — как код обрабатывает ответы; совпадает ли мок с настоящим шлюзом, тест не скажет — контракт сверяют отдельно.

### Типовые ошибки с MockServer

- ожидание зарегистрировано после первого запроса — клиент уже получил 404
- на PUT /mockserver/expectation нет Content-Type: application/json — тело не разобрано, правила нет
- хардкод 1080 в клиенте вместо ms.URL(ctx): маппированный порт меняется от запуска к запуску
- матчинг тела точный: лишний пробел или другой порядок полей ломают совпадение
- нет clear и reset между тестами: чужие ожидания переживают свой прогон
- verify без times: пять ретраев не отличить от одного запроса
- тест на моке проверяет только вашу сторону — совпадение с настоящим API сверяют контрактом отдельно

### Соберите регистрацию ожидания

Восстановите вызовы: старт модуля, адрес мока, метод PUT и заголовок.

```go
ms, err := mockserver.___(ctx, "mockserver/mockserver:5.15.0")
if err != nil {
    t.Fatal(err)
}

url, err := ms.___(ctx)

body, _ := json.Marshal(expectation)
put, err := http.NewRequest(http.___, url+"/mockserver/expectation", bytes.NewReader(body))
put.Header.Set("Content-Type", "___")
```

Подсказка: Контейнер запускает mockserver.Run, адрес отдаёт URL, ожидание регистрируется методом PUT — http.MethodPut, а мок ждёт Content-Type: application/json.

Правильные ответы в пропусках: `Run`, `URL`, `MethodPut`, `application/json`

### Соберите тест по порядку

От старта мока до статуса: контейнер, уборка, URL, ожидание, запрос, проверка.

Правильный порядок строк:

1. `ctx := context.Background()`
2. `ms, err := mockserver.Run(ctx, "mockserver/mockserver:5.15.0")`
3. `defer testcontainers.TerminateContainer(ms)`
4. `url, err := ms.URL(ctx)`
5. `put, _ := http.NewRequest(http.MethodPut, url+"/mockserver/expectation", bytes.NewReader(body))`
6. `resp, _ := http.DefaultClient.Do(put)`
7. `out, err := http.Post(url+"/api/payments", "application/json", strings.NewReader(data))`
8. `if out.StatusCode != http.StatusOK {`
9. `  t.Fatalf("код: %d", out.StatusCode)`
10. `}`

### Карточки: модуль Mockserver

- **mockserver.Run** — Вход в модуль: Run(ctx, образ, opts...) возвращает типизированный MockServerContainer. Дефолты — открытый порт 1080/tcp и ожидание по логу started on port: 1080 плюс слушающий порт. Старый RunContainer устарел.
- **URL(ctx)** — Адрес мока на хосте: PortEndpoint по 1080/tcp со схемой http. Docker маппирует порт, и он меняется от запуска к запуску — адрес нужно брать только отсюда.
- **PUT /mockserver/expectation** — Регистрация ожидания: JSON с httpRequest (method, path, body) и httpResponse (statusCode, body, headers). Матчинг точный; без подходящего правила мок отвечает 404.
- **PUT /mockserver/verify** — Проверка вызовов: httpRequest плюс times с atLeast и atMost. Успех — 202 Accepted, расхождение — 406 с описанием, что именно не сошлось.
- **clear и reset** — PUT /mockserver/clear стирает ожидания и историю запросов, PUT /mockserver/reset возвращает мок в исходное состояние. Гигиена между тестами — в t.Cleanup.
- **PUT /mockserver/retrieve** — Достаёт записанные запросы JSON-ом: для проверок, которые не влезают в шаблон verify, — тело, заголовки и время каждого вызова.

> **Про песочницу: нужен локальный Docker.** Честно: в браузерной песочнице этот урок не запустить — Docker там нет, а мок должен жить в настоящем контейнере. Рабочая схема — локально: установленный Go, запущенный Docker и обычный go test в терминале. Код разобран построчно, так что напечатайте тест руками и прогоните на своей машине: первый запуск скачает образ MockServer, последующие пойдут быстро — образ кешируется. Ошибка про подключение к демону — это не про тест, а про окружение: сначала поднимите Docker.

Что дальше: модуль mockserver — самый тонкий в курсе, и это хорошая новость: всё поведение мока задаётся его REST-API, а не опциями Go. Хотите те же кастомайзеры, но для образа, у которого своего модуля нет, — следующий урок, [GenericContainer: любой Docker-образ](/courses/testcontainers/lessons/11-generic-container/), покажет, как собрать то же самое руками. Если Docker на машине ещё не настроен, вернитесь к [установке и первому контейнеру](/courses/testcontainers/lessons/02-ustanovka-pervyj-kontejner/): без демона не стартует ни один модуль курса. Обзор всего пути — [курс testcontainers](/courses/testcontainers/): от жизненного цикла контейнера до CI. И проверка на честность: возьмите самый волосатый httptest-мок в своём проекте и перепишите его на MockServer — ручные счётчики, if-ы и чистка состояния исчезнут, а тест начнёт читаться как спецификация внешнего API.

## Коротко: что запомнить

- mockserver.Run поднимает MockServer в контейнере: порт 1080/tcp и wait-стратегию модуль ставит сам, URL(ctx) отдаёт маппированный адрес на хосте.
- Ожидание — PUT /mockserver/expectation: JSON с httpRequest и httpResponse; без подходящего правила мок отвечает 404.
- Мок помнит вызовы: PUT /mockserver/verify с times сверяет запросы, retrieve достаёт их целиком, clear и reset чистят состояние.
- httptest — для простых моков в процессе теста; MockServer — когда нужны матчинг, верификация и честный отдельный сервер.

## Домашнее задание

Соберите пакет с хелпером startMockserver: mockserver.Run, t.Cleanup с TerminateContainer и возврат URL. Зарегистрируйте ожидание 200 с JSON-телом на POST /api/payments, дёрните мок обычным http.Post и проверьте статус и тело ответа. Добавьте PUT на /mockserver/verify с times atMost — и убедитесь, что он ловит лишний вызов, если отправить два запроса. Затем смените statusCode на 500 и допишите проверку, что ваш клиент возвращает ошибку, а не молчит. Сломайте матчинг — уберите из ожидания поле method или поменяйте путь — и посмотрите на 404 от мока, чтобы знать эту ошибку в лицо. В конце добавьте clear или reset в t.Cleanup и прогоните пакет дважды с -count=2: состояние мока не должно перетекать между прогонами.

## Вопросы и ответы по уроку

### Можно ли обойтись httptest.Server и не тянуть Docker?

Можно, и для одного-двух простых маршрутов это лучший выбор. Но матчинг тела, счётчики вызовов и чистку состояния вы реализуете руками — на трёх-четырёх сценариях это уже десятки строк поддержки. MockServer отдаёт всё это готовым REST-API, а модуль добавляет честный отдельный процесс. Если Docker в окружении недоступен, выбора нет — берите httptest и закладывайте время на ручной учёт. Порог перехода простой: как только моку нужен учёт вызовов или два сценария на один путь — это уже MockServer.

### Почему после Run не нужно ждать готовность мока отдельно?

Модуль сам ставит wait-стратегию из двух условий: в логах должна появиться строка started on port: 1080 и порт 1080/tcp должен слушать. Run возвращается, только когда оба условия выполнены, так что сразу после него можно регистрировать ожидания и слать трафик. Это та же механика готовности, что у базы и брокеров из прошлых уроков, только проще.

### Мок отвечает 404 на путь, для которого ожидание зарегистрировано. Что проверить?

Первым делом Content-Type: application/json на регистрации — без него тело могло не разобраться. Затем точное совпадение: метод, путь, тело — лишний пробел в JSON ломает матчинг. Проверьте, что ходите на адрес из ms.URL(ctx), а не на хардкод порта, и что чужой clear из параллельного теста не стёр ваше ожидание. Быстрая диагностика: отправьте запрос curl-ом на URL из теста и сравните его с httpRequest из ожидания поле за полем.
