Статьи

Изолирование интеграционных тестов и макетных зависимостей с помощью Spring Boot

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

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

мое заявление

Предположим теперь, что мы хотели бы, чтобы наш интеграционный тест подтвердил поведение, которое включает в себя только вызовы REST API, но не вызовы базы данных или очереди сообщений. Чтобы привести конкретный пример, давайте предположим, что мы хотим проверить, правильно ли настроен наш клиент REST на тайм-аут через 3 секунды.

Все, что нам для этого нужно, — это маленький Controller который будет издеваться над REST API, ожидая, прежде чем вернуть ответ клиенту REST. Время ожидания будет передано в качестве параметра в строке запроса.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
@Profile("restTemplateTimeout")
@RestController
@RequestMapping(value = "/test")
public class DelayedWebServerController {
 
  @RequestMapping(value = "/delayRestTemplate", method = GET)
  public String answerWithDelay(@RequestParam Long waitTimeMs) {
 
    if (waitTimeMs > 0) {
      try {
        Thread.sleep(waitTimeMs);
      } catch (InterruptedException e) {
        throw new RuntimeException(e);
      }
    }
 
    return "Delayed Result";
  }
 
}

Для @Profile используется аннотация @Profile ? Если мы добавим этот контроллер в наш стандартный контекст приложения, это будет иметь несколько недостатков

  • Тест будет медленным: нам нужно запустить только один контроллер, а не все
  • Наш контроллер будет выбран Spring и введен во все остальные интеграционные тесты, замедляя каждый интеграционный тест и, возможно, наступая на трудности другого теста

DelayedWebServerController альтернативой было бы раскрутить минимальное приложение Spring Boot, выставив только наш DelayedWebServerController . Мы также скажем Spring Boot сканировать только те пакеты, которые нам интересны, и исключить автоконфигурацию, связанную с постоянством, поскольку она не нужна для ускорения контроллера. Это делается в классе Configuration как этот:

1
2
3
4
5
6
7
8
@Profile("restTemplateTimeout")
@Configuration
@EnableAutoConfiguration(
    exclude = {DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class})
@ComponentScan(basePackages = "my.application.resttemplate.timeout")
public class DelayedWebServerConfiguration {
    //The class is empty and only used to support the annotations
}

Конфигурация контекста Spring может привести к путанице, давайте посмотрим на аннотации одну за другой:

  • @Profile : сообщает Spring, что эту конфигурацию следует использовать только тогда, когда restTemplateTimeout профиль restTemplateTimeout . Далее в этой статье мы увидим, как мы включаем этот профиль для конкретного интеграционного теста. Именно эта аннотация предотвращает выбор конфигурации другими несвязанными интеграционными тестами. Обратите внимание, что наш DelayedWebServerController идентично аннотирован.
  • @Configuration : стандартная аннотация, @Configuration Spring, что это класс конфигурации контекста.
  • @EnableAutoConfiguration : здесь мы отключаем некоторые «магические» функции Spring Boot, которые нам не нужны для нашего конкретного теста
  • @ComponentScan : мы @ComponentScan запуск приложения Spring Boot, сканируя только один пакет вместо всего проекта. Любой Spring-аннотированный класс, находящийся вне этого пакета, не будет выбран Spring.

Вот как выглядит интеграционный тест:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@RunWith(SpringJUnit4ClassRunner.class)
@WebIntegrationTest("server.port:0")
@SpringApplicationConfiguration(classes = DelayedWebServerConfiguration.class)
@ActiveProfiles("restTemplateTimeout")
public class RestTemplateShould {
 
  @Rule
  public ExpectedException thrown = none();
 
  @Value("${local.server.port}")
  private int port;
 
  @Autowired
  private RestTemplate restTemplate;
 
  @Test
  public void throw_timeout_if_response_lasts_more_than_two_seconds() {
    thrown.expect(ResourceAccessException.class);
    thrown.expectCause(instanceOf(SocketTimeoutException.class));
 
    callEndpointWithDelay(3000);
  }
 
  @Test
  public void do_not_throw_timeout_if_response_lasts_less_than_two_seconds() {
    callEndpointWithDelay(10);
  }
 
  private void callEndpointWithDelay(long delayMs) {
    restTemplate.getForObject(
        "http://localhost:" + port + "/test/delayRestTemplate?waitTimeMs=" + delayMs, String.class);
  }
}

Конечно, все эти классы хранятся в нашей папке исходного кода теста (обычно это src/test/java ), так как они не требуются для производства.

Давайте еще раз посмотрим на аннотации:

  • @RunWith : в тесте будет использоваться раннер Spring Junit, который позаботится о создании для нас контекста Spring.
  • @WebIntegrationTest : сообщает Spring, что это интеграционный тест, запускающий веб-приложение, иначе Spring по умолчанию не будет запускать HTTP-сервер в тестовом режиме. Мы также устанавливаем для server.port значение 0 чтобы Spring Boot выбирал случайный порт для HTTP-сервера для прослушивания. Это позволяет запускать несколько тестов параллельно или запускать другую версию приложения в фоновом режиме.
  • @SpringApplicationConfiguration : мы @SpringApplicationConfiguration Spring, где он найдет класс DelayedWebServerConfiguration мы создали ранее.
  • @ActiveProfiles : включает профиль restTemplateTimeout , в противном случае Controller и Configuration будут отфильтрованы.

Теперь у нас есть интеграционный тест с ограниченным набором зависимостей вместо всего приложения. Что если мы хотим пойти дальше и добавить в игру насмешки? Это может потребоваться, если у зависимости нет среды разработки или она слишком сложна для вызова с рабочей станции разработчика. В этом случае мы можем добавить эти макеты в класс Configuration и они будут внедрены в контекст Spring теста.

Вот пример Configuration где мы внедряем пользовательскую службу CustomerService , смоделированную Mockito, вместо стандартной:

01
02
03
04
05
06
07
08
09
10
11
12
@Profile("validationTests")
@Configuration
@EnableAutoConfiguration(
    exclude = {DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class})
@ComponentScan(basePackages = {"my.application.controller",
    "my.application.actions"})
public class ValidationEndToEndConfiguration {
    @Bean
  public CustomerService customerService() {
    return Mockito.mock(CustomerService.class);
  }
}

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