Pretius: strategiczna fuzja jako odpowiedź na współczesne wyzwania
Pretius. Budujemy mądrzej:
strategiczna fuzja jako odpowiedź na współczesne wyzwania

Testcontainers + Liquibase: Ułatw testowanie integracji

Arkadiusz Rosłoniec

Senior Java Developer / Team Project Leader

  • 9 lutego, 2023

Spis

[Uwaga] Ten artykuł został pierwotnie przygotowany w języku angielskim i został przetłumaczony na język polski.

Ogromna liczba narzędzi, bibliotek i frameworków może przyprawić wielu programistów o zawrót głowy. Co więcej, złożone projekty często wymagają współdziałania wielu tych komponentów – a przynajmniej tego, by nie wchodziły sobie one w paradę.

Wersjonowanie bazy danych – oraz testy integracyjne, podczas których je przeprowadzamy – to świetne przykłady takiej problematycznej współpracy. Istnieje również aspekt warstwy trwałości (persistence layer) w naszym kodzie, który będzie przedmiotem wspomnianych testów.

W tym artykule pokażę Ci, jak uprościć ten proces, korzystając z Liquibase oraz biblioteki Testcontainers dla Dockera.

Wymagania wstępne

Zanim przejdziemy do przykładu, warto zapoznać się z poniższymi pojęciami, ponieważ będą one przedmiotem naszych rozważań.

Wersjonowanie bazy danych

Skoro wersjonowanie wszystkich zmian w kodzie jest standardową praktyką, dlaczego nie przenieść tego zwyczaju na warstwę bazy danych? Dzieje się to już w wielu projektach, ale uważam, że wciąż warto przypominać o tym procesie, aby stał się on tak naturalny, jak używanie Gita.

Wersjonowanie bazy danych (DB versioning) pozwala nam zachować kontrolę nad wszelkimi zmianami w schemacie. Może to dotyczyć aktualizacji schematu, dodawania lub usuwania tabel, kolumn itp. Oprócz możliwości audytu zmian, dostępne narzędzia pozwalają również swobodnie zarządzać nimi w różnych środowiskach i łatwo je wycofać (rollback), jeśli coś pójdzie nie tak.

W Pretius używamy do tego narzędzia Liquibase i mamy na naszym blogu szczegółowy kurs Liquibase, więc koniecznie go sprawdź. Istnieją jednak inne opcje, takie jak Flyway.

Testy integracyjne

Mam nadzieję, że nie muszę wyjaśniać, dlaczego zawsze należy testować swój kod – to niezbędny etap procesu tworzenia oprogramowania.

Testy integracyjne to bardzo szeroki temat, dlatego ograniczymy się do konkretnego, mniejszego obszaru. Skupimy się mianowicie na testach związanych z persistence layer, czyli na tym, czy nasz kod poprawnie integruje się z bazą danych.

W przypadku frameworków takich jak Hibernate, testy automatycznie generowanego SQL-a mogą mieć mniejszy sens – w końcu niekoniecznie musimy testować bibliotekę, która została już gruntownie przetestowana przez jej twórców. Jednak w przypadku np. MyBatis, gdzie sami piszemy kod SQL, takie testy stają się koniecznością.

@Insert("INSERT INTO OFFERS (offer_name) VALUES (#{offer.offerName});")
@Options(useGeneratedKeys = true, keyProperty = "offer.id")
void save(@Param("offer") Offer offer);

Problem, który chcemy rozwiązać

Rozważymy połączenie tych dwóch aspektów: testów integracyjnych DB i wersjonowania bazy danych.

Jeśli zdecydowałeś już, że w Twoim projekcie wszystkie zmiany w bazie danych są dokonywane za pomocą dedykowanego narzędzia (takiego jak Liquibase), grzechem byłoby nie pójść o krok dalej i nie wykorzystać go w testach integracyjnych.

Przykładowy kod na GitHubie zawiera aplikację opartą na Spring Boot i MyBatis. Jak wspomniałem, ręcznie pisany SQL (do czego zmusza nas MyBatis) otwiera wiele możliwości popełnienia błędu. Niezależnie od tego, czy edytujesz istniejące zapytania, czy piszesz nowe, łatwo o literówkę lub ucięcie końcówki podczas wklejania zapytania z konsoli DB do kodu aplikacji. Pomimo wszystkich swoich zalet, Spring Boot wyłapie taki błąd dopiero w momencie próby wykonania zapytania w runtime. Jeśli będziesz mieć pecha, o błędzie dowiesz się po kilku dniach, co zdecydowanie nie jest pożądaną sytuacją.

Na potrzeby testów wykorzystałem bibliotekę Testcontainers, która pozwala uruchomić wymaganą infrastrukturę (w moim przypadku bazę danych PostgreSQL) podczas testu jednostkowego przy użyciu Dockera.

Oprócz bazy danych, Testcontainers może dostarczyć nam szereg innych elementów naszej infrastruktury: kolejki, cache oraz dokumentowe bazy danych. Polecam lekturę dokumentacji. Możesz odkryć, że testy, które kiedyś wydawały się niemożliwe do napisania, stają się prozaicznie proste.

Dlaczego warto połączyć te dwa elementy?

Ktoś mógłby teraz zapytać: dlaczego nie użyć do takich testów lokalnej bazy danych lub bazy ze środowiska testowego? Jednym z powodów jest powtarzalność. Kluczowe jest, aby uruchamiać testy w tych samych warunkach za każdym razem, a bieżące prace – czy to w środowisku lokalnym, czy testowym – nie powinny wpływać na ich przebieg.

Dzięki Liquibase możesz mieć 100% pewności, że Twoja baza danych będzie spójna z tym, co aktualnie posiadasz w repozytorium. Co więcej, korzystając z Testcontainers, możesz stworzyć niezależne środowisko testowe w zaledwie kilka sekund.

Podsumowując. Elementy składające się na mój przykład to:

Struktura naszej aplikacji wygląda następująco:

A screen showing application structure.

Krótko mówiąc, to nic nadzwyczajnego. Dwie klasy modelu, jedna klasa repozytorium i publiczna fasada z DTO, która dostarcza funkcjonalności „światu”. Obszar Resources będzie ciekawszy. To tutaj znajdują się skrypty SQL Liquibase oraz changelog.

A screen showing the resources area.

Aby uruchomić aplikację za pomocą polecenia spring-boot:run, będziesz potrzebować lokalnej bazy danych, którą możesz szybko stworzyć za pomocą Dockera i pliku docker-compose.yml dołączonego do repozytorium.

docker-compose up -d
./mvn spring-boot:run

Po wykonaniu powyższych komend aplikacja wystartuje, ale proces zakończy się po krótkiej chwili. Niemniej jednak będziesz mógł zaobserwować kilka zmian, które zaszły od momentu jej uruchomienia:

  • W logach powinny pojawić się dwa wpisy:
SQL in file changelog/scripts/01-create-offer-and-product-schema.sql executed
SQL in file changelog/scripts/02-alter-offers-with-uqnique-constraint.sql executed
  • W Twojej lokalnej bazie danych powinny pojawić się 4 nowe tabele:

A screen showing new tables.

Dwie pierwsze tabele są potrzebne do działania Liquibase i tam przechowywane są dane o audycie. Offers i products to natomiast tabele, które celowo utworzyłeś za pomocą skryptów SQL.

Aby było to możliwe, potrzebujesz tylko jednego wpisu w pom.xml i odpowiedniej struktury katalogów w resources. Spring Boot zajmie się resztą.

<dependency>
   <groupId>org.liquibase</groupId>
   <artifactId>liquibase-core</artifactId>
</dependency>

Testowanie

Jak niektórzy z Was zapewne już się domyślili, wiele z tego, o czym tutaj piszę, można wykorzystać w testach bez większego wysiłku.

Jeśli spróbujesz uruchomić test, który podnosi kontekst Springa (raises the spring context), stanie się to samo, co przy uruchamianiu aplikacji.

A screen showing the Spring Boot test.

Liquibase spróbuje utworzyć tabele – jeśli jeszcze nie istnieją – w lokalnej bazie danych. Ale Ty chcesz czegoś innego.

Oczywiste jest, że używanie lokalnej bazy danych w testach jednostkowych to zły pomysł. Z pomocą przychodzi tutaj biblioteka Testcontainers. Możesz jej użyć, aby z powodzeniem uruchomić świeżą bazę danych w formie kontenera, zarówno lokalnie, jak i w ramach CI/CD.

Co musimy zmienić?

Dodaj następującą zależność do pliku pom.xml.

<dependency>
   <groupId>org.testcontainers</groupId>
   <artifactId>postgresql</artifactId>
   <version>1.17.6</version>
   <scope>test</scope>
</dependency>

Plus odrobina konfiguracji:

spring:
 datasource:
   username: test
   password: test
   url: jdbc:tc:postgresql:15.1:///test
   driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
mybatis:
 mapper-locations: classpath:db/mappers/*.xml

Wszechwiedzący Spring Boot już wie, że musi pobrać i uruchomić kontener PostgreSQL 15.1.

Aby udowodnić, że wszystko działa, możesz uruchomić dwa testy. Pierwszy zapisze dane w DB, a drugi odczyta wcześniej zapisane dane. Żadnych mocków. Całe testowanie odbywa się na kodzie produkcyjnym.

@Test
@Order(1)
void shouldCreateAndSaveOffer() {
   //when
   OfferShortInfoDto offer
           = offerFacade.createOffer("TestOfferName", "TestProductName", BigDecimal.valueOf(123.12));
   //then
   Assertions.assertEquals("TestOfferName", offer.getOfferName());
   Assertions.assertEquals(BigDecimal.valueOf(123.12), offer.getTotalPrice());
}
@Test
@Order(2)
void shouldFindAndMapOfferToDto() {
   //when
   OfferShortInfoDto offer = offerFacade.findOfferByOfferName("TestOfferName");
   //then
   Assertions.assertEquals("TestOfferName", offer.getOfferName());
   Assertions.assertEquals(BigDecimal.valueOf(123.12), offer.getTotalPrice());
}

A screen showing the test.

Skąd biorą się błędy w ręcznie pisanym kodzie SQL?

Jak wspomniałem wcześniej, ręcznie pisany SQL pozostawia wiele miejsca na „głupie” błędy. MyBatis daje Ci pełną kontrolę nad wyglądem Twoich zapytań (co jest jego największą zaletą), ale pisanie ich w plikach xml lub w formie adnotacji w klasach Java nie należy do najprzyjemniejszych czynności.

Dlatego najczęstszą praktyką jest pisanie zapytań w konsoli bazy danych, a następnie wklejanie ich do xml. Niestety, składnia MyBatis nie jest w 100% czystym SQL-em. Istnieją różne rodzaje tagów, instrukcje warunkowe, a nawet parametry, które muszą zostać uzupełnione w xml. Myślę, że sam możesz wywnioskować, co to oznacza…

A screen showing the error.

Nawet prosty select z jednym parametrem wciąż daje pole do błędu.

Liquibase i Testcontainers w istniejącym projekcie?

Każdy moment jest dobry, jeśli chcesz wdrożyć wersjonowanie bazy danych w swoim projekcie. Nie powinno być tutaj żadnych problemów technicznych. To po prostu decyzja, którą musi podjąć zespół – że od teraz wszyscy będą używać wybranego narzędzia, np. Liquibase.

A co z Dockerem?

Problem polega na tym, że Liquibase nie będzie w stanie utworzyć bazy danych od zera przy użyciu samych skryptów. Będzie więc potrzebował pomocy.

Jeśli weźmiemy przykład bazy danych PostgreSQL, jedną z możliwości jest stworzenie kopii zapasowej (backup) i przygotowanie na jej podstawie własnego obrazu Dockera. Może to być przydatne, gdy Twoja baza danych jest już duża, a przywracanie jej od zera za każdym razem byłoby czasochłonne. Warto zauważyć, że Testcontainers pozwala na uruchamianie obrazów nie tylko ze swojego repozytorium. W przykładzie użyłem DockerHub do uruchomienia mojego własnego obrazu (custom docker image).

Korzystając z Testcontainers SDK, możesz utworzyć odpowiednią konfigurację, która wskaże na obraz zbudowany wcześniej.

public static PostgreSQLContainer postgresqlContainer = new PostgreSQLContainer(
       DockerImageName.parse("arkadiuszrosloniec/postgres-preloaded-testcontainers:1.0.0")
               .asCompatibleSubstituteFor("postgres")
           )
       .withDatabaseName("db")
       .withUsername("test_user")
       .withPassword("test_password");

Teraz, korzystając z adnotacji JUnit 5, musisz uruchomić kontener i ustawić odpowiednią konfigurację datasource, aby Spring Boot wiedział, gdzie Twoja baza danych wystartowała.

@BeforeAll
public static void setUp() {
   postgresqlContainer.setWaitStrategy(
           new LogMessageWaitStrategy()
                   .withRegEx(".database system is ready to accept connections.\s")
                   .withTimes(1)
                   .withStartupTimeout(Duration.of(60, ChronoUnit.SECONDS))
   );
   postgresqlContainer.start();
}

@DynamicPropertySource
public static void overrideProperties(DynamicPropertyRegistry dynamicPropertyRegistry) {
   dynamicPropertyRegistry.add("spring.datasource.url", postgresqlContainer::getJdbcUrl);
   dynamicPropertyRegistry.add("spring.datasource.username", postgresqlContainer::getUsername);
   dynamicPropertyRegistry.add("spring.datasource.password", postgresqlContainer::getPassword);
   dynamicPropertyRegistry.add("spring.datasource.driver-class-name", 
   postgresqlContainer::getDriverClassName);

Całe rozwiązanie – wraz z własnym obrazem bazy danych – jest dostępne na tej gałęzi na GitHubie.

Podsumowanie

Uważam, że Testcontainers i Liquibase nigdy nie powinny być używane oddzielnie. Jak pokazuje mój przykład, posiadanie narzędzia do wersjonowania bazy danych przy niskim koszcie pozwala na tworzenie testów, które w pełni odzwierciedlają faktyczne działanie kodu. Dzięki Dockerowi masz również pewność, że testy są powtarzalne i żadne czynniki zewnętrzne (np. praca innych członków zespołu) na nie nie wpłyną. Narzędzia te doskonale się uzupełniają.

Jeśli masz dodatkowe pytania, możesz napisać do mnie bezpośrednio na adres arosloniec@pretius.com lub skontaktować się z firmą pod adresem hello@pretius.com.

Inne materiały o Liquibase od deweloperów Pretius

Możesz również sprawdzić inne wpisy na blogu i filmy o Liquibase stworzone przez programistów Pretius:

  1. What is Liquibase and how to start using it? Automate your database scripts deployment with this Liquibase tutorial
  2. Liquibase for teams: GIT collaboration and easy deployment
  3. Boost the management of your Oracle Database version control changes with Liquibase
  4. Liquibase rollback – A smart way to do it with Jenkins
  5. Take control over your database: Automation and CI/CD with Liquibase

Szukasz firmy tworzącej oprogramowanie?

Pracuj z zespołem, który pomógł już dziesiątkom rynkowych liderów. Umów spotkanie, by dowiedzieć się:

  • Jak działają nasze produkty
  • Jak możesz oszczędzić czas i pieniądze
  • Czym nasze rozwiązania różnią się od konkurencji

Przebieg kontaktu z Pretius

Dbamy o bezpieczeństwo Twoich danych: Certyfikat ISO

Działamy zgodnie z normą ISO 27001, zapewniając najwyższy poziom bezpieczeństwa Twoich danych.
certified dekra 27001
© 2026 Pretius. All right reserved.