[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.
Zanim przejdziemy do przykładu, warto zapoznać się z poniższymi pojęciami, ponieważ będą one przedmiotem naszych rozważań.
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.
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);
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.
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:
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.
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:
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
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>
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.
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.
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());
}
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…
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.
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.
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.
Możesz również sprawdzić inne wpisy na blogu i filmy o Liquibase stworzone przez programistów Pretius: