20.07.20226 min

Łukasz TkaczykSenior QA - Automation EngineerAsseco Poland S.A.

Jak tworzyć testy integracyjne z Testcontainers

Poznaj Testcontainers, czyli bibliotekę Javy ze wsparciem dla testów opartych o Junit i łatwo pisz testy integracyjne.

Jak tworzyć testy integracyjne z Testcontainers

Testcontainers jest biblioteką języka Javy, ze wsparciem dla testów opartych o Junit, dostarczająca łatwy dostęp do serwisów jednorazowego użytku opartych o kontenery Dockerowe. Może to być baza danych, własny serwer z udostępnionym API czy serwis zawierający instancje przeglądarek ze wsparciem dla Selenium.

It’s still magic even if you know how it’s done.

Terry Pratchett

Co to dla nas oznacza ? Jeśli potrzebujemy zewnętrznego źródła do testowania naszej aplikacji możemy wystartować kontener Dockerowy z tym źródłem na czas testu, lub “co znacznie lepsze” na czas developowania testów. Poniżej pokażę jak to zrobić.


Podstawa rozwiązania – AbstractContainerBase

Stwórzmy główną klasą, w której wskażemy z jakich obrazów dockerowych będziemy korzystać. 

@Testcontainers

public class AbstractContainerBase {}

Parametryzujemy w niej swoje kontenery dockerowe i je inicjalizujemy. Będziemy je wykorzystywać w celu uruchomienia jednej instancji naszej aplikacji w kontekście Spring oraz będą z niej korzystać naszej klasy testowe.

Dzięki temu kontener i skrypty inicjujące odpalane są jednokrotnie, podczas gdy pozostałe części kodu można testować na „żywym” środowisku.

Co powinno się w niej znaleźć:

Zmienne statyczne przechowujące dane wykorzystywane w kontenerach, np.,

public static Integer port;

public static String restUrl;

 
public static OracleContainer oracleContainer;

public static final String ADMIN_USER = "OCP_ADMIN";

public static final String ADMIN_PASSWORD = "admin";

 
public static final String BANKING_USER = "BANKING_ADMIN";

public static final String BANKING_PASSWORD = "admin";

public static final String BANKING_RANDOM_USER = BANKING_USER + "_" + RandomStringUtils.randomAlphabetic(10);

 
public static final String GUARDIAN_USER = "GUARDIAN_ADMIN";

public static final String GUARDIAN_PASSWORD = "admin";

public static final String GUARDIAN_RANDOM_USER = GUARDIAN_USER + "_" + RandomStringUtils.randomAlphabetic(10);

Deklaracje zmiennych kontenerów, wraz z ich pożądanymi portami (jeśli to konieczne), np.

public static Network network = Network.newNetwork();

//RABBITMQ

private static final int RABBITMQ_PORT = 5672;

public static GenericContainer rabbitMQContainer;

 
//ebp-data-seeder

public static final int DATA_SEEDER_PORT = 3005;

public static GenericContainer dataSeederContainer;

 
//MockServerContainer for CL and Guardian

public static MockServerContainer mockServerContainer;

 

Trick ;)

Używając klasy Network możemy tworzyć sieci, a w nich startować określone kontenery. Dzięki temu komunikacja pomiędzy test-kontenerami w jednej sieci jest możliwa bez konieczności wystawiania określonych portów na zewnątrz kontenera.


Uwzględnienie pipeline’ów CI/CD vs local developement

Startowanie aplikacji do developmentu lokalnego na maszynie użytkownika jest zimplementowane w klasie TestApplication, tam też ustalany jest kontekst aplikacji.

W przypadku przygotowania rozwiązania dla pipeline’a konieczna jest odpowiednia konfiguracja:

@BeforeAll

static void start() {

    if (System.getenv("CI") != null) {

        if (appContext == null) {

            port = RandomUtils.nextInt(55535, 65535);

            System.setProperty("server.port", String.valueOf(port));

            SpringApplication application = Application.createSpringApplication();

            application.addInitializers(new AbstractContainerBase.Initializer());

            try {

                appContext = application.run();

            } catch (Exception e) {

                log.error("failed to start application", e);

            }

            log.info("CI mode");

            Thread cleanContainersHook = new Thread(() -> {

                AbstractContainerBase.dataSeederContainer.stop();

                AbstractContainerBase.rabbitMQContainer.stop();

                AbstractContainerBase.oracleContainer.stop();

                AbstractContainerBase.mockServerContainer.stop();

                AbstractContainerBase.kafkaContainer.stop();

            });

            Runtime.getRuntime().addShutdownHook(cleanContainersHook);

            log.info("CI addShutdownHook");

        } else {

            log.info("Application already started and reused in CI mode");

        }

    } else {

        port = 8080;

        System.setProperty("server.port", String.valueOf(port));

        log.info("Dev mode, application should be started using TestApplication.java main");

    }

    restUrl = "http://localhost:" + port + "/banking-service/api";

}

Używając adnotacji @BeforeAll odpalamy funkcję start() przed wykonaniem jakichkolwiek innych operacji. W niej sprawdzane jest czy zmienna środowiskowa CI (domyślnie ustawiana na runnerach Gitab) istnieje, po czym następuje przypisanie portów i  wystartowanie aplikacji springowej z wykorzystaniem Initializerów. Initializery podmieniają „w locie” konfiguracje serwisów aplikacji na pobrane z aktywnych TestContainersów.

Dodatkowo po zakończeniu prac kontenery są wyłączane za pomocą shutDownHook.

Konfiguracja kontenerów:

static void init() {

    oracleContainer = new OracleContainer(OracleTestImages.ORACLE_IMAGE)

            .withNetwork(network)

            .withNetworkAliases("oracle")

            .withUsername(ADMIN_USER)

            .withPassword(ADMIN_PASSWORD);

    oracleContainer.start();

 
    dataSeederContainer = new GenericContainer(DataSeederTestImages.DATASEEDER_IMAGE)

            .dependsOn(oracleContainer)

            .dependsOn(mockServerContainer)

            .withNetwork(network)

            .withNetworkAliases("dataSeeder")

            .withExposedPorts(DATA_SEEDER_PORT)

            .withEnv("BANKING_SCHEMA_DB_IP", "oracle")

            .withEnv("BANKING_SCHEMA_DB_PORT", "1521")

            .withEnv("BANKING_SCHEMA_DB_SID", oracleContainer.getSid())

            .withEnv("BANKING_SCHEMA_DB_USERNAME", BANKING_RANDOM_USER)

            .withEnv("BANKING_SCHEMA_DB_PASSWORD", BANKING_PASSWORD)

            .withEnv("MOCKSERVER_HOST", mockServerContainer.getHost())

            .withEnv("MOCKSERVER_PORT", String.valueOf(mockServerContainer.getServerPort()))

            .withCommand("lerna run api");

    dataSeederContainer.start();

}

Kontenery konfigurowane są w funkcji init(). Dostępne jest kilka rodzajów kontenerów, od GenericContainer dla własnych obrazów kontenerowych, po standaryzowane jak OracleContainer pod bazy danych Oracla czy PostgreSQLContainer do PostgreSQL lub MockServerContainer.

W zależności od wybranego rodzaju kontenera należy podać odpowiedni obraz dockerowy i skonfigurować testcontainer przed wystartowaniem.

Polecenie dependsOn wymaga aby docker wystartował dopiero po uruchomieniu przekazanego kontenera jako argumentu, withNetwork określa, do której sieci należał będzie startowany docker, a withExposedPorts decyduje na jakim porcie dostępny ma być serwis wystartowany dockerowo.

Ważną częścią konfiguracji jest polecenie withEnv, które w trakcie uruchamiania TestContainersa jest w stanie podmienić zawartość pliku .env w startowanej aplikacji na przekazaną w konfiguracji.


Initializer

public static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

    @Override

    public void initialize(ConfigurableApplicationContext applicationContext) {

        init();

        //initdb and profiles

        TestPropertyValues.of(

                "spring.profiles.active=cotOnline,rabbitmq,C",

                "eureka.client.enabled=false",

                "spring.datasource.url=" + oracleContainer.getJdbcUrl(),

                "spring.datasource.username=" + BANKING_RANDOM_USER,

                "spring.datasource.password=" + BANKING_PASSWORD,

                "spring.guard.datasource.hikari.jdbcUrl=" + oracleContainer.getJdbcUrl(),

                "spring.guard.datasource.hikari.username=" + GUARDIAN_RANDOM_USER,

                "spring.guard.datasource.hikari.password=" + GUARDIAN_PASSWORD,

                "spring.flyway.enabled=true",

                "spring.flyway.locations=classpath:flyway/sql/oracle"

        ).applyTo(applicationContext);

        //rabbit values

        TestPropertyValues.of(

                "com.asseco.omni.config.rabbit.host=" + rabbitMQContainer.getHost(),

                "com.asseco.omni.config.rabbit.port=" + rabbitMQContainer.getMappedPort(RABBITMQ_PORT),

                "com.asseco.omni.config.rabbit.username=" + "guest",

                "com.asseco.omni.config.rabbit.password=" + "guest"

        ).applyTo(applicationContext);

        //guardian-service

        TestPropertyValues.of(

                "guardian-service.ribbon.listOfServers=" + mockServerContainer.getHost() + ":" + mockServerContainer.getServerPort()

        ).applyTo(applicationContext);

    }

}

Wcześniej wymieniony initializer jest statyczną klasą posiadającą jedną metodę, w której nadpisujemy konfiguracje parametrów z jakimi wystartuje aplikacja. Warto zauważyć, że nadpisujemy je po części parametrami pobranymi z wcześniej skonfigurowanych dockerów w metodzie init().


Development lokalny - TestApplication

Klasa TestApplication zawiera tylko dwie metody i uruchamiana jest w celu lokalnego developmentu aplikacji czy przypadków testowych.

Uwaga: W głównej klasie Application.java projektu należy dodać metodę createSpringApplication();

public static SpringApplication createSpringApplication() {  return new SpringApplication(Application.class);}

Main () -

public static void main(String[] args) {  SpringApplication application = Application.createSpringApplication();

  // Here we add the same initializer as we were using in our tests...

  application.addInitializers(new AbstractContainerBase.Initializer());

  // ... and start it normally

  try {

    application.run(args);

  } catch (Exception e) {

    log.error("failed to start application", e);

  }

  DotEnvFile.writeDotEnvFile(AbstractContainerBase.dataSeederContainer.getHost(),

                             AbstractContainerBase.dataSeederContainer.getMappedPort(AbstractContainerBase.DATA_SEEDER_PORT),

          AbstractContainerBase.mockServerContainer.getHost(),

          AbstractContainerBase.mockServerContainer.getServerPort());

  AbstractContainerBase.initEBP();

}

W głównej metodzie klasy standardowo tworzymy aplikację Spring, a następnie wywołujemy z niej metodę addInitializers odnoszącą się do wcześniej opisanej klasy AbstractContainerBase i jej klasy Initializer. Startujemy aplikację, a następnie do pliku .env zapisywane są wybrane parametry z jakimi wystartowały nasze TestContainersy.

Druga metoda odpowiedzialna jest za zatrzymanie uruchomionych kontenerów.

@Override protected void finalize() throws Throwable {  // clean up logic

  AbstractContainerBase.dataSeederContainer.stop();

  AbstractContainerBase.rabbitMQContainer.stop();

  AbstractContainerBase.oracleContainer.stop();

  AbstractContainerBase.mockServerContainer.stop();

}


Wykorzystanie w praktyce

Startując lokalnie klasę TestApplication jesteśmy w stanie rozwijać przypadki testowe bez konieczności restartowania całej aplikacji za każdym razem kiedy wprowadzamy zmianę, równocześnie w trakcie trwania testów w CI/CD czas przebiegu pipeline’a jest wielokrotnie skrócony dzięki temu, że aplikacja wstaje tylko raz na całą paczkę testów. 


Przykładowy przypadek testowy

Najważniejszym elementem jest uruchomienie TestApplication jako oddzielny proces, wówczas możemy pisać klasy testowe pamiętając o rozszerzaniu ich funkcjonalności z AbstractContainerBase.

class GetAccountQueryIT extends AbstractContainerBase {

 
    @Test

    @SneakyThrows

    void GetAccountQuery_OK() {    	//Kod przypadku testowego}

}

Przypadek może zostać uruchomiony natychmiastowo bez potrzeby ponownego startowania całej aplikacji. Nie mamy tutaj dostępu do komponentów SpringBoot, mamy za to izolację testów. Have fun using it ;)

<p>Loading...</p>