Testcontainers: Docker w testach integracyjnych


Testcontainers, czyli biblioteka, która wykorzystuje Docker w testach integracyjnych to coraz bardziej popularne rozwiązanie w branży IT. Konteneryzacja usług stała się powszechną praktyką. Kontenery są wykorzystywane do uruchamiania aplikacji w izolowanych środowiskach. Dotyczy to zarówno środowisk lokalnych, jak i produkcyjnych. Konteneryzacja ułatwia replikowanie środowiska aplikacji, co jest kluczowe dla zapewnienia spójności między różnymi etapami rozwoju i wdrożenia. Okazuje się, że Docker za sprawą Testcontainers może być również skutecznie narzędziem w testach integracyjnych, a czasami nawet game changerem!

JavaSenior Testcontainers: Docker w testach integracyjnych

Kod testować trzeba. Istnieje wiele rodzajów testów, które działają na różnych poziomach. Jako programiści skupiamy się na pisaniu testów jednostkowych, testujących poszczególne funkcjach bez konieczności uruchamiania całej aplikacji. Niektóre podejśca typu architektura portów i adapterów, czy clean architecture, wspierają testowanie w sposób jednostkowy. Niemniej jednak konieczne jest także posiadanie testów odzwierciedlających realne scenariusze i sprawdzających, czy aplikacja prawidłowo współdziała z zewnętrznymi usługami. Czyli testów integracyjnych, które wymagają uruchomienia kontekstu aplikacji.

Często w wymaganiach też mamy testy akceptacyjne, czyli w praktyce testy integracyjne, które mają za zadanie sprawdzić konkretne scenariusze. W aplikacjach backendowych może to oznaczać np. wywołanie REST API w celu sprawdzenie poprawności odpowiedzi i weryfikacji czy inne usługi otrzymały wymagane informacje. Tutaj pojawia się wyzwanie związane z integracją wielu zewnętrznych usług, takich jak bazy danych, message broker itp.

Jeśli wolisz formę wideo, to zapraszam!

Zewnętrzne usługi w testach integracyjnych

Trudno wyobrazić sobie aplikację funkcjonującą bez wsparcia dodatkowych narzędzi, takich jak np. baza danych. Często jednak korzystamy z więcej niż jednej takiej usługi. Scenariusze testowe powinny odzwierciedlać rzeczywiste przypadki użycia, więc też powinny korzystać z zewnętrznych usług. Oto kilka propozycji dotyczących wykorzystania zewnętrznych usług w testach na przykładzie bazy danych:

Mockowanie

Mock

Możemy symulować odpowiedzi zewnętrznych usług. Czyli jeśli poproszę o wyciągnięcie z bazy danych użytkownika o Id 5, to dostanę określone dane. Czy to testuje realny scenariusz? Nie. Jeśli zasymulujemy kilka usług, istnieje duże prawdopodobieństwo, że na realnym środowisku aplikacja będzie działać zupełnie inaczej niż w testach.

Korzystanie z alternatywnych usług w testach

Baza w pamięci

Dochodzimy do wniosku, że chcemy rzeczywiście coś włożyć do pudełka i to wyjąć, a nie imitować wkładanie i wyjmowanie. Korzystanie z innych usług, które mogą „pomóc” przy testach, oznacza, że jeśli mamy bazę PostgreSQL, to w testach wykorzystamy bazę w pamięci H2. Czy to odzwierciedla realny scenariusz? Niestety nie. PostgreSQL ma inny dialekt niż H2, posiada swoje funkcje, a do tego nie ograniczamy się tylko do bazy jako zewnętrznej usługi. Na przykład, jeśli w projekcie korzystamy z Apache Kafka, to w tym podejściu nie odwzorowujemy realnego scenariusza.

Lokalne usługi

Baza lokalna

Dochodzimy do wniosku, że chcemy korzystać z tej samej usługi w testach, co na środowisku wdrożeniowym. Rozsądnym wydaje się postawienie sobie lokalnie takich usług i łączenie się do nich w testach. W dobie Dockera postawienie lokalnie usług to nie problem. Jednak brak automatyzacji to już problem. Jeśli mamy lokalnie postawione takie usługi, to musimy sami zadbać o to, by je wyczyścić przed każdymi testami. Jeśli ich nie wyczyścimy, testy mogą dać inny wynik dla różnych wywołań. Zaczną pojawiać się problemy, że działa to różnie u różnych osób, bo mają inne wersje tych usług. Dodatkowo trzeba gdzieś postawić i utrzymywać te usługi, by na pipelinach na Git testy mogły przechodzić. Ciągłe czyszczenie i ręczne dbanie o te usługi może sprawiać wiele problemów.

Usługi wbudowane (Embedded)

Baza Embedded

Dochodzimy do wniosku, że chcemy korzystać z tej samej usługi w testach co na środowisku wdrożeniowym i żeby to było zautomatyzowane. Tutaj z pomocą przychodzą nam biblioteki z usługami typu embedded np. dla PostgreSQL dla Apache Kafka. Biblioteki te starają się naśladować usługi, które imitują. Pozwalają nam odwzorować realne scenariusze z wdrożeń w testach.
Takie „usługi” są tworzone podczas inicjalizacji kontekstu aplikacji. To powoduje, że za każdym razem, gdy w testach uruchamiamy nowy kontekst aplikacji, czas testowania wydłuża się o czas potrzebny na postawienie tych usług. Niestety, w przypadku tworzenia wielu kontekstów w testach, może to być kłopotliwe. Dodatkowo biblioteki wspierające podejście embedded są dostępne tylko dla nielicznych usług.

Testcontainers

Testcontainers, korzystanie z Docker w testach integracyjnych

Dochodzimy do wniosku, że chcemy używać tej samej usługi w testach, co na środowisku wdrożeniowym. Chcemy, aby było to zautomatyzowane, szybkie przy przeładowaniu kontekstów i dostępne dla wielu usług. Tu właśnie wjeżdża na białym, podkutym koniu Testcontainers.

Testcontainers pozwala nam korzystać z narzędzia Docker w zautomatyzowany sposób w testach integracyjnych. Przed testami automatycznie tworzone są kontenery Dockerowe z obrazów zadeklarowanych w kodzie. Po testach kontenery są automatycznie zatrzymywane i usuwane. Przy przeładowywaniu kontekstów, kontenery Dockerowe nie są tworzone na nowo. Są wykorzystywane istniejące, co przyspiesza testy dla wielu kontekstów.

Jak wiadomo, w kontenerze Dockera możemy postawić prawie wszystko, a większość usług ma swoje oficjalne obrazy Dockerowe. Dzięki temu w testach możemy korzystać z tych samych zewnętrznych usług w tych samych wersjach co na środowiskach wdrożeniowych.

Jeśli chcemy przetestować kompatybilność naszego kodu z inną wersją usługi, wystarczy zmienić wersję tej usługi w testach. Jeśli chcemy uruchomić naszą usługę w kontenerze Dockera podczas testów, by uniknąć mockowania żądań wysyłanych do niej, możemy to zrobić.

Wykorzystanie Testcontainers, czyli Docker w testach integracyjnych

Wiemy już, co daje Testcontainers. Zobaczmy, jak możemy wykorzystać tę bibliotekę w naszej aplikacji. Oczywiście na końcu wpisu znajdziesz link do Git, gdzie będą dostępne przykłady użycia tej biblioteki w kodziku.

Użycie adnotacji @Testcontainers

@Testcontainers
@ActiveProfiles({"test"})
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class HorseRestIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16.1")
            .withDatabaseName("horses")
            .withUsername("username")
            .withPassword("kon")
            .withExposedPorts(5432);

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

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void test() {
        //some test
    }

    private String getBaseUrl() {
        return "http://localhost:" + port + "/horses";
    }
}

Adnotacja @Testcontainers w JUnit Jupiter automatycznie uruchamia i zatrzymuje kontenery Docker używane w testach. Rozszerzenie to znajduje pola oznaczone jako @Container i zarządza ich cyklem życia.
Jak widzimy, w tym przypadku za pomocą adnotacji @Container poprosiliśmy o stworzenie kontenera zbudowanego z obrazu postgres:16.1.

Nic trudnego, a mamy bazę danych w testach, automatycznie zarządzaną przez Testcontainers. Jednaką wadę ma to podejście? Jeśli zastosujemy je dla każdej klasy testowej, to dla każdej klasy testowej powstanie nowy kontener z bazą danych. A to, jak wiadomo, będzie czasochłonne przy uruchamianiu wszystkich testów.

Użycie dziedziczenia

class HorseRestIntegrationTest extends IntegrationTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void test() {
        //some test
    }

    private String getBaseUrl() {
        return "http://localhost:" + port + "/horses";
    }

@ActiveProfiles({"test"})
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTest {

    static final PostgreSQLContainer<?> POSTGRESQL_CONTAINER = new PostgreSQLContainer<>("postgres:16.1")
            .withDatabaseName("horses")
            .withUsername("username")
            .withPassword("kon")
            .withExposedPorts(5432);

    static {
        POSTGRESQL_CONTAINER.start();

        System.setProperty("spring.datasource.url", POSTGRESQL_CONTAINER.getJdbcUrl());
        System.setProperty("spring.datasource.username", POSTGRESQL_CONTAINER.getUsername());
        System.setProperty("spring.datasource.password", POSTGRESQL_CONTAINER.getPassword());
    }

W tym podejściu nasze testy dziedziczą po klasie IntegrationTest, gdzie mamy „magię” związaną z Testcontainers. Oznacza to, że dla wszystkich testów dziedziczących po tej klasie, kontener z bazą danych zostanie utworzony tylko raz. Co więcej, nie korzystamy z adnotacji @Testcontainers, która, warto wspomnieć, nie wspiera równoległego wykonania testów i działa dobrze tylko przy wykonwaniu ich w sposób sekwencyjny.

Jednakże w tym podejściu nadal istnieje pewien minus, a mianowicie dziedziczenie, które nie jest najpiększniejszym rozwiązaniem.

Użycie własnej adnotacji

@IntegrationTest
class HorseRestIntegrationTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void test() {
        //some test
    }

    private String getBaseUrl() {
        return "http://localhost:" + port + "/horses";
    }
}

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@ActiveProfiles({"test"})
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ContextConfiguration(initializers = TestcontainersInitializer.class)
@interface IntegrationTest {
}

class TestcontainersInitializer implements
        ApplicationContextInitializer<ConfigurableApplicationContext> {

    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16.1")
                    .withDatabaseName("horses")
                    .withUsername("username")
                    .withPassword("kon")
                    .withExposedPorts(5432);

    static {
        postgres.start();
    }

    @Override
    public void initialize(ConfigurableApplicationContext ctx) {
        TestPropertyValues.of(
                "spring.datasource.url=" + postgres.getJdbcUrl(),
                "spring.datasource.username=" + postgres.getUsername(),
                "spring.datasource.password=" + postgres.getPassword()
        ).applyTo(ctx.getEnvironment());
    }
}

To rozwiązanie przypomina poprzednie, jednak zrezygnowaliśmy z dziedziczenia na rzecz deklaratywnego podejścia, które Spring wspiera, czyli zastosowaliśmy adnotacje. Dodaliśmy własną adnotację @IntegrationTest, która ma ustawioną konfigurację kontekstu wykorzystując inicjalizera TestcontainersInitializer, wykonującego „magię” związaną z Testcontainers.

Podsumowanie

Oczywiście znajdziesz więcej możliwości konfiguracji Testcontainers w projekcie niż te, które przedstawiłem. Mam jednak nadzieję, że te przedstawionę dadzą ci ogólny zarys tego rozwiązania. Z ciekawych opcji jest jeszcze skorzystanie z pliku Docker Compose do tworzenia wielu kontenerów i wskazanie ścieżki do takiego pliku w kodzie. Co więcej, jeśli tworzymy wiele kontenerów na potrzeby naszy testów, warto rozważyć zrównoleglenie ich tworzenia, czyli zamiast:

  static {
        postgres.start();
    }

zrobić:

    static {
        Startables.deepStart(postgres, kafka).join();
    }

Podsumowując Testcontainers, automatyzują proces stawiania zewnętrznych usług na potrzeby testowania. Dzięki stosowaniu powszechnej techniki, jaką jest konteneryzacja, możliwe jest zautomatyzowanie procesu uruchamiania niemal każdej usługi na czas testów. Jak wiadomo, to co możemy, powinniśmy automatyzować, aby uprościć i przyspieszyć swoją pracę. Oczywiście, nie zawsze mamy potrzebę skorzystać z tej biblioteki, ale na pewno warto mieć ją swoim w tool boxie!

Bądź na bieżąco!

Zostaw swój e-mail, a raz na jakiś czas wyśle Ci info o nowych postach na blogu. Tylko wartościowe e-mail!

Logo github

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Zacznij wpisywać wyszukiwane hasło powyżej i naciśnij Enter, aby wyszukać. Naciśnij ESC, aby anulować.

Do góry