Внешние зависимости — HTTP-сервисы, почтовые провайдеры, платёжные шлюзы — делают тесты медленными и хрупкими. В этой статье разберём, когда нужен мок (заглушка), а когда он только мешает, и как тестировать HTTP-вызовы с WireMock.
Самое неприятное в заглушках — не то, что они ломают тесты, а то, что они их не ломают. Партнёр поменял формат ответа, приложение в проде считает отказ успехом, а тест по-прежнему зелёный: он спрашивает не партнёра, а наше представление о партнёре.
Пока заглушка и партнёр отвечают одинаково, зелёный тест что-то значит. Как только партнёр поменял формат ответа, заглушка продолжает повторять старый — и тест защищает уже не поведение системы, а нашу память о том, каким этот ответ был. Такое расхождение видно только там, где ответы сверяют с настоящим партнёром: контрактные тесты или записанные ответы из его тестового контура.
Когда мок уместен
Мок — объект, который притворяется настоящим: принимает вызовы и возвращает заранее заданные ответы. Mockito — стандартный инструмент для создания моков в Java-тестах.
Короткая формула: мок уместен на внешней границе — там, где заканчивается ваш код и начинается чужой.
Хорошие кандидаты для мока: HTTP-клиент к внешнему сервису, SMS-шлюз, email-провайдер. В unit-тесте достаточно убедиться, что ваш класс правильно реагирует на ответ зависимости — поднимать реальный сервер ради этого излишне.
@ExtendWith(MockitoExtension.class)
class CheckoutServiceTest {
@Mock
private PaymentClient paymentClient;
@InjectMocks
private CheckoutService checkout;
@Test
void failsWhenPaymentDeclined() {
when(paymentClient.charge(any())).thenReturn(PaymentResult.DECLINED);
assertThrows(PaymentDeclinedException.class,
() -> checkout.place(orderRequest()));
}
}
Так мок создают в обычном тесте, без Spring. Если контекст всё-таки поднимается, бин подменяют аннотацией: @MockitoBean ставит вместо настоящего бина мок, @MockitoSpyBean оборачивает настоящий. В коде постарше на их месте встречаются @MockBean и @SpyBean — это они же, в Spring Boot 3.4 объявленные устаревшими.
Разница между этими двумя инструментами не в синтаксисе, и её стоит проговорить, потому что второй стоит дорого.
@Mock — это просто объект: Mockito создаёт заглушку, вы сами передаёте её в конструктор проверяемого класса, никакого Spring рядом нет. Стоимость — микросекунды, тест мгновенный.
@MockitoBean — это изменение контекста Spring: настоящий бин заменяется заглушкой в самом контейнере. И вот цена, о которой почти не пишут: набор подменённых бинов входит в ключ кеша контекста. Контекст переиспользуется между тестами, только если конфигурация совпадает полностью; добавили в одном тестовом классе подмену одного бина — у него другой ключ, и Spring поднимает ещё один полный контекст с миграциями, пулами и всем остальным. Пять тестовых классов с пятью разными наборами подмен — это пять контекстов и пять раз по несколько секунд старта.
Отсюда правило, которое экономит минуты прогона: если можно обойтись @Mock, обходитесь им. Подмена бина нужна ровно тогда, когда проверяемый код достаётся из контекста и подсунуть ему зависимость руками нельзя. Если подменять всё-таки приходится, набор подмен выносят в общую тестовую конфигурацию, одну на все такие тесты, — тогда ключ у них общий и контекст один. Подробнее про кеш контекста — в статье про интеграционные тесты.
Когда мок вредит
Короткая формула: мокать свой код — значит тестировать ожидания о поведении, а не само поведение.
Если мок стоит между двумя вашими классами (например, OrderService и OrderRepository), тест проходит даже при неправильном взаимодействии. Поменяйте сигнатуру или смысл метода — мок «не заметит», тест всё равно зеленеет.
Правило: моки только на внешних границах. Для своего Repository — лучше Testcontainers с реальной базой: запуск дороже, но тест честнее.
Граница системы проходит по владению кодом: смотрите, по какую её сторону лежит подменяемая зависимость.
Чего нельзя мокать вообще
Есть третья категория, помимо «уместно» и «вредит»: типы, которые мокать нельзя ни при каких обстоятельствах, потому что результат не будет иметь отношения к реальности.
Типы, которые вам не принадлежат, со сложным поведением. Драйвер базы данных, Connection, ResultSet, клиент брокера, HTTP-клиент чужой библиотеки, клиент облачного хранилища. Заглушка на них воспроизводит не библиотеку, а вашу фантазию о библиотеке: вы записали «на третий вызов вернёт пустой список», а настоящая библиотека в этом месте бросает исключение, кидает повтор или возвращает ленивый курсор. Такой тест зелёный ровно до прода. Признак ловушки простой: если в тесте приходится описывать, в каком порядке и что вернут четыре метода чужого класса, — вы пишете спецификацию чужого кода, и она неверна.
Чем заменять: настоящей реализацией в контейнере (база, брокер, совместимое хранилище), локальным HTTP-сервером-заглушкой на уровне протокола — то есть WireMock из следующего раздела, а не мок клиента, — или собственной тонкой обёрткой. Последнее — самый недооценённый приём: заводите свой интерфейс с двумя-тремя методами на языке вашей задачи, за ним прячете чужую библиотеку, и мокаете свой интерфейс, а не чужой класс. Обёртка проверяется одним интеграционным тестом против настоящей библиотеки, всё остальное тестируется на заглушке своего интерфейса.
Значимые объекты и структуры данных. Мок на Money, OrderId, LocalDate, коллекцию или запись — это всегда ошибка: такие объекты просто создают настоящими, они для этого и существуют. Заглушка вместо значения делает тест нечитаемым и способна «вернуть» то, чего настоящий объект не вернул бы никогда.
Статические вызовы и конструкторы. Технически Mockito это умеет, и именно поэтому стоит сказать: подмена статического метода или создаваемого внутри объекта — признак, что зависимость не выражена в коде. Правильный ход — сделать её явной (передать через конструктор), а не подменять на уровне загрузчика классов. Единственное разумное исключение — чужой код, который вы не можете изменить, и обёртку вокруг него всё равно придётся написать.
И то, что проверяется только целиком. Транзакция, права доступа, сериализация ответа, работа фильтров: заглушка обходит ровно тот механизм, который вы собирались проверить. Это уровень интеграционного теста, и никакой мок его не заменит.
Строгие заглушки и проверка вызовов
Две вещи в Mockito, без которых заглушки начинают лгать со временем.
Строгость. MockitoExtension работает в режиме STRICT_STUBS, и это не придирка, а способ ловить устаревшие тесты. Правила два: заглушка, которую тест завёл, но так и не использовал, роняет тест с UnnecessaryStubbingException; вызов, для которого заглушки нет, возвращает не тишину, а сразу указывает на несоответствие аргументов. Польза видна через полгода: код изменился, метод больше не вызывается, а тест по-прежнему «проверяет» его ответ — строгий режим это обнаружит, мягкий промолчит и оставит зелёный тест, который ничего не проверяет.
Когда заглушка нужна не всегда (одна подготовка на пять тестов, из которых её использует три), не выключают строгость целиком, а помечают конкретное место: lenient().when(...). Общий @MockitoSettings(strictness = Strictness.LENIENT) на класс — почти всегда признак, что подготовка теста делает больше, чем нужно.
Проверка вызовов. Всё, что выше, — про ответы заглушки. Но половина проверок звучит иначе: «письмо отправлено», «повтора не было», «в платёжный шлюз ушла правильная сумма». Это не результат функции, а факт исходящего вызова, и проверяется он отдельно:
verify(mailer).send(argThat(m -> m.to().equals("a@b.ru"))); // вызвали с такими аргументами
verify(paymentClient, never()).charge(any()); // не вызывали вовсе
verify(paymentClient, times(3)).charge(any()); // ровно три попытки
ArgumentCaptor<ChargeRequest> captor = ArgumentCaptor.forClass(ChargeRequest.class);
verify(paymentClient).charge(captor.capture());
assertThat(captor.getValue().amount()).isEqualTo(new Money(500));
never() заслуживает отдельного слова: проверить, что чего-то не произошло, иначе невозможно, а именно такие ошибки самые дорогие — второе списание, повторное письмо, отправка уведомления при откате. times(n) — единственный способ проверить число повторов. ArgumentCaptor нужен, когда аргумент собирается внутри и проверить его условием в argThat неудобно: захватили объект и проверяете его обычными утверждениями, по-человечески читая падение.
И граница, за которую лучше не выходить: verify проверяет взаимодействие, и им легко привязать тест к реализации. «Вызвал ли репозиторий метод сохранения» — плохая проверка, потому что поведение «заказ сохранён» не изменится, если сохранять станут иначе. «Ушёл ли запрос в платёжный шлюз» — хорошая, потому что сам вызов и есть наблюдаемое поведение. Разбор по видам дублей — в разделе «Глубже» ниже.
WireMock: HTTP-заглушка вместо реального сервиса
Когда ваш сервис вызывает внешний HTTP-сервис, поднять реальный сервер в тестах невозможно. WireMock запускает локальный HTTP-сервер и возвращает настроенные ответы.
@SpringBootTest
@AutoConfigureWireMock(port = 0)
@TestPropertySource(properties = "payment.base-url=http://localhost:${wiremock.server.port}")
class PaymentGatewayClientTest {
@Autowired
private PaymentGatewayClient client;
@Test
void returnsDeclinedOnHttp402() {
stubFor(post(urlEqualTo("/charge"))
.willReturn(aResponse()
.withStatus(402)
.withHeader("Content-Type", "application/json")
.withBody("{\"status\":\"DECLINED\"}")));
PaymentResult result = client.charge(new ChargeRequest("card_123", 500));
assertThat(result).isEqualTo(PaymentResult.DECLINED);
}
}
@AutoConfigureWireMock(port = 0) — аннотация не из Spring Boot, как можно подумать, а из Spring Cloud Contract: нужна отдельная зависимость org.springframework.cloud:spring-cloud-contract-wiremock, версию которой задаёт BOM Spring Cloud. Она поднимает WireMock на свободном порту и кладёт его номер в свойство wiremock.server.port.
И сразу про вариант, который нужен большинству: если Spring Cloud в проекте нет, тянуть его ради тестов не надо. У самого WireMock есть расширение для JUnit 5, и оно самодостаточно:
// testImplementation("org.wiremock:wiremock-standalone:3.9.1")
@SpringBootTest
class PaymentGatewayClientExtensionTest {
@RegisterExtension
static WireMockExtension wiremock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("payment.base-url", wiremock::baseUrl);
}
@Autowired
PaymentGatewayClient client;
@Test
void returnsDeclinedOnHttp402() {
wiremock.stubFor(post("/charge").willReturn(aResponse().withStatus(402)));
assertThat(client.charge(new ChargeRequest("card_123", 500)))
.isEqualTo(PaymentResult.DECLINED);
}
}
Разница только в том, кто поднимает сервер и откуда берётся адрес: @RegisterExtension со статическим полем поднимает его один раз на класс тестов, а baseUrl() отдаёт готовый адрес с уже подставленным случайным портом. Побочная польза — заглушки сбрасываются между тестами автоматически, то есть не нужно помнить про очистку правил в @AfterEach.
Дальше половина работы остаётся за вами. Приложение о заглушке не знает: адрес партнёра оно берёт из своей конфигурации и пойдёт ровно по нему — то есть на настоящий адрес из application.yml. Чтобы клиент пришёл на localhost, базовый URL в тесте переопределяют этим портом — строка @TestPropertySource в примере как раз про это. Без неё тест выглядит рабочим, а стучится к настоящему партнёру.
WireMock позволяет имитировать задержку (withFixedDelay), разрывы соединения, последовательность ответов — полезно для проверки логики повторных попыток и таймаутов. И это та половина его ценности, которую без кода не понять, поэтому три самых нужных случая.
Таймаут клиента. Партнёр отвечает медленнее, чем вы готовы ждать. Задержка задаётся заглушке, а тест проверяет, что клиент не завис, а честно отвалился своей ошибкой:
stubFor(post("/charge").willReturn(aResponse()
.withStatus(200)
.withFixedDelay(3000))); // клиент настроен на таймаут 1 с
assertThatThrownBy(() -> client.charge(request))
.isInstanceOf(PaymentUnavailableException.class);
Тест заодно отвечает на вопрос, который иначе проверить нечем: а настроен ли таймаут вообще? Клиент без таймаута в этом тесте будет ждать три секунды и вернёт успех — и вы увидите, что в проде он ждал бы бесконечно.
Обрыв соединения. Не всякий отказ — это код ответа. Сброшенное соединение и пустой ответ обрабатываются другим кодом, чем 500, и ломают клиентов чаще:
stubFor(post("/charge").willReturn(aResponse()
.withFault(Fault.CONNECTION_RESET_BY_PEER)));
Последовательность ответов и число повторов. Самая полезная возможность: описать «первые два раза плохо, третий хорошо» и проверить, что клиент сделал именно три попытки и вернул успех.
Сценарий WireMock во времени: два отказа подряд, успех на третьей попытке и проверка, что попыток было ровно три.
stubFor(post("/charge").inScenario("retry")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503))
.willSetStateTo("attempt-2"));
stubFor(post("/charge").inScenario("retry")
.whenScenarioStateIs("attempt-2")
.willReturn(aResponse().withStatus(503))
.willSetStateTo("attempt-3"));
stubFor(post("/charge").inScenario("retry")
.whenScenarioStateIs("attempt-3")
.willReturn(okJson("{\"status\":\"APPROVED\"}")));
assertThat(client.charge(request)).isEqualTo(PaymentResult.APPROVED);
verify(exactly(3), postRequestedFor(urlEqualTo("/charge")));
Последняя строка здесь — самое важное: verify у WireMock считает запросы, дошедшие до заглушки, и это единственный способ проверить, что повторов было ровно столько, сколько задумано. Тест с бесконечными повторами и тест с одной попыткой оба «проходят», если число не проверять.
За пределами такого теста остаётся сам контракт партнёра: заглушка отвечает так, как мы думаем, что отвечает партнёр. Расхождение ловят контрактными тестами (Pact, Spring Cloud Contract) или записью реальных ответов из тестового контура партнёра, которые потом воспроизводит заглушка. Сценарии WireMock (scenario со состояниями) позволяют описать последовательность «первые два ответа — таймаут, третий — 200» и проверить число повторов через verify.
Изоляция тестовых данных
Тесты должны быть независимы: порядок запуска не должен влиять на результат. Три основных подхода.
Транзакционный откат — каждый тест выполняется в транзакции, которая откатывается после. Подходит, когда не нужно проверять поведение при commit.
@SpringBootTest
@Transactional
class ProductRepositoryTest {
// база данных чистая для каждого теста
}
Очистка через @Sql — явно сбрасывать состояние перед тестом или набором тестов. Медленнее, зато честно покрывает несколько транзакций.
@Test
@Sql("/sql/cleanup.sql")
void createsOrder() { ... }
executionPhase писать не нужно: «перед методом теста» и есть значение по умолчанию. А вот о чём стоит знать заранее — скрипт по умолчанию выполняется в той же транзакции, что и тест. На тесте с @Transactional очистка откатится вместе с ним и до базы не доедет; если она должна пережить откат, режим задают явно: @SqlConfig(transactionMode = TransactionMode.ISOLATED).
Уникальные идентификаторы — генерировать уникальный UUID прямо в тесте, чтобы разные тесты не конкурировали за одни и те же записи.
@Test
void findsOrdersOfCustomer() {
String customerId = "cust-" + UUID.randomUUID(); // ничей больше
repository.save(order(customerId, "PENDING"));
repository.save(order(customerId, "PAID"));
assertThat(repository.findByCustomerId(customerId)).hasSize(2);
}
Разница с двумя предыдущими способами практическая, а не стилистическая. Откат транзакции не годится, когда тест идёт через настоящий HTTP: запрос обрабатывается в другом потоке со своей транзакцией, и откат тестовой его не касается. Общая очистка не годится при параллельном прогоне: пока один тест делает TRUNCATE, второй в это же время читает свои данные и не находит их. Уникальные ключи не ломаются ни там, ни там — им вообще не важен ни порядок, ни соседи, ни число потоков, и именно поэтому это единственный способ, выживающий при параллельном прогоне на одной базе.
Цена ровно одна, и её надо помнить: любая проверка «а сколько всего» перестаёт работать. count() по таблице и findAll() в таком тесте бессмысленны, потому что там же лежат данные соседей; проверять надо всегда с условием по своему ключу — как в примере выше. И база от прогона к прогону растёт, поэтому раз в какое-то время её всё равно пересоздают — обычно это бесплатно, потому что контейнер поднимается заново на каждой сборке.
Тест падает в полночь: время и случайность
Тест с LocalDateTime.now() внутри проверяемого кода — недетерминированный: каждый прогон возвращает другой результат, и точную проверку не написать. Решение — Clock из java.time.
// Конфигурация: бин Clock в основном контексте
@Bean
public Clock clock() {
return Clock.systemUTC();
}
// Сервис принимает Clock через конструктор
public class OrderService {
private final Clock clock;
private final OrderRepository repository;
public OrderService(Clock clock, OrderRepository repository) {
this.clock = clock;
this.repository = repository;
}
public Order place(OrderRequest request) {
return repository.save(Order.from(request, OffsetDateTime.now(clock)));
}
}
// Тест подставляет фиксированный Clock
@Test
void stampsOrderWithCreationTime() {
Clock fixed = Clock.fixed(Instant.parse("2025-01-15T10:00:00Z"), ZoneOffset.UTC);
var service = new OrderService(fixed, repository);
Order order = service.place(orderRequest());
assertThat(order.createdAt()).isEqualTo(OffsetDateTime.parse("2025-01-15T10:00:00Z"));
}
Отдельно про Clock.systemUTC() вместо привычного systemDefaultZone(): второй берёт часовой пояс машины, и одна и та же запись получит разное время на ноутбуке и на сервере — тест, зелёный у вас, покраснеет в сборке. В базе отметка времени лежит в timestamptz, то есть это момент на общей шкале, а не «время на чьих-то часах». Поэтому и в коде удобнее момент: Instant или OffsetDateTime, а не LocalDateTime, у которого зоны нет вовсе.
То же касается генераторов случайных чисел: передавайте Random с фиксированным seed через конструктор, не создавайте new Random() внутри метода.
Глубже: пять видов дублей: dummy, stub, fake, spy, mockрасширенное
Слово «заглушка» в этой статье обозначает пять разных вещей, и спор «мок уместен или вредит» без разделения не решается. Разделение старое и точное.
Dummy передают, потому что параметр обязателен, и никогда не используют: null или пустой объект на месте сервиса уведомлений в тесте, который проверяет расчёт цены. Stub отвечает заранее заданным: when(rates.get("USD")).thenReturn(rate); он нужен, чтобы тест дошёл до проверяемого места, и его ответы никто не проверяет. Fake это работающая упрощённая реализация: репозиторий на HashMap с настоящей логикой поиска, почтовый сервис, складывающий письма в список. Он ведёт себя как настоящий на уровне контракта, и поэтому тесты с ним переживают рефакторинг. Spy записывает, что с ним делали, и тест смотрит запись после: сколько раз вызвали, с чем; в Mockito это verify на любом моке. Mock в строгом смысле знает ожидания заранее и падает при отклонении; на практике словом «мок» называют всё, что создал mock(), и это стирает разницу.
Разница определяет, что тест проверяет. Stub и fake проверяют результат: что вернул метод, что оказалось в базе. Spy и mock проверяют взаимодействие: что метод вызвал соседа. Первое устойчиво, второе хрупко: тест с verify(repo).save(any()) падает при любом изменении способа сохранения, хотя поведение не изменилось. Отсюда правило: проверять взаимодействие только там, где оно и есть результат, у исходящих команд без возвращаемого значения, «письмо отправлено», «событие опубликовано»; всё остальное проверять по состоянию через stub или fake.
Что брать по умолчанию. Для репозиториев и хранилищ fake или настоящая база в контейнере; для внешних сервисов stub с заданными ответами (WireMock из раздела выше это stub на уровне HTTP); для проверки «отправили ли» spy. Мок с ожиданиями на каждый вызов это признак, что тест повторяет реализацию, и такой тест удаляют вместе с первым рефакторингом.
Глубже: контрактные тесты: кто пишет контракт и как он ломает сборкурасширенное
Схема в начале статьи показывает, как заглушка тихо расходится с партнёром. Тест на заглушке остаётся зелёным, потому что заглушку никто не сверяет с настоящим сервисом; контрактные тесты делают именно это, и есть два способа их устроить.
Со стороны потребителя (Pact). Потребитель пишет тест, в котором описывает, что он отправляет и что ожидает получить: POST /charge с такими полями отвечает 201 с полем paymentId. Тест запускается против заглушки, которую Pact поднимает из этого описания, и на выходе даёт файл контракта. Файл публикуют в брокер контрактов, а сборка поставщика скачивает контракты всех своих потребителей и проигрывает их против настоящего кода поставщика: каждый запрос из контракта отправляется в поднятый сервис, ответ сверяется с ожиданием. Изменил поставщик поле, которое читает мобильное приложение, и его сборка красная с именем потребителя и полем, до выката. Брокер умеет отвечать на вопрос «можно ли выкатить эту версию поставщика, если у потребителей такие-то версии в проде» одной командой, и это встраивают в конвейер.
Со стороны поставщика (Spring Cloud Contract). Контракт пишет поставщик, в своём репозитории, на языке описания запросов и ответов. Из него генерируются два артефакта: тесты для самого поставщика, которые проверяют, что он отвечает по контракту, и заглушка для потребителей, которую те поднимают в своих тестах вместо WireMock, настроенного руками. Заглушка не расходится с поставщиком по построению, потому что собрана из того же контракта, что и его тесты.
Что выбрать. Потребителей много и они в других командах, которые не хотят зависеть от репозитория поставщика: Pact. Поставщик один на несколько своих потребителей и хочет отдать им готовые заглушки: Spring Cloud Contract. Оба умеют контракты для сообщений (событие в очереди с такой-то схемой), и для событий это важнее, чем для HTTP, потому что там нет статуса 400, который скажет о расхождении. Контракт не заменяет интеграционный тест на настоящем партнёре в препроде, но заменяет большую часть таких тестов и ловит расхождение там, где его создали, а не в проде.
Коротко
- Моки уместны только на внешних границах (HTTP-клиент, email, платёжный шлюз); мокать свой код делает тест хрупким.
- WireMock поднимает локальный HTTP-сервер и заменяет реальный внешний сервис без сетевых вызовов.
- Testcontainers с реальной базой надёжнее, чем мок-репозиторий.
- Изолируйте тестовые данные: транзакционный откат,
@Sqlили уникальные идентификаторы в каждом тесте. LocalDateTime.now()внутри кода — источник недетерминированности; заменяйте наClock, внедряемый через конструктор.- Дубли различают по роли: dummy не используют, stub отвечает заданным, fake работает упрощённо, spy записывает вызовы, mock ждёт их; проверять взаимодействие только у исходящих команд, остальное по состоянию.
- Контракт сверяет заглушку с настоящим партнёром: Pact от потребителя через брокер, Spring Cloud Contract от поставщика с генерацией тестов и заглушек; для событий важнее, чем для HTTP.
@Mock— просто объект,@MockitoBean— изменение контекста Spring, входящее в ключ кеша: каждый новый набор подмен поднимает ещё один полный контекст, поэтому подмены выносят в общую тестовую конфигурацию.- Нельзя мокать чужие типы со сложным поведением (драйверы, клиенты библиотек), значимые объекты и то, что проверяется только целиком; вместо этого — своя тонкая обёртка, контейнер или заглушка на уровне протокола.
- Строгий режим Mockito роняет тест на неиспользованной заглушке и так находит устаревшие тесты; факт вызова проверяют
verify, отсутствие —never(), число повторов —times(n), аргументы —ArgumentCaptor.
Что почитать дальше
- Пирамида тестирования — какой уровень тестов за что отвечает.
- Интеграционное тестирование — Testcontainers,
@SpringBootTest, тесты со слоем. - Тестирование в Spring —
@WebMvcTest,@DataJpaTestи другие срезы.