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

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

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

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

  • Установка и настройка необходимых зависимых служб, таких как базы данных, брокеры сообщений и т. д.

  • Настройка веб-сервера или сервера приложений

  • Создание и развертывание артефакта (jar, war, нативный исполняемый файл и т. д.) на сервере.

  • Наконец, запуск интеграционных тестов

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

1. Почему важно тестирование с реальными зависимостями

Тесты должны позволять разработчикам проверять поведение приложения с помощью быстрых циклов обратной связи во время фактической разработки.

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

Рассмотрим распространенный сценарий использования баз данных в памяти, таких как H2, для тестирования при использовании Postgres или SQL Server в производственной среде. Есть несколько причин, почему это плохая практика.

Проблемы совместимости

Любое нетривиальное приложение будет использовать некоторые специфичные для базы данных функции, которые могут не поддерживаться базами данных в памяти. Например, распространенный способ применения пагинации является использование LIMIT и OFFSET.

SELECT id, name FROM employee ORDER BY name LIMIT 25 OFFSET 50

Представьте, что вы используете базу данных H2 для тестирования и MS SQL Server в качестве производственной базы данных. Когда вы тестируете с помощью H2, тесты пройдут, создавая ошибочное впечатление, что ваш код работает нормально, но в производственной среде они провалятся, потому что MS SQL Server не поддерживает синтаксис LIMIT … OFFSET.

Базы данных в памяти могут не поддерживать все функции вашей производственной базы данных

Ваше приложение может использовать специфические для производителя базы данных расширенные возможности, такие как функции преобразования XML/JSON, WINDOW-функции и общие табличные выражения (CTE), которые могут не полностью поддерживаться базами данных в памяти. В таких случаях тестирование с использованием баз данных в памяти становится невозможным.

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

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

Поэтому настоятельно рекомендуется писать тесты с использованием реальных зависимостей как можно чаще и использовать имитаторы только в редких случаях.

2. Тестирование с использованием реальных зависимостей с помощью Testcontainers

Testcontainers — это библиотека тестирования, которая позволяет писать тесты с использованием реальных зависимостей с помощью одноразовых контейнеров Docker. Она предоставляет программируемый API для создания необходимых зависимых сервисов в виде контейнеров Docker, чтобы вы могли писать тесты, используя реальные сервисы вместо макетов. Таким образом, независимо от того, пишете ли вы модульные тесты, тесты API или сквозные тесты, вы можете писать тесты с использованием реальных зависимостей с помощью одной и той же модели программирования.

Библиотеки Testcontainers доступны для следующих языков и хорошо интегрируются с большинством фреймворков и библиотек тестирования:

  • Java

  • Go

  • Node.js 

  • .NET

  • Python

  • Rust

3. Исследование примера

Давайте посмотрим, как Testcontainers можно использовать для тестирования различных частей приложения, и все они выглядят как «модульные тесты с реальными зависимостями». 

В этой статье мы будем использовать пример кода из приложения SpringBoot, реализующего типичный API-сервис, который используется через веб-приложение и использует Postgres для хранения данных. 

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

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

3.1. Тестирование репозиториев данных

Допустим, у нас есть следующий репозиторий Spring Data JPA с одним пользовательским методом.

public interface TodoRepository extends PagingAndSortingRepository<Todo, String> {
   @Query("select t from Todo t where t.completed is false")
   Iterable<Todo> getPendingTodos();
}

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

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

INSERT INTO todos (id, title)
VALUES ('1', 'Learn Modern Integration Testing with Testcontainers')
ON CONFLICT do nothing;

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

Мы можем написать модульные тесты для TodoRepository, используя аннотацию @DataJpaTest для срезовых тестов SpringBoot, создав контейнер Postgres с помощью Testcontainers следующим образом:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class TodoRepositoryTest {
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14-alpine");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    TodoRepository repository;

    @BeforeEach
    void setUp() {
        repository.deleteAll();
        repository.save(new Todo(null, "Todo Item 1", true, 1));
        repository.save(new Todo(null, "Todo Item 2", false, 2));
        repository.save(new Todo(null, "Todo Item 3", false, 3));
    }

    @Test
    void shouldGetPendingTodos() {
        assertThat(repository.getPendingTodos()).hasSize(2);
    }
}

Зависимость базы данных Postgres обеспечивается с помощью Testcontainers JUnit5 Extension, и тест взаимодействует с реальной базой данных Postgres. Для получения дополнительной информации об использовании управления жизненным циклом контейнеров смотрите раздел Интеграция Testcontainers и JUnit.

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

Для тестирования баз данных Testcontainers предоставляют специальную поддержку JDBC URL, которая облегчает работу с базами данных SQL.

3.2. Тестирование конечных точек REST API

Мы можем протестировать конечные точки API, загружая приложение вместе с необходимыми зависимостями, такими как база данных, предоставленная через Testcontainers. Модель программирования для тестирования конечных точек REST API такая же, как и для модульного тестирования репозитория.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
public class TodoControllerTests {
    @LocalServerPort
    private Integer port;
    
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14-alpine");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    TodoRepository todoRepository;

    @BeforeEach
    void setUp() {
        todoRepository.deleteAll();
        RestAssured.baseURI = "http://localhost:" + port;
    }

    @Test
    void shouldGetAllTodos() {
        List<Todo> todos = List.of(
                new Todo(null, "Todo Item 1", false, 1),
                new Todo(null, "Todo Item 2", false, 2)
        );
        todoRepository.saveAll(todos);

        given()
                .contentType(ContentType.JSON)
                .when()
                .get("/todos")
                .then()
                .statusCode(200)
                .body(".", hasSize(2));
    }
}

Мы загрузили приложение с помощью аннотации @SpringBootTest и использовали RestAssured для выполнения вызовов API и проверки ответа. Это даст нам больше уверенности в наших тестах, поскольку в них не задействованы макеты, и позволит разработчикам выполнять любой внутренний рефакторинг кода, не нарушая API-контакта.

3.3. Сквозное тестирование с использованием Selenium и Testcontainers

Selenium — популярный инструмент автоматизации браузера для проведения сквозного тестирования. Testcontainers предоставляет модуль Selenium, упрощающий выполнение тестов на основе Selenium в контейнере Docker.

@Testcontainers
public class SeleniumE2ETests {
   @Container
   static BrowserWebDriverContainer<?> chrome = new BrowserWebDriverContainer<>().withCapabilities(new ChromeOptions());
 
   static RemoteWebDriver driver;
   
   @BeforeAll
   static void beforeAll() {
       driver = new RemoteWebDriver(chrome.getSeleniumAddress(), new ChromeOptions());
   }
 
   @AfterAll
   static void afterAll() {
       driver.quit();
   }
 
   @Test
   void testViewHomePage() {
      String baseUrl = "https://myapp.com";
      driver.get(baseUrl);
      assertThat(driver.getTitle()).isEqualTo("App Title");
   }
}

Мы можем запускать Selenium-тесты с помощью той же модели программирования, используя WebDriver, предоставленный Testcontainers. Testcontainers даже позволяют легко записывать видео выполнения тестов без необходимости выполнять сложную настройку конфигурации.

Для справки вы можете взглянуть на проект Testcontainers Java SpringBoot QuickStart.

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

Мы рассмотрели различные типы тестов, которые разработчики используют в своих приложениях: уровень доступа к данным, тесты API и даже сквозные тесты. Также было рассмотрено использование библиотек Testcontainers для упрощения настройки для запуска этих тестов с реальными зависимостями, такими как реальная версия базы данных, которую вы будете использовать в производственной среде. 

Testcontainers доступна на нескольких популярных языках программирования, таких как Java, Go, .NET и Python, и предоставляет вам идиоматический подход к преобразованию ваших тестов с реальными зависимостями в модульные тесты, которые знают и любят разработчики.

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

Более того, Testcontainers позволяет писать тесты с использованием реальных зависимостей без необходимости использования макетов, что придает больше уверенности вашему набору тестов. Итак, если вы сторонник практичного подхода, ознакомьтесь с Testcontainers Java SpringBoot QuickStart, содержащим все типы тестов, которые мы рассмотрели в этой статье, сразу же доступные для запуска!

Комментарии (2)


  1. alexdoublesmile
    21.11.2022 15:55
    +1

    Спасибо за статью, только не совсем понятна пара моментов:

    1. При использовании аннотации TestContainers будет создаваться новый docker-container с базой для каждого тестового класса, зачем нам это? Не разумнее ли запустить одну базу для всех тестов (например из общего тестового класса осуществить BeforeAll container.start())?

    2. Почему не наполнять тестовую БД данными из какого-нибудь sql-скрипта, а не осуществлять BeforeEach repo.save()? Ведь это во-первых, лишнее взаимодействие с логикой, которое никак не проверяется, я уже молчу что в каждом классе прописать надо не забыть этот сетап..

    3. Зачем вообще осуществлять repo.deleteAll, если по умолчанию в Spring Test все транзакции и так откатываются?

    4. Зачем регистрировать username и password, если в тестовой БД будут и так значения по умолчанию "test", которые просто можно прописать в пропертях?

    5. Для тестирования API не удобнее ли пользоваться гораздо более подходящим mockMvc механизмом?


    1. AstarothAst
      22.11.2022 11:48

      При использовании аннотации TestContainers будет создаваться новый docker-container с базой для каждого тестового класса, зачем нам это? Не разумнее ли запустить одну базу для всех тестов (например из общего тестового класса осуществить BeforeAll container.start())?

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

      Почему не наполнять тестовую БД данными из какого-нибудь sql-скрипта, а не осуществлять BeforeEach repo.save()? Ведь это во-первых, лишнее взаимодействие с логикой, которое никак не проверяется, я уже молчу что в каждом классе прописать надо не забыть этот сетап..

      Посмотрим с точки зрения человека, который вынужден чинить покрасневший тест, который написан не им. Он смотрит в тест, и видит конкретный сетап над которым производятся тестовые действия. Если этот сетап отдельный для каждого теста, то вообще хорошо — с пределах теста ясно что делается, над какими данными делается, и какой ожидается результат. А вот «общее заполнение чем-то сторонним» — это самый большой кошмар, какой только может быть. Один тест починил — и сломал еще пяток, потому что все они не явно связаны через данные.