Опирается на правила:
TS-23…TS-25из Test Strategy Style Guide → раздел 7. Внешние HTTP — WireMock.
Важно знать
- WireMock в
BaseIntegrationTestчерез@RegisterExtensionс dynamic port.@DynamicPropertySourceпрокидывает base-url в Spring properties.- Стабы в самом тесте, не в общих
mappings/*.json— видно, на что опирается тест.@MockitoBeanдля@FeignClient— допустимо, но WireMock предпочтительнее.- WireMock проверяет HTTP-сериализацию, заголовки, retry, timeout — Mock пропускает.
- Один WireMock на внешний сервис —
catalogClient,paymentClientотдельно.- Static WireMock через
@RegisterExtension— shared между тестами, не пересоздаётся.
Внешние HTTP-вызовы — единственный класс зависимостей, который не заменяется на Outbox-проверку (как Kafka) или @MockitoBean (как DateTimeService). Они есть в проде, и тест должен моделировать их поведение реалистично — включая HTTP-сериализацию, заголовки, codes. WireMock — стандартный инструмент.
WireMock в base
TS-23: подключение к BaseIntegrationTest.
@Import(TestJwtConfiguration.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("integration-test")
@Testcontainers
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public abstract class PlatformBaseIntegrationTest {
@ServiceConnection
protected static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@RegisterExtension
static WireMockExtension catalog = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
@RegisterExtension
static WireMockExtension payment = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
@DynamicPropertySource
static void clientProps(DynamicPropertyRegistry r) {
r.add("clients.catalog.base-url", catalog::baseUrl);
r.add("clients.payment.base-url", payment::baseUrl);
}
@MockitoBean
protected DateTimeService dateTimeService;
@MockitoBean
protected UuidGenerator uuidGenerator;
}
Что важно:
@RegisterExtension static— WireMock запускается один раз на JUnit lifecycle.dynamicPort()— порт назначается автоматически (избегает конфликтов с прод-портами).@DynamicPropertySource— Spring подхватывает URL вclients.catalog.base-url.- Per внешний сервис — отдельный WireMockExtension. Не один на всё.
В production code:
@ConfigurationProperties("clients.catalog")
@Validated
public record CatalogClientSettings(
@NotBlank String baseUrl,
@NotNull Duration timeout
) {}
@Configuration
public class CatalogClientConfig {
@Bean
public RestClient catalogRestClient(CatalogClientSettings settings) {
return RestClient.builder()
.baseUrl(settings.baseUrl())
.build();
}
}
В тесте clients.catalog.base-url = http://localhost:12345 (WireMock port), а timeout приходит из тестового application-integration-test.yml.
Стабы в самом тесте
TS-24: не в общих JSON-файлах.
@Test
@DisplayName("BR-007: create order fetches product from catalog")
void createOrder_whenValidProduct_returns201() {
// Arrange
var productId = UUID.randomUUID();
var orderId = UUID.randomUUID();
given(uuidGenerator.generate()).willReturn(orderId);
catalog.stubFor(get("/api/v1/products/" + productId)
.willReturn(okJson("""
{
"id": "%s",
"name": "Test Product",
"price": "100.00",
"currency": "RUB",
"available": true
}
""".formatted(productId))));
var request = new CreateOrderRequest(productId, 2);
// Act
var response = restTemplate.exchange(
"/v1/orders",
HttpMethod.POST,
new HttpEntity<>(request, TestHttpHeaders.withSuccessToken()),
OrderResponse.class
);
// Assert
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody().totalAmount()).isEqualByComparingTo(new BigDecimal("200.00"));
catalog.verify(getRequestedFor(urlEqualTo("/api/v1/products/" + productId)));
}
Почему стабы в тесте:
- Видно, на что опирается тест — payload product читается прямо в коде теста.
- Тест self-contained — не нужно искать
mappings/get-product.jsonв другом файле. - Variants легко — для разного сценария разный стаб, без множества
.jsonфайлов.
@Test
void createOrder_whenProductOutOfStock_returns409() {
catalog.stubFor(get("/api/v1/products/" + productId)
.willReturn(okJson("""
{
"id": "%s",
"available": false
}
""".formatted(productId))));
// act + assert (409 Conflict)
}
@Test
void createOrder_whenCatalogDown_returns503() {
catalog.stubFor(get(urlPathMatching("/api/v1/products/.*"))
.willReturn(serverError()));
// act + assert (503 + Circuit Breaker triggered)
}
WireMock vs @MockitoBean для FeignClient
TS-25: WireMock предпочтительнее.
Допустимая альтернатива — @MockitoBean CatalogClient:
@MockitoBean
private CatalogClient catalogClient;
@Test
void test() {
given(catalogClient.getProduct(productId)).willReturn(new Product(...));
// act + assert
}
Что проверяет @MockitoBean:
- Spring DI работает.
- Business логика вызывает
getProduct(...).
Что не проверяет:
- HTTP-сериализация (request body matches).
- Заголовки (Idempotency-Key, Authorization).
- Response десериализация.
- Retry policy.
- Circuit Breaker behaviour.
Что проверяет WireMock:
- Всё выше + реальный HTTP roundtrip.
В UCP-сервисах WireMock предпочтительнее. @MockitoBean используется только для:
- Edge cases где WireMock сложно настроить (streaming, websockets).
- Когда внешний контракт не HTTP (gRPC, JMS).
- Для скорости в unit-тестах (не integration).
stub verify
catalog.verify(getRequestedFor(urlEqualTo("/api/v1/products/" + productId))
.withHeader("X-Idempotency-Key", matching(".+"))
.withHeader("Authorization", matching("Bearer .+")));
Verify проверяет, как наш сервис вызвал внешний:
- URL правильный.
- Headers выставлены (
Idempotency-Key,Authorization). - Body соответствует.
Это даёт E2E-style проверки HTTP-контракта без поднимания реального catalog-сервиса.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Стабы в mappings/*.json отдельных файлах | TS-24 | inline в тесте |
| Один WireMock на все внешние сервисы | TS-23 | per внешний сервис |
@MockitoBean CatalogClient по умолчанию | TS-25 | WireMock |
WireMock без @DynamicPropertySource для URL | TS-23 | Spring property + dynamic port |
@RegisterExtension не static | TS-23 | static (один WireMock на class) |
Hard-coded port (port: 8888) | TS-23 | dynamicPort() |
Без verify — не проверяем что внешний был вызван | TS-24 | verify для critical interactions |
| WireMock в каждом тестовом классе отдельно | TS-23 | в BaseIntegrationTest |
Куда дальше
- Test Strategy → раздел 7. Внешние HTTP — WireMock — нормативные формулировки.
- BaseIntegrationTest — WireMockExtension в base.
- Один тест — пример stub в тесте.
- Resilience → OpenAPI generator binding — generated client + WireMock.
- Resilience → retry — testing retry policy через WireMock.
- Resilience → circuit breaker — testing CB через WireMock failures.
- Что НЕ покрывается интеграционными — E2E с реальным catalog.