Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 10 из 25 ~16 мин +50 XP

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

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

Содержание урока · 10 разделов

Сервисы не живут в изоляции: платёжный шлюз, смс-провайдер, карта, почта — почти каждый продакшен-код ходит наружу, и интеграционный тест обязан проверить, как код переживает ответы этого наружного мира. Реальный биллинг из тестов дёргать нельзя — деньги настоящие, лимиты чужие, а поведение зависит от чужой инфраструктуры, на которую вы не влияете: сегодня шлюз отвечает за секунду, завтра завис на реконсиляции, и ваш тест красный без всякой вины. Заглушить зависимость интерфейсом можно, но тогда тест не увидит ни заголовков, ни статусов, ни реального 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: сигнатура одинаковая во всех модулях курса, и мозг не переключается. Свои опции модуль принимает те же, что и ядро: переменные окружения, лимиты ресурсов, имя контейнера — они применяются поверх дефолтов, а не вместо них.

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)?

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

Вторая половина ценности 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 с описанием расхождения провалит тест и покажет, где поведение разошлось с планом.

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

Прочитайте код и выберите, что он выведет:

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

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

fmt.Println(out.StatusCode)

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

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

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

ПодходКогда применятьСлабое место
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 и заголовок.

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", "")

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

mockserver.Run

нажмите, чтобы перевернуть
Определение

Вход в модуль: Run(ctx, образ, opts...) возвращает типизированный MockServerContainer. Дефолты — открытый порт 1080/tcp и ожидание по логу started on port: 1080 плюс слушающий порт. Старый RunContainer устарел.

1 / 6

Что дальше: модуль mockserver — самый тонкий в курсе, и это хорошая новость: всё поведение мока задаётся его REST-API, а не опциями Go. Хотите те же кастомайзеры, но для образа, у которого своего модуля нет, — следующий урок, GenericContainer: любой Docker-образ, покажет, как собрать то же самое руками. Если Docker на машине ещё не настроен, вернитесь к установке и первому контейнеру: без демона не стартует ни один модуль курса. Обзор всего пути — курс testcontainers: от жизненного цикла контейнера до CI. И проверка на честность: возьмите самый волосатый httptest-мок в своём проекте и перепишите его на MockServer — ручные счётчики, if-ы и чистка состояния исчезнут, а тест начнёт читаться как спецификация внешнего API.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • 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: состояние мока не должно перетекать между прогонами.

Личные заметкиПусто
Хранятся только в вашем браузере (Testcontainers · Модуль Mockserver: подмена внешних API)

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

Можно ли обойтись 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 из ожидания поле за полем.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Go Base25 уроков~12 чновичок25 уроков уже доступны

Go с нуля: базовый синтаксис и конструкции

Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.

  • Писать, компилировать и запускать программы на Go
  • Свободно работать с переменными, типами, операторами
  • Управлять потоком выполнения: if, switch, for, range
  • Хранить данные в массивах, срезах и картах