Опирается на правила: TS-23TS-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-24inline в тесте
Один WireMock на все внешние сервисыTS-23per внешний сервис
@MockitoBean CatalogClient по умолчаниюTS-25WireMock
WireMock без @DynamicPropertySource для URLTS-23Spring property + dynamic port
@RegisterExtension не staticTS-23static (один WireMock на class)
Hard-coded port (port: 8888)TS-23dynamicPort()
Без verify — не проверяем что внешний был вызванTS-24verify для 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.