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:
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:

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!

Dodatkowo, na potwierdzenie dodaję jeszcze widok z Docker Desktop w czasie wykonania testów. Widać wyraźnie, że potrzebne kontenery są w statusie running:
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
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:
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:
Zostaw komentarz