Wybierz region
pl
  • PL
  • EN
Wydrukuj

TestContainers - najlepsza pomoc nie tylko w testach

Chyba nikogo dzisiaj nie trzeba przekonywać o wartości testów automatycznych przy rozwoju i utrzymaniu aplikacji. Jak wiemy pomagają one nie tylko utrzymać wysoką jakość, ale stanowią też świetną, można powiedzieć żywą, dokumentację danego modułu.

Jest to szczególnie istotne w momencie, gdy średnia długość stażu pracy w jednej firmie w IT znacząco spada, a co za tym idzie próg wejścia dla nowych programistów musi być jak najmniejszy.

Testy integracyjne

Po środku piramidy testów znajduje się dość szczególny rodzaj testów – testy integracyjne. Stanowią one szczególnie interesujący obszar dla dewelopera. W ich trakcie uruchamiamy nasz moduł i testujemy go w całym otoczeniu, czyli z komponentami z którymi się komunikuje. Najczęściej jest to oczywiście baza danych, ale też mogą być to systemy takie jak RabbitMq, Kafka, czy dodatkowi dostawcy API rest-owego. Takie otoczenie jest bardzo popularne, szczególnie w przypadku tworzenia mikroserwisów.

Aby zapewnić wiarygodność i powtarzalność naszych testów musimy zapewnić, że wspomniane otoczenie jest zawsze dostępne i odpowiada w powtarzalny sposób. W tym celu niezbędne jest odcięcie się od „środowisk testowych” i uruchomienie niezbędnych modułów w ramach testów.

Tutaj dochodzimy do sedna dzisiejszego wpisu, narzędzia, które wspomniany cel znacząco ułatwia, czyli biblioteki Testcontainers.

Czym jest Testcontainers for Java?

Cytując stronę testcontainers.org (https://www.testcontainers.org/) „jest to biblioteka która obsługuje testy JUnit, zapewniając lekkie, jednorazowego użytku, instancje popularnych baz danych, przeglądarek internetowych lub czegokolwiek innego, co można uruchomić w kontenerze Docker”.

O użyteczności tej biblioteki może świadczyć lista gotowych do wykorzystania modułów: jest to ponad 18 rodzajów samych baz danych, a ponadto kilkanaście modułów takich jak RabbitMq, Nginx, Elasticsearch, Kafka i inne.

Dodatkowo możemy w bardzo prosty sposób tworzyć swoje moduły, zawierające np.: nasze własne mikroserwisy

Biblioteka zapewnia obsługę całego cyklu życia uruchamianych za jej pomocą modułów – od ich tworzenia aż do usunięcia. Dzięki temu zapewnia pełne, powtarzalne środowisko dla testowanego serwisu.

Koniec teorii

Najwyższa pora coś zaprogramować… Napiszemy testy integracyjne dla przykładowego modułu, który posiada następujące funkcjonalności:

  • posiada bazę danych w której zapisane są informacje o produktach,
  • ceny produktów mogą być wyrażone w PLN lub innej walucie,
  • w przypadku gdy cena wyrażona jest w innej walucie niż PLN, zwracana jest również cena przeliczona na PLN według średniego kursu NBP z dnia. Do tego wykorzystywane jest udostępniane przez NBP API: https://api.nbp.pl/

Z powyższego opisu widać, że aplikacja korzysta z bazy danych (w moim przypadku PostgreSQL) oraz wywołuje zewnętrzne API. Oczywiście nie chcemy uzależniać testów od dostępności API w NBP oraz bieżących, zmieniających się kursów walut. Dlatego API to zamokujemy korzystając z rozwiązania MockServer. Szczęśliwie zarówno do bazy danych jak i MockServer mamy gotowe moduły Testcontainer.

Sam moduł do obsługi produktów napisany jest w Java z wykorzystaniem SpringBoot.

Implementacja

Aby skorzystać z Testcontainers załączamy w pom.xml wymagane biblioteki:

<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-testcontainers</artifactId>

<scope>test</scope>

</dependency>

<dependency>

<groupId>org.testcontainers</groupId>

<artifactId>junit-jupiter</artifactId>

<scope>test</scope>

</dependency>

<dependency>

<groupId>org.testcontainers</groupId>

<artifactId>postgresql</artifactId>

<scope>test</scope>

</dependency>

<dependency>

<groupId>org.testcontainers</groupId>

<artifactId>mockserver</artifactId>

<scope>test</scope>

</dependency>

Jak widzimy dodajemy tutaj silnik Testcontainers wraz z dwoma modułami: do PostgreSQL oraz MockServer.

Teraz przechodzimy do klasy testowej. Aby w teście wykorzystać nasz silnik, należy klasę testową opatrzeć adnotacją @Testcontainers.

@Testcontainers

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)

public class FirstIntegrationTest {

Aby utworzyć kontenery z wymaganymi modułami tworzymy w klasie testowej stałe:

@Container
private static final PostgreSQLContainer POSTGRESQL_CONTAINER = new PostgreSQLContainer("postgres:15.3")
.withDatabaseName("product_db")
.withUsername("produser")
.withPassword("password");

@Container
private static final MockServerContainer MOCK_SERVER_CONTAINER = new MockServerContainer("5.13.2");

* Publikujemy Wam wyjątkowo nie w kodzie, bo przez system nie było tak czytelnie.

Tutaj warto zaznaczyć, że w przypadku gdyby te stałe nie miały modyfikatora static, to kontenery z danymi modułami byłyby tworzone przed każdą metodą testową, a tak tworzone są tylko raz na wszystkie przypadki metody testowe zawarte w danej klasie.

Teraz musimy zintegrować uruchomione moduły z aplikacją. Ponieważ jest ona napisana z wykorzystaniem SpringBoot, po prostu modyfikujemy jego konfigurację na potrzeby testu:

@DynamicPropertySource

public static void setUpEnvironment(DynamicPropertyRegistry registry) {

registry.add("spring.datasource.url", POSTGRESQL_CONTAINER::getJdbcUrl);

registry.add("spring.flyway.locations",()->;"classpath:/db/migration,classpath:/scripts");

registry.add("spring.cloud.openfeign.client.config.nbpratesclient.url",

()->;"http://"+MOCK_SERVER_CONTAINER.getHost()+":"+MOCK_SERVER_CONTAINER.getServerPort());

}

Jak widzimy z w celu pobrania wymaganych informacji (takie jak port, adresy, itd.) wykorzystujemy metody udostępniane nam przez Testcontainers.

Teraz jesteśmy już gotowi do stworzenia przypadku testowego.

W jego ramach, za pomocą klienta MockServer-a przygotowujemy odpowiedź, którą ma zwrócić w przypadku zapytania o kurs EUR.

Następnie wykonujemy żądanie GET do naszego serwisu pobierając dane produktu. Na końcu weryfikujemy, czy zwrócone dane są zgodne z naszymi oczekiwaniami.

@Test

void shouldGetProductWithEur() {

try(

MockServerClient mockServerClient =

new MockServerClient(MOCK_SERVER_CONTAINER.getHost(),MOCK_SERVER_CONTAINER.getServerPort())

) {

mockServerClient

.when(

request()

.withPath("/api/exchangerates/rates/A/EUR")

.withQueryStringParameter("format","json"

)

)

.respond(

response()

.withStatusCode(200)

.withContentType(MediaType.APPLICATION_JSON)

.withBody("{\"table\":\"A\",\"currency\":\"euro\",\"code\":\"EUR\",\"rates\":[{\"no\":\"109/A/NBP/2023\",\"effectiveDate\":\"2023-06-07\",\"mid\":4.500}]}")

);

 

RestAssured

.with()

.baseUri("http://localhost")

.port(serverPort)

.given()

.pathParam("id", PRODUCT_EUR_ID)

.contentType("application/json")

.accept("application/json, application/*+json")

.get("/api/product/{id}")

.then()

.assertThat()

.statusCode(200)

.body("currency", equalTo("EUR"))

.body("price", equalTo(2.0f))

.body("pricePLN", equalTo(9.0f));

}

}

Nie pozostaje nic innego jak cieszyć się przetestowaną aplikacją.

Kilka słów wyjaśnienia

Wszystkim moim czytelnikom należy się jeszcze kilka słów wyjaśnienia. Pewnie zastanawiacie się skąd wzięły się w bazie danych tabele oraz dane testowe?

Jest to całkiem proste, w aplikacji używam bibliotek Flyway, który przy starcie aplikacji sprawdza strukturę bazy danych i w przypadku gdy nie istnieje to ją tworzy.

Dodatkowo, na potrzeby testów, do lokalizacji w których flyway poszukuje skryptów, dołączany jest katalog scripts, z plikiem afterMigrate.sql, w którym wstawiane są rekordy do tabel. Strukturę projektu możecie zobaczyć na poniższej ilustracji.

Całość kodu znajdziecie w repozytorium: github.com/domatblog/tcsample


Dobromir Matusiewicz

Ekspert Architekt w Asseco Rzeszów. Miłośnik danych, chmur i Kafki. Ojciec, kinoman i odkrywca!


Wydrukuj