Testcontainers – wznieś testy integracyjne na wyższy poziom z użyciem Dockera

Podczas pisania testów integracyjnych dochodzimy w pewnym momencie do sytuacji gdy musimy zastanowić się w jaki sposób zasymulować działanie zewnętrznych systemów, tak aby nie odbiegały one znacząco od tych z którymi będziemy mieli do czynienia na środowisku produkcyjnym.

Jednym z najczęstszych takich systemów zewnętrznych jest baza danych. Chcemy upewnić się, że nasza aplikacja poprawnie się z nią komunikuje. Przejdźmy przez kilka możliwych rozwiązań.

Baza danych, która jest na stałe postawiona na zewnętrznym serwerze

Brzmi strasznie brzydko, ale niech pierwszy rzuci kamieniem kto nie widział takich rozwiązań w korpo-aplikacjach. Ktoś w projekcie uznał, że skoro trzeba sprawdzić połączenie z bazą danych no to sprawdzi się połączenie z bazą danych ze środowiska testowego. Nie chcę rozpisywać się nad tym, bo stabilność takich testów jest żadna. Po prostu nie róbcie tego.

Baza danych in-memory (H2)

W przypadku ekosystemu Javy jest to aktualnie najpopularniejsze rozwiązanie. Wystarczy, że dorzucimy do projektu bibliotekę H2 Database, HyperSQL lub podobną. Wybór jest spory.
I faktycznie jest to całkiem dobre i prawdopodobnie wystarczające rozwiązanie dla dużej części projektów.

Niestety taki rodzaj „baz danych” mocno odbiega od tej bazy z którą będziemy mieli do czynienia na produkcji. Problem pojawia się w momencie gdy chcemy wykorzystać coś ponad standardowe zapytania SQL.

Teoretycznie można ustawić H2 Console, aby działał w trybie zbliżonym do PostgreSQL, ale nie oznacza to, że jest to PostgreSQL. Mam skrypt SQL, który tworzył początkową strukturę bazy danych. Ten sam skrypt chcę wykorzystywać zarówno w testach jak i podczas uruchamiania prawdziwej aplikacji. Niestety okazuje się, że H2 nie interpretował słowa kluczowego COLLATE przez co muszę utrzymywać ten skrypt w dwóch miejscach w różnych wersjach.

Spring Cloud Contract

W niektórych przypadkach możemy wykorzystać testy kontraktowe o których niedawno wspinałem tutaj. Jeżeli chodzi o bazy danych to raczej się to nie spisze, ale w innych sytuacjach może nadać się idealnie. Przed wybraniem odpowiedniego rozwiązania warto wiedzieć jakie mamy opcje. 🙂

Docker

Wchodzimy na wyższy poziom. Skoro wykorzystujemy do wszystkiego Dockera to czemu by nie wykorzystać go również do testów integracyjnych? Przecież podczas testów możemy postawić prawdziwą instancję bazy danych i działać na prawdziwej bazie zamiast na czymś co tę bazę udaje.

Musimy napisać jakiś shell-script, który będzie nam tworzył taki kontener Dockerowy i wywołać go zanim zaczną wykonywać się nasze testy.

Wydaje się to prostą sprawą, ale niestety natykamy się na kilka problemów.


Małe wtrącenie: zamiast pisać skrypt w shellu możemy użyć np. docker-maven-plugin. Jest on całkiem popularny i do prostych zastosowań może być wystarczający. Niestety konfiguracja tego pluginu w XML jest strasznie toporna.


To, że kontener został uruchomiony nie oznacza, że jest gotowy do przyjmowania zapytań.

Zanim database system is ready to accept connections mija trochę czasu.

Oczywiście przychodzi łatwe rozwiązanie – no to uśpijmy nasz skrypt na x sekund i dopiero po tym czasie zacznijmy uruchamiać testy. Czasami to faktycznie będzie działać, ale co jeżeli akurat nasz system będzie przeciążony i zamiast x sekund będzie potrzebne x+1 sekund? Nie chcemy, aby nasze testy były podatne na takie sytuacje.

Port zajęty przez inną aplikację

To kolejny problem z którym przyjdzie nam się zmierzyć. Jeżeli ustalimy port statycznie to nasze testy będą czasami czerwone tylko dlatego, że port może być akurat w tym czasie wykorzystywany przez inny proces. Tracimy możliwość współbieżnego odpalania testów.

Docker informujący o tym, że port jest zajęty – tej sytuacji chcemy uniknąć.

Usuwanie kontenerów po zakończeniu testów

Załóżmy, że mamy faktycznie skrypt, który po wszystkich testach posprząta nasze kontenery. Jeżeli jednak nasze testy wywalą się w trakcie z jakiegoś powodu (CI uzna, że zabieramy za dużo RAM i wywali proces) to ten skrypt nie zostanie wywołany, a nasze utworzone kontenery dalej będą istniały.

Dodatkowo dorzucanie takich skryptów powoduje, że nasza aplikacja staje się coraz trudniejsza do uruchomienia. Jest to dosyć ważny aspekt. Wejście do nowego projektu kojarzy się zazwyczaj z potrzebą spędzenia kilku godzin lub dni nad konfiguracją, aby cały projekt udało się poprawnie zbudować. Nie idź w tę stronę. Trzymaj się tego, że zbudowanie twojego projektu przez nową osobę w zespole powinno wyglądać mniej więcej tak:

$ git clone http://github.com/foo/bar 
$ cd bar
$ mvn clean install

Podsumowując problemy związane z pisaniem własnego skryptu do obsługi kontenerów:

  1. Nie wiemy jak długo czekać z wystartowaniem testów od momentu uruchomienia kontenera.
  2. Musimy w jakiś sposób obsłużyć dynamiczne ustawianie portów.
  3. Potrzebujemy obsłużyć sprzątanie kontenerów po zakończeniu testów nawet przypadku gdy nasza JVM zostanie zatrzymana w trakcie ich działania.

Oczywiście część tych problemów możemy rozwiązać pisząc trochę więcej (sporo więcej) skryptów. Ale po co skoro możemy wykorzystać Testcontainers?


Może był to ciut przydługi wstęp, ale przed podjęciem decyzji o wdrożeniu nowej technologii do projektu warto rozumieć jaki problem nam rozwiązuje.

Testcontainers

Testcontainers is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker containerTestcontainers.org

Zanim przejdę do przykładowego kodu spójrzmy napierw na problemy, które mieliśmy w sytuacji gdy chcieliśmy wykorzystać Dockera i sprawdźmy w jaki sposób Testcontainers nam je rozwiązuje.

1. Nie wiemy jak długo czekać z wystartowaniem testów od momentu uruchomienia kontenera.

Testcontainers ma zaimplementowane kilka podstawowych strategii według których określa czy kontener jest gotowy na uruchomienie testów. W przypadku baz danych może być to zwykłe zapytanie w stylu: „SELECT 1„, a w przypadku kontenerów z aplikacją webową może być to request HTTP pod wybrany endpoint. Domyślną strategią jest nasłuchiwanie portu TCP, ale nie w każdym przypadku będzie to dobra strategia.

Przykładowe ustawienie strategii oczekiwania na kontener z app. Oczekujemy, że zapytanie HTTP pod adres /index zwróci nam odpowiedź z kodem 200.

public GenericContainer nginxWithHttpWait = new GenericContainer("app:0.0.2")
    .withExposedPorts(8080)
    .waitingFor(Wait.forHttp("/index"))
    .forStatusCode(200)

Więcej o tym możecie poczytać tutaj: Waiting for containers to start or be ready.

2. Musimy w jakiś sposób obsłużyć dynamiczne ustawianie portów.

Port kontenera na którym będzie nasłuchiwała aplikacja ustalany jest dynamicznie (aby uniknąć kolizji) dlatego ważne jest, aby móc ustawić ten port z poziomu pisania testów integracyjnych. Nie powinno być z tym problemu jeżeli korzystacie z wzorca wstrzykiwania zależności.

3. Potrzebujemy obsłużyć sprzątanie kontenerów po zakończeniu testów nawet przypadku gdy nasza JVM zostanie zatrzymana w trakcie ich działania.

Tutaj twórcy Testcontainers wpadli na pomysł, aby na czas testów uruchamiać jeden dodatkowy kontener (ryuk), który będzie obserwował czy JVM zakończył swój proces, aby następnie posprzątać wszystkie kontenery, które były przez niego utworzone. Oznacza to, że nawet kill -9 nie spowoduje, że zostawimy po sobie bezużyteczne kontenery.

Rozwieję jeszcze kilka wątpliwości:

  1. Testcontainers ma już produkcyjnie stabilną wersję. Listę firm, która wykorzystuje tę technologię na co dzień możecie znaleźć na głównej stronie Testcontainers.org
  2. Działa z CI. Jest to kwestia uruchomienia Dockera w Dockerze.
  3. Do uruchomienia potrzebujesz jedynie Dockera więc działa na Linux, MacOS i Windowsie.

Przykład

Zacznijmy od dorzucenia kilku zależności do projektu (pełny projekt na githubie).

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

 <!--	Dodatkowy moduł, aby skorzystać z gotowego kontenera dla mySQL -->
<dependency>
	<groupId>org.testcontainers</groupId>
	<artifactId>mysql</artifactId>
	<version>1.12.5</version>
</dependency>

Przykładowy serwis z dwoma metodami: zapis do tabeli USERS i odczyt sumy elementów w tabeli.

public class ExampleService {

    private final JdbcTemplate jdbcTemplate;

    public ExampleService(JdbcTemplate jdbcTemplate) {

        this.jdbcTemplate = jdbcTemplate;
    }

    public int count(){
        return this.jdbcTemplate
                .queryForObject("SELECT count(*) as sum FROM USERS",
                (resultSet, i) -> resultSet.getInt("sum"));
    }

    public void save(String firstName) {
        this.jdbcTemplate.update("INSERT INTO USERS(first_name) VALUES (?)", firstName);
    }
}

Oraz to co nas najbardziej interesuje – klasa testowa. Aktualnie taki szybki start jest możliwy przy wykorzystanu JUnit4, Spock oraz JUnit5. W przypadku JUnit5 wygląda to tak:

@Testcontainers
public class ExampleIntegrationTest {
    @Container
    public MySQLContainer mySQLContainer = new MySQLContainer();

    private ExampleService exampleService;

    @BeforeEach
    public void setUp() {
        DataSource dataSource = DataSourceBuilder.create()
                .url(mySQLContainer.getJdbcUrl())
                .password(mySQLContainer.getPassword())
                .username(mySQLContainer.getUsername())
                .driverClassName(mySQLContainer.getDriverClassName())
                .build();

        JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
        jdbcTemplate.execute("CREATE TABLE USERS(" +
                "id SERIAL, first_name VARCHAR(255))");

        exampleService = new ExampleService(jdbcTemplate);

    }

    @Test
    public void shouldExistOneUserInDatabaseAfterSave() {
        exampleService.save("EXAMPLE_FIRST_NAME");
        Assert.assertEquals(exampleService.count(), 1);
    }

    @Test
    public void tableWithUsersShouldBeEmpty(){
        Assert.assertEquals(exampleService.count(), 0);
    }
}

Jak widzicie nad klasą pojawiła się adnotacja @Testcontainers oraz obiekt typu MySQLContainer z adnotacją @Container. Potrzebujemy jeszcze zdefiniować DataSource, aby namierzyć się na naszą bazę danych. I to wszystko.

Podczas testów zobaczymy, że uruchomione zostały dwa kontenery:

A po poprawnym wykonaniu się testów w logach widzimy informację mówiącą o tym, że nasze kontenery zostały posprzątane:

MySQLContainer to klasa, który została napisana specjalnie pod kontener z obrazem MySQL. Spójrzcie na jej implementację. Jest to między innymi ustawienie zmiennych środowiskowych, zdefiniowane domyślnego obrazu z którym ma zostać uruchomiony kontener.

Istnieje już sporo gotowych rozwiązań dla różnych popularnych technologii dzięki czemu nie musimy implementować tego sami.

Cassandra, CockroachDB, Couchbase, Clickhouse, DB2, Dynalite, InfluxDB, MariaDB, MS SQL Server, MySQL, Neo4j, Oracle-XE, OrientDB, Postgres, Presto, Docker Compose, Elasticsearch, Kafka, Localstack, Mockserver, Nginx, Apache Pulsar, RabbitMQ, Toxiproxy, Hashicorp Vault, Webdriver Containers

Docker Compose i Toxiproxy to szczególne moduły, które zasługują na osobny wpis, który pojawi się prawdopodobnie w następny weekend.

Mimo, że o tym za dużo nie wspomniałem (poruszę ten temat również w osobnym wpisie) uruchomienie kontenera z obrazem dla którego nie ma gotowego modułu jest również bardzo proste. Wygląda to mniej więcej tak:

Starajmy się, aby nasze testy integracyjne były wykonywane na środowisku jak najbardziej zbliżonym do realnego środowiska. Unikajmy „dziwne, u mnie działa”.

Repozytorium z pełnym projektem, którego kod pojawił się we wpisie:


https://github.com/mtszpater/testcontainers-examples
0 forks.
0 stars.
0 open issues.
Recent commits:

Ciekawe prezentacje