Wyślij zapytanie Dołącz do Sii

Spośród wielu narzędzi, które są już dostępne i tych, ciągle pojawiających się w dynamicznym świecie IT, Testcontainers zwróciło moją szczególną uwagę w minionym już 2022 roku.

Chciałbym przedstawić Ci, jakie wyzwania i problemy możemy rozwiązać, dodając do swojego warsztatu tę opcję. Nie będzie to artykuł wyłącznie zachwalający narzędzie, ponieważ – poza jego oczywistymi zaletami – chcę również zwrócić uwagę na jego wady. Uprzedzę jednak mój werdykt końcowy – jestem zwolennikiem Testcontainers i już na dobre ulokowałem je w moich frameworkach testowych.

Docker dla testera manualnego

Jeśli miałeś do czynienia z bazami danych, to prawdopodobnie przypominasz sobie własny wysiłek włożony w przygotowanie „od zera” środowisko pracy z DB. Nawet jeśli to tylko lokalne środowisko, musisz mieć drivera i klienta DB oraz właściwie wszystko skonfigurować, tworząc poprawny connection string. Szczególnie ważne jest to w przypadku baz danych Oracle.

Znacznie sprawniej można to zrobić przy użyciu Dockera. Centralne repozytorium obrazów Dockera zawiera ciągle rozwijaną bazę obrazów, a wśród nich obrazy bazy danych Oracle. Producent prezentuje swoje oficjalne obrazy, ale też inni developerzy udostępniają własne wersje. Słowem – mamy wszystkie wersje niemal dowolnego narzędzia w jednym miejscu.

Wybieram wersję Oracle np. 9, reprezentowaną dockerowym tagiem i mogę zastosować komendę ściągającą obraz na dysk lokalny:

Komenda ściągająca obraz
Ryc. 2 Komenda ściągająca obraz

Korzyść jest znaczna, bo to, co pojawi się na naszym dysku w postaci obrazu [image] dockerowego, zawiera już wszystko, czego potrzebujemy, aby korzystać z bazy danych. Kolejna komenda [docker run], która uruchomi kontener, sprawi, że baza danych będzie już dostępna do pracy. 

W opisie danego obrazu autor najczęściej podaje gotowe komendy w różnych wersjach. Wystarczy tylko uruchomić je lokalnie w konsoli Windows.

Zalety i wady rozwiązania

Nie twierdzę, że obsługa Dockera jest trywialna, ale stawiam tezę, że jak ktoś raz spróbuje, to już nie wróci do wymagających instalacji i żmudnej konfiguracji wersji desktopowych.

Istnieje też znaczna oszczędność przestrzeni dyskowej. Instalacja natywnego narzędzia prawie zawsze zajmuje wielokrotnie więcej miejsca na dysku niż obraz dockerowy.

Do minusów i niedogodności należy jednokrotna instalacja samego Dockera. Jest to narzędzie konsolowe, ale w systemie Windows można korzystać w Docker Desktop, które umożliwia przejrzyste zarządzanie obrazami i kontenerami Dockera.

Trzeba też przyznać, że musimy opanować konsolowe komendy, które pozwolą swobodnie poruszać się w Dockerze. Jest to jednak głównie wysiłek czasowy, bo z pełnym przekonaniem twierdzę, że darmowe źródła wiedzy na ten temat w sieci są wystarczające i nie ma potrzeby inwestować w płatne szkolenia w przypadku testera manualnego.

Docker dla testera automatyzującego i programisty

Programiści już docenili Dockera i czerpią z niego pełnymi garściami. Kontener z dowolną aplikacją uruchamiamy błyskawicznie (kwestia milisekund), korzystamy z niej i zamykamy równie szybko. Kiedy zapis stanu aplikacji nie ma dla nas znaczenia, to sprawa jest zupełnie bezproblemowa. Nie ma również kłopotu, gdy potrzebna jest komunikacja między dyskiem lokalnym a kontenerem np. poprzez wysłanie pliku do kontenera lub odebranie pliku z kontenera na dysk.

Możliwe jest także uruchomienie wielu kontenerów jednocześnie i połączenie ich w jedną sieć – Network.

Testcontainers

Pojawiło się jednak wyzwanie dla testowania aplikacji, które korzystają z kontenerów, zwłaszcza tych opartych na architekturze mikroserwisowej. Testy integracyjne to faza, podczas której chcemy zbadać wspólne działanie różnych komponentów systemu np. API Restowe i baza danych czy też mikroserwis produkujący wiadomości na Kafkę i konsumujący te wiadomości wraz z brokerem kafkowym.

Przygotowując automatyczne testy integracyjne dla takiej architektury, niezbędne jest wykorzystanie kontenera bazy danych np. PostgreSQL czy kolejki np. Kafka. Kontener ten musi być uruchamiany w kodzie.

Tutaj właśnie na scenę wchodzi Testcontainers. Jest to biblioteka, która dostarcza API do obsługi kontenerów dockerowych w kodzie. Narzędzie obsługuje następujące języki programowania:

  • Java,
  • Go,
  • .NET,
  • Python,
  • Node.js,
  • Rust.

Biblioteka dostarcza automatyczne zarządzanie uruchamianiem i zamykanie kontenerów zaraz po zakończonym teście. To niezwykle „estetyczne” podejście. Środowisko testowanej aplikacji jest budowane stosunkowo szybko (ok. 2-3 dodatkowe sekundy) automatycznie zaraz przed testem. Następnie wykonywana jest logika naszych scenariuszy i zwracany rezultat, a na koniec kontener znika bezpowrotnie.

Ta „efemeryczność” dla testera automatyzującego jest niezwykle atrakcyjna. Testcontainers dostarcza mechanizm przygotowania „w locie” danych testowych. Znika również potrzeba czyszczenia stanu po teście w trosce o to, by artefakty i pozostawione dane nie wpływały przypadkiem na następne egzekucje tego samego testu. Tutaj każde uruchomienie testu posiada własną, „świeżą” instancję komponentu, która ginie po teście wraz z całą swoją zawartością.

Testcontainers/ryuk

Obiekt, który zarządza zamykaniem wybranych kontenerów to „testcontainers/ryuk”. Uruchamia się zawsze, mimo iż wprost nie deklarujemy tego w kodzie. To kontener uprzywilejowany, który odpowiada za prawidłowe i automatyczne zamykanie kontenerów [automatic cleanup].

Mechanizm ten ma duże znaczenie zwłaszcza, kiedy kończymy egzekucję testów w niestandardowy sposób np.:

  • pojawia się exception w trakcie wykonania scenariusza,
  • nie udało się uruchomić pełnego środowiska do testów, bo zabrakło jednego kontenera,
  • w czasie debugowania zamykamy test z poziomu IDE.

Wszystkie te i podobne sytuacje powodują wymuszenie zamknięcia uruchomionych kontenerów, aby nie pozostawić w pamięci „martwych kontenerów”.  Dokumentacja biblioteki pozwala wprawdzie na własną konfigurację ryuk, a nawet wyłączenie go, ale jest to niewskazane.

Testcontainers dla języka JAVA – demo

Przedstawię praktyczny przykład użycia Testcontainers w kodzie Java. Chcę wykorzystać bazę danych PostgreSQL jako komponent podczas testowania kontrolera [Controller] w prostym microservice. Wybrałem framework Micronaut, ale równie dobrze zadziała to z tradycyjnym Spring Boot.

Serwis to klasyczny CRUD – pozwala na odczyt, zapis, edycję i usuwanie encji Actor do bazy danych. Posiada podział na warstwy:

  • Controller,
  • Service,
  • Repository.

W Micronaucie taka klasa modelowa będzie wyglądała następująco:

package com.example;

import io.micronaut.core.annotation.Nullable;
import io.micronaut.data.annotation.GeneratedValue;
import io.micronaut.data.annotation.MappedEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import javax.persistence.Entity;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;

@Schema(description="Actor business model")
@Entity
public class Actor
 {
     @GeneratedValue @Id
     @Nullable
     private Long id;
     @NotBlank @Size(max = 20)
     private String firstName;
     @NotBlank @Size(max = 20)
     private String lastName;
     @NotBlank
     private Long rating;

//getters and setters
}
[src/main/java/model/Actor] 

Zdefiniowałem DataSource w pliku konfiguracyjnym application.yml dla Postgresa, który wygląda następująco:

datasources:
  default:
    url: jdbc:postgresql://localhost:5432/actor
    driverClassName: org.postgresql.Driver
    username: postgres
    password: postgres
    schema-generate: NONE
    dialect: POSTGRES
    schema: public

Kiedy baza danych z odpowiednim schematem jest uruchomiona, wszystko działa bez zarzutu. Mogę wykonać moje testy API napisane np. w RestAssured. Jeśli jednak wyłączę bazę danych, żaden test nie da wiarygodnych rezultatów. Testcontainer pozwoli mi na „włączenie” brakującego komponentu tylko na czas wykonania moich testów.

Pierwszym krokiem jest pobranie zależności do projektu:

Maven:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.8.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <version>1.17.6</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.17.6</version>
    <scope>test</scope>
</dependency>

Gradle:

testImplementation "org.junit.jupiter:junit-jupiter:5.8.1"
testImplementation "org.testcontainers:testcontainers:1.17.6"
testImplementation "org.testcontainers:junit-jupiter:1.17.6"

Następnie definiuję DataSource na poziomie testów. W Micronaut odpowiedzialny jest za to plik konfiguracyjny application-test.yml:

datasources:
default:
url: jdbc:tc:postgresql:latest:///postgres?TC_INITSCRIPT=file:src/test/resources/init-actor-testdata.sql?TC_DAEMON=true
    	driverClassName: org.testcontainers.jdbc.ContainerDatabaseDriver
    	minimum-idle: 5

Zwróćcie uwagę, że parametr url jest oznaczony przez tc – [jdbc:tc] – co wskazuje, że jego obsługą zajmie się Testcontainers. Dodatkowo, driverClassName również zawiera wskazanie na pakiet org.testcontainers.

Teraz już możemy w kodzie klasy testowej zaznaczyć, że będziemy wykorzystywali kontenery Dockera poprzez adnotację @Testcontainers przed klasą.

@Testcontainers
@MicronautTest(environments = "test")
@Slf4j
public class JdbcTemplateActorTest {
}
[src/test/java/ JdbcTemplateActorTest]

oraz adnotacją @Container przed polem, który definiuje typ I wersję kontenera:

@Testcontainers
@MicronautTest(environments = "test")
@Slf4j
public class JdbcTemplateActorTest {

    @Container
    private static final PostgreSQLContainer<?> postgres = PostgresContainer.getContainerPostgres();

[src/test/java/ JdbcTemplateActorTest]

Pozostałe adnotacje to:

  • @MicronautTest(environments = „test”) – micronautowe oznaczenie, że będziemy korzysali z testowego kontekstu Micronauta.
  • @Slf4j – adnotacja Lombok uruchamiająca logger w klasie.

Klasa PostgreSQLContainer zapewnia nam prawidłową obsługę tego szczegółowego kontenera PostgreSQL. Istnieje ponadto generyczna klasa GenericContainer, którą zawsze możemy wykorzystać do przechowania obiektu dowolnego kontenera:

@Container
    private static final GenericContainer<?> postgres = PostgresContainer.getContainerPostgres();

Biblioteka Testcontainers wymaga, abyśmy dodali absolutnie minimalną i niezbędną konfigurację naszego kontenera. Musimy przecież wybrać, jaki image postgres potrzebujemy, zdefiniować nazwę schematu, username i password.

Osobiście preferuję umieszczenie takiej konfiguracji nie w klasie testowej, ale w osobnej klasie odpowiedzialnej za kontenery postgresa. Ma to sens, ponieważ postgres to nie jedyny komponent, jakiego potencjalnie możemy potrzebować. Gdy pojawi się Kafka, Redis czy MongoDB, to bezpiecznie odseparujemy wszystkie te konfiguracje.

Kod takiej klasy może wyglądać następująco:

package com.example.containers;

import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;

public class PostgresContainer extends PostgreSQLContainer<PostgresContainer> {
    private static final String MYPOSTGRESIMAGE = "postgres:latest" ;
    private static final String MYTESTDATABASENAME = "actor";
    private static final String USERNAME = "postgres";
    private static final String PASSWD = "postgres";
    private static final Integer DB_PORT = 5432;

    private PostgresContainer() {
        super(DockerImageName.parse(MYPOSTGRESIMAGE));
    }

    public static PostgreSQLContainer<?> getContainerPostgres() {
        return new PostgreSQLContainer<>(DockerImageName.parse(MYPOSTGRESIMAGE))
                .withDatabaseName(MYTESTDATABASENAME)
                .withExposedPorts(DB_PORT)
                .withUsername(USERNAME)
                .withPassword(PASSWD);
    }
}
[src/test/containers/PostgresContainer]

Najistotniejsza jest tu statyczna metoda – getContainerPostgres(). To tutaj za pomocą wielu metod rozpoczynających się od „with” ustawiamy stan naszego kontenera oraz sposoby łączenia się z nim.

Tryb łańcuchowego wywołania tych metod dodaje ogromnej wygody:

tryb łańcuchowy wywołania metody

Teraz pozostaje już tylko przekazanie do testowego DataSource szczegółów połączenia z kontenerową bazą danych. Wraz z micronautową adnotacją @MockBean jest to bardzo proste i może wyglądać następująco w klasie testowej:

@Testcontainers
@MicronautTest(environments = "test")
@Slf4j
public class JdbcTemplateActorTest {

    @Container
    private static final PostgreSQLContainer<?> postgres = PostgresContainer.getContainerPostgres();

    private DataSource postgresDataSource;

    private JdbcTemplate jdbcTemplate;

    @Inject
    public JdbcTemplateExampleTest(DataSource postgresDataSource) {
        this.postgresDataSource = postgresDataSource;
    }

    @MockBean(DBConnector.class)
    DBConnector postgresConnection() {
        PostgresTestContainer dbConnection = new PostgresTestContainer();
        dbConnection.setUrl(postgres.getJdbcUrl());
        dbConnection.setUsername(postgres.getUsername());
        dbConnection.setPasswd(postgres.getPassword());
        return dbConnection;
    }
[src/test/java/ JdbcTemplateActorTest]

Widoczny w klasie bean JdbcTemplate posłużył mi tylko jako connector do bazy danych. To dzięki niemu będziemy wiedzieli, że baza danych rzeczywiście pojawi się, kiedy jej potrzebujemy, bo będziemy mogli wykonać nasze zapytania i odebrać rezultaty.

Kiedy wszystko jest przygotowane mogę zaimplementować dwa proste testy:

  • Test 1 – shouldGetAllActorsFromDBBasedOnTestContainers() – pobranie wszystkich aktorów z bazy – „SELECT * FROM actor”;
  • Test 2 – shouldGetSingleActorFromDBBasedOnTestContainers() – pobranie jednego aktora z bazy – „SELECT firstname FROM actor WHERE id=1”;
@Test
    void shouldGetAllActorsFromDBBasedOnTestContainers() {
        jdbcTemplate = new JdbcTemplate(postgresDataSource);
        await().atMost(10, TimeUnit.SECONDS)
                .until(this::isRecordLoaded);

        var dbResultsSize = this.getLoadedRecords().size();
        var dbResults = this.getLoadedRecords();

        assertThat(dbResultsSize).isEqualTo(3);
        assertThat(dbResults.get(0).get("firstname")).isEqualTo("Brad");
        assertThat(dbResults.get(1).get("firstname")).isEqualTo("Angelina");
        assertThat(dbResults.get(2).get("firstname")).isEqualTo("Salma");
    }

    @Test
    void shouldGetSingleActorFromDBBasedOnTestContainers() {
        jdbcTemplate = new JdbcTemplate(postgresDataSource);
        await().atMost(10, TimeUnit.SECONDS)
                .until(this::isRecordLoaded);

        var dbResultsSize = this.getLoadedRecords().size();
        var dbResults = this.getSingleRecord();

        assertThat(dbResultsSize).isEqualTo(3);
        assertThat(dbResults).isEqualTo("Brad");
    }

    private boolean isRecordLoaded() {
        return jdbcTemplate.queryForList("Select * from actor").size() > 1;
    }

    private List<Map<String, Object>> getLoadedRecords() {
        return jdbcTemplate.queryForList("Select * from actor");
    }

    private String getSingleRecord() {
        return jdbcTemplate.queryForObject("Select firstname from actor where id=1", String.class);
    }

[src/test/java/ JdbcTemplateActorTest]

Wyniki

Oto rezultaty klasy testowej uruchomionej lokalnie w IDE. Zwróć uwagę, jak logi w konsoli wyraźnie wskazują, że:

  • uruchomił się Docker,
  • uruchomił się uprzywilejowany kontener Testcontainers/ryuk,
  • uruchomił się kontener PostgreSQL

I co najważniejsze – czas wykonania obu testów to zaledwie 0,5 sek!

Rezultaty klasy testowej uruchomionej w lokalnej IDE
Ryc. 6 Rezultaty klasy testowej uruchomionej w lokalnej IDE

Dodatkowo, na potwierdzenie dodaję jeszcze widok z Docker Desktop w czasie wykonania testów. Widać wyraźnie, że potrzebne kontenery są w statusie running:

Widok z Docker Desktop w czasie wykonania testów
Ryc. 7 Widok z Docker Desktop w czasie wykonania testów

Można zauważyć, jak Testcontainer pozwolił mi minimalnym wysiłkiem implementować dowolne testy integracyjne. Niezbędne komponenty „wstają” w postaci kontenerów dockerowych, a po wykonanym teście komponenty te są usuwane.

Jest jeszcze jedno zagadnienie, które uznaję jako niezwykle przydatne w Testcontainers. Są to opcje (flagi), które definiuję w pliku konfiguracyjnym application-test.yml:

  • TC_INITSCRIPT
  • TC_DAEMON
Flagi definiowane w pliku konfiguracyjnym application-test.yml
Ryc. 8 Flagi definiowane w pliku konfiguracyjnym application-test.yml

TC_INITSCRIPT

Zrozumiałe jest, że kiedy korzystamy z bazy danych, chcielibyśmy, żeby już był tam schemat, tabele i jakieś dane testowe niezbędne do wykonania testu. Dzięki opcji TC_INITSCRIPT, możemy zdefiniować skrypt SQL, który wykona się zaraz po uruchomieniu kontenera DB, ale przed wykonaniem pierwszej linijki kodu.

W moim demo wykorzystałem następujący skrypt, dzięki czemu nie musiałem w kodzie testu obsługiwać zawartości mojej bazy PostgreSQL:

Skrypt src/test/resources/init-actor-testdata.sql
Ryc. 9 Skrypt src/test/resources/init-actor-testdata.sql

TC_DAEMON

W domyślnym ustawieniu kontener bazy danych jest zatrzymywany po zamknięciu ostatniego połączenia. Bywają jednak sytuacje, w których będziemy chcieli, aby kontener działał do momentu jego wyraźnego zatrzymania lub wyłączenia maszyny JVM. Aby to zrobić, dodaj parametr TC_DAEMON do adresu URL jak w grafice wyżej.

Podsumowanie

Testcontainers to biblioteka do obsługi kontenerów dockerowych w kodzie. Doskonale sprawdza się podczas tworzenia efemerycznych komponentów w zautomatyzowanych testach integracyjnych. Pozwala też na załadowanie skryptów SQL w taki sposób, że kontenerowa baza danych jest już wyposażona w schemat i dane niezbędne do testów.

Podczas pracy w Testcontainers zauważyłem dwie niedogodności:

  • trzeba mieć doświadczenie w pracy z zewnętrznymi bibliotekami Java. Samodzielna implementacja nie jest trywialna, jak nietrywialny jest sam Docker. Próg intelektualnego wejścia jest zatem zauważalny, ale widzę wyraźnie wysiłek twórców tej biblioteki, żeby możliwie uprościć developerom tę pracę,
  • po całodziennej pracy w Testcontainers i wielokrotnym uruchamianiu testów integracyjnych zauważam, że Docker, zwłaszcza Docker Desktop w Windows OS, potrafi konsumować dużą ilość zasobów komputera (RAM, procesor). Niejednokrotnie kontenery nie „wstają”, a test kończy się błędem „Initialization error”. Możemy wtedy skonfigurować w pliku .wslconfig zakres dedykowanych zasobów dla Dockera. Kiedy błąd inicjalizacji kontenerów się powtarza, najskuteczniejszym sposobem jest zwykły restart komputera.

***
Jeżeli interesuje Cię temat Dockera, zachęcamy do przeczytania serii artykułów przygotowanych przez naszego eksperta:

5/5 ( głosy: 9)
Ocena:
5/5 ( głosy: 9)
Autor
Avatar
Dariusz Hryciuk

Senior Test Automation Engineer w Sii

Zostaw komentarz

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

Może Cię również zainteresować

Pokaż więcej artykułów

Bądź na bieżąco

Zasubskrybuj naszego bloga i otrzymuj informacje o najnowszych wpisach.

Otrzymaj ofertę

Jeśli chcesz dowiedzieć się więcej na temat oferty Sii, skontaktuj się z nami.

Wyślij zapytanie Wyślij zapytanie

Natalia Competency Center Director

Get an offer

Dołącz do Sii

Znajdź idealną pracę – zapoznaj się z naszą ofertą rekrutacyjną i aplikuj.

Aplikuj Aplikuj

Paweł Process Owner

Join Sii

ZATWIERDŹ

This content is available only in one language version.
You will be redirected to home page.

Are you sure you want to leave this page?