«1. Обзор

В этой статье мы рассмотрим интеграционное тестирование клиента Feign.

Мы создадим базовый клиент Open Feign, для которого напишем простой интеграционный тест с помощью WireMock.

После этого мы добавим конфигурацию ленты в наш клиент, а также создадим для нее интеграционный тест. И, наконец, мы настроим тестовый контейнер Eureka и протестируем эту настройку, чтобы убедиться, что вся наша конфигурация работает должным образом.

2. Клиент Feign

Чтобы настроить наш клиент Feign, мы должны сначала добавить зависимость Spring Cloud OpenFeign Maven:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

После этого давайте создадим класс Book для нашей модели:

public class Book {
    private String title;
    private String author;
}

И, наконец, давайте создадим наш интерфейс Feign Client:

@FeignClient(value="simple-books-client", url="${book.service.url}")
public interface BooksClient {

    @RequestMapping("/books")
    List<Book> getBooks();

}

Теперь у нас есть Feign Client, который извлекает список книг из службы REST. Теперь давайте двигаться вперед и написать несколько интеграционных тестов.

3. WireMock

3.1. Настройка сервера WireMock

Если мы хотим протестировать наш BooksClient, нам нужна фиктивная служба, предоставляющая конечную точку /books. Наш клиент будет совершать вызовы против этой фиктивной службы. Для этой цели мы будем использовать WireMock.

Итак, давайте добавим зависимость WireMock Maven:

<dependency>
    <groupId>com.github.tomakehurst</groupId>
    <artifactId>wiremock</artifactId>
    <scope>test</scope>
</dependency>

и настроим фиктивный сервер:

@TestConfiguration
public class WireMockConfig {

    @Autowired
    private WireMockServer wireMockServer;

    @Bean(initMethod = "start", destroyMethod = "stop")
    public WireMockServer mockBooksService() {
        return new WireMockServer(9561);
    }

}

Теперь у нас есть работающий фиктивный сервер, принимающий соединения через порт 9651.

3.2. Настройка макета

Давайте добавим в наш application-test.yml свойство book.service.url, указывающее на порт WireMockServer:

book:
  service:
    url: http://localhost:9561

И также подготовим макет ответа get-books-response.json для конечная точка /books:

[
  {
    "title": "Dune",
    "author": "Frank Herbert"
  },
  {
    "title": "Foundation",
    "author": "Isaac Asimov"
  }
]

Теперь давайте настроим фиктивный ответ на запрос GET в конечной точке /books:

public class BookMocks {

    public static void setupMockBooksResponse(WireMockServer mockService) throws IOException {
        mockService.stubFor(WireMock.get(WireMock.urlEqualTo("/books"))
          .willReturn(WireMock.aResponse()
            .withStatus(HttpStatus.OK.value())
            .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
            .withBody(
              copyToString(
                BookMocks.class.getClassLoader().getResourceAsStream("payload/get-books-response.json"),
                defaultCharset()))));
    }

}

На этом этапе все необходимые настройки готовы. Давайте продолжим и напишем наш первый тест.

4. Наш первый интеграционный тест

Давайте создадим интеграционный тест BooksClientIntegrationTest:

@SpringBootTest
@ActiveProfiles("test")
@EnableConfigurationProperties
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = { WireMockConfig.class })
class BooksClientIntegrationTest {

    @Autowired
    private WireMockServer mockBooksService;

    @Autowired
    private BooksClient booksClient;

    @BeforeEach
    void setUp() throws IOException {
        BookMocks.setupMockBooksResponse(mockBooksService);
    }

    // ...
}

На данный момент у нас есть SpringBootTest, настроенный с помощью WireMockServer, готовый вернуть предопределенный список книг, когда конечная точка /books вызывается BooksClient.

И, наконец, добавим наши тестовые методы:

@Test
public void whenGetBooks_thenBooksShouldBeReturned() {
    assertFalse(booksClient.getBooks().isEmpty());
}

@Test
public void whenGetBooks_thenTheCorrectBooksShouldBeReturned() {
    assertTrue(booksClient.getBooks()
      .containsAll(asList(
        new Book("Dune", "Frank Herbert"),
        new Book("Foundation", "Isaac Asimov"))));
}

5. Интеграция с лентой

Теперь давайте улучшим наш клиент, добавив возможности балансировки нагрузки, предоставляемые лентой.

Все, что нам нужно сделать в клиентском интерфейсе, это удалить жестко закодированный URL-адрес службы и вместо этого ссылаться на службу по имени службы book-service:

@FeignClient("books-service")
public interface BooksClient {
...

Затем добавьте зависимость Netflix Ribbon Maven:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
</dependency>

И, наконец, в файле application-test.yml мы должны удалить book.service.url и вместо этого определить ленту listOfServers:

books-service:
  ribbon:
    listOfServers: http://localhost:9561

Теперь снова запустим BooksClientIntegrationTest. Это должно пройти, подтверждая, что новая настройка работает должным образом.

5.1. Конфигурация динамического порта

Если мы не хотим жестко кодировать порт сервера, мы можем настроить WireMock на использование динамического порта при запуске.

Для этого давайте создадим еще одну тестовую конфигурацию, RibbonTestConfig:

@TestConfiguration
@ActiveProfiles("ribbon-test")
public class RibbonTestConfig {

    @Autowired
    private WireMockServer mockBooksService;

    @Autowired
    private WireMockServer secondMockBooksService;

    @Bean(initMethod = "start", destroyMethod = "stop")
    public WireMockServer mockBooksService() {
        return new WireMockServer(options().dynamicPort());
    }

    @Bean(name="secondMockBooksService", initMethod = "start", destroyMethod = "stop")
    public WireMockServer secondBooksMockService() {
        return new WireMockServer(options().dynamicPort());
    }

    @Bean
    public ServerList ribbonServerList() {
        return new StaticServerList<>(
          new Server("localhost", mockBooksService.port()),
          new Server("localhost", secondMockBooksService.port()));
    }

}

Эта конфигурация устанавливает два сервера WireMock, каждый из которых работает на другом порту, динамически назначаемом во время выполнения. Кроме того, он также настраивает список серверов ленты с двумя фиктивными серверами.

5.2. Тестирование балансировки нагрузки

Теперь, когда у нас настроен балансировщик нагрузки Ribbon, давайте удостоверимся, что наш BooksClient правильно переключается между двумя фиктивными серверами:

@SpringBootTest
@ActiveProfiles("ribbon-test")
@EnableConfigurationProperties
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = { RibbonTestConfig.class })
class LoadBalancerBooksClientIntegrationTest {

    @Autowired
    private WireMockServer mockBooksService;

    @Autowired
    private WireMockServer secondMockBooksService;

    @Autowired
    private BooksClient booksClient;

    @BeforeEach
    void setUp() throws IOException {
        setupMockBooksResponse(mockBooksService);
        setupMockBooksResponse(secondMockBooksService);
    }

    @Test
    void whenGetBooks_thenRequestsAreLoadBalanced() {
        for (int k = 0; k < 10; k++) {
            booksClient.getBooks();
        }

        mockBooksService.verify(
          moreThan(0), getRequestedFor(WireMock.urlEqualTo("/books")));
        secondMockBooksService.verify(
          moreThan(0), getRequestedFor(WireMock.urlEqualTo("/books")));
    }

    @Test
    public void whenGetBooks_thenTheCorrectBooksShouldBeReturned() {
        assertTrue(booksClient.getBooks()
          .containsAll(asList(
            new Book("Dune", "Frank Herbert"),
            new Book("Foundation", "Isaac Asimov"))));
    }
}

6. Интеграция Eureka

До сих пор мы видели, как для тестирования клиента, использующего ленту для балансировки нагрузки. Но что, если в нашей установке используется система обнаружения служб, такая как Eureka. Мы должны написать интеграционный тест, который удостоверится, что наш BooksClient работает должным образом и в таком контексте.

Для этого мы запустим сервер Eureka в качестве тестового контейнера. Затем мы запускаем и регистрируем фиктивный книжный сервис в нашем контейнере Eureka. И, наконец, как только эта установка будет запущена, мы можем запустить наш тест на ней.

Прежде чем двигаться дальше, давайте добавим зависимости Testcontainers и Netflix Eureka Client Maven:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <scope>test</scope>
</dependency>

6.1. Настройка TestContainer

Давайте создадим конфигурацию TestContainer, которая будет запускать наш сервер Eureka:

public class EurekaContainerConfig {

    public static class Initializer implements ApplicationContextInitializer {

        public static GenericContainer eurekaServer = 
          new GenericContainer("springcloud/eureka").withExposedPorts(8761);

        @Override
        public void initialize(@NotNull ConfigurableApplicationContext configurableApplicationContext) {

            Startables.deepStart(Stream.of(eurekaServer)).join();

            TestPropertyValues
              .of("eureka.client.serviceUrl.defaultZone=http://localhost:" 
                + eurekaServer.getFirstMappedPort().toString() 
                + "/eureka")
              .applyTo(configurableApplicationContext);
        }
    }
}

«

«Как мы видим, приведенный выше инициализатор запускает контейнер. Затем он открывает порт 8761, который прослушивает сервер Eureka.

И, наконец, после запуска службы Eureka нам нужно обновить свойство eureka.client.serviceUrl.defaultZone. Это определяет адрес сервера Eureka, используемого для обнаружения службы.

6.2. Зарегистрируйте фиктивный сервер

@Configuration
@RestController
@ActiveProfiles("eureka-test")
public class MockBookServiceConfig {

    @RequestMapping("/books")
    public List getBooks() {
        return Collections.singletonList(new Book("Hitchhiker's Guide to the Galaxy", "Douglas Adams"));
    }
}

Теперь, когда наш сервер Eureka запущен и работает, нам нужно зарегистрировать фиктивный сервис книг. Мы делаем это, просто создавая RestController:

Все, что нам нужно сделать сейчас, чтобы зарегистрировать этот контроллер, это убедиться, что свойство spring.application.name в нашем application-eureka-test.yml books-service, то же, что и имя службы, используемое в интерфейсе BooksClient.

Примечание. Теперь, когда библиотека netflix-eureka-client находится в нашем списке зависимостей, Eureka будет использоваться по умолчанию для обнаружения служб. Итак, если мы хотим, чтобы наши предыдущие тесты, которые не используют Eureka, продолжали проходить, нам нужно вручную установить для eureka.client.enabled значение false. Таким образом, даже если библиотека находится на пути, BooksClient не будет пытаться использовать Eureka для поиска службы, а вместо этого будет использовать конфигурацию ленты.

6.3. Интеграционный тест

@ActiveProfiles("eureka-test")
@EnableConfigurationProperties
@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = Application.class, webEnvironment =  SpringBootTest.WebEnvironment.RANDOM_PORT)
@ContextConfiguration(classes = { MockBookServiceConfig.class }, 
  initializers = { EurekaContainerConfig.Initializer.class })
class ServiceDiscoveryBooksClientIntegrationTest {

    @Autowired
    private BooksClient booksClient;

    @Lazy
    @Autowired
    private EurekaClient eurekaClient;

    @BeforeEach
    void setUp() {
        await().atMost(60, SECONDS).until(() -> eurekaClient.getApplications().size() > 0);
    }

    @Test
    public void whenGetBooks_thenTheCorrectBooksAreReturned() {
        List books = booksClient.getBooks();

        assertEquals(1, books.size());
        assertEquals(
          new Book("Hitchhiker's guide to the galaxy", "Douglas Adams"), 
          books.stream().findFirst().get());
    }

}

Еще раз, у нас есть все необходимые части конфигурации, поэтому давайте соберем их вместе в тесте:

В этом тесте происходит несколько вещей. Давайте посмотрим на них один за другим.

Во-первых, инициализатор контекста внутри EurekaContainerConfig запускает службу Eureka.

Затем SpringBootTest запускает приложение службы книг, которое предоставляет контроллер, определенный в MockBookServiceConfig.

Поскольку запуск контейнера Eureka и веб-приложения может занять несколько секунд, нам нужно дождаться регистрации службы книг. Это происходит в настройках теста.

И, наконец, метод тестов проверяет правильность работы BooksClient в сочетании с конфигурацией Eureka.

7. Заключение

В этой статье мы рассмотрели различные способы написания интеграционных тестов для клиента Spring Cloud Feign. Мы начали с базового клиента, который протестировали с помощью WireMock. После этого мы перешли к добавлению балансировки нагрузки с помощью ленты. Мы написали интеграционный тест и убедились, что наш Feign Client правильно работает с балансировкой нагрузки на стороне клиента, предоставляемой Ribbon. И, наконец, мы добавили в этот набор сервис обнаружения Eureka. И снова мы убедились, что наш клиент по-прежнему работает так, как ожидалось.