Automatyzacja testów integracyjnych z użyciem dockera

Opublikowane przez admin w dniu

Cześć!

Dziś postaram się opowiedzieć trochę o automatyzacji testów integracyjnych przy pomocy dockera.

 

Według klasycznej piramidy testów, testy integracyjne powinny stanowić 20% wszystkich testów. Piramida ta jednak odnosiła się do aplikacji monolitycznych.

W przypadku rozproszonej aplikacji ilość testów integracyjnych powinna się zwiększać. Oczywiście, jak zwykle, to zależy od indywidualnej aplikacji. Większe skupienie na testach integracyjnych stosuje np. Spotify. Można o tym przeczytać tutaj. Spotify stosuje rozkład testów w postaci plastra miodu.

W skrócie:

  • Testy zintegrowane testują pełną integracje pomiędzy usługami. Są one ciężkie w konfiguracji, długotrwałe i kruche.
  • Testy integracyjne są bardziej wyizolowane i obejmują pojedynczą usługę. Dodatkowo, nadal wymagają sporo konfiguracji.
  • Testy szczegółów implementacji skupiają się na logice biznesowej w naszym serwisie.

W powyższym plastrze miodu brakuje mi jeszcze testów kontraktów, które mogą z powodzeniem jeszcze bardziej zredukować ilość testów typu integrated.

W .NET Core testy integracyjne są o wiele łatwiejsze w konfiguracji. Aplikacja bez problemu może być uruchamiana w pamięci. Wystarczy w projekcie testowym zainstalować kilka paczek nugetowych.

  • Microsoft.AspNetCore.Mvc
  • Microsoft.AspNetCore.Mvc.Core
  • Microsoft.AspNetCore.Diagnostics
  • Microsoft.AspNetCore.TestHost
  • Microsoft.Extensions.Configuration.Json

Następnie w testowym projekcie następujący kawałek kodu będzie hostował naszą aplikacje w pamięci. Zwykle taka konfiguracja powinna być wystarczająca.

   var contentRootPath = Path.Combine(Directory.GetCurrentDirectory());
   var configurationBuilder = new ConfigurationBuilder()
                                 .SetBasePath(contentRootPath)
                                 .AddJsonFile("appsettings.json");


   var builder = new WebHostBuilder()
                    .UseContentRoot(contentRootPath)
                    .UseEnvironment("Development")
                    .UseConfiguration(configurationBuilder.Build())
                    .UseStartup<Startup>();

   testServer = new TestServer(builder);
   client = testServer.CreateClient();

Jednak zwykle w testach integracyjnych potrzebujemy zewnętrznej infrastruktury. Na przykład bazę danych, gdy chcemy ją wypełnić danymi przed testem, czy szyny komunikatów, gdy chcemy sprawdzić, czy nas serwis opublikował jakąś wiadomość. Z automatyzacją tego procesu może nam pomóc docker i docker-compose.

Automatyzacja tego procesu (np. w procesie CI/CD) zawiera się w kilku krokach.

1. Stworzeniedockerfile dla projektu testowego, który mógłby wyglądać mniej więcej tak:

FROM microsoft/dotnet:2.2-sdk AS build-env 
WORKDIR /app 

COPY /tests/MyIntegrationTests.csproj ./tests/MyIntegrationTests.csproj 
COPY /src/MyService.csproj ./src/MyService.csproj 

RUN dotnet restore nuget-source \ ./IntegrationTests/MyIntegrationTests.csproj 
RUN dotnet restore nuget-source \ ./src/MyService.csproj 

COPY /src ./src RUN dotnet build ./src 
COPY /tests ./tests

RUN dotnet build ./tests/ 
ENTRYPOINT ["dotnet", "test","./tests/MyIntegrationTests.csproj","--no-build","--no-restore"]

    2. Stworzenie pliku docker-compose w którym oprócz naszej infrastruktury musimy  zdefiniować health checki oraz poczekać, aż nasza infrastruktura będzie gotowa do uruchomienia testów.

    version: "2.1"
    services:
      mongodb_test:
        image: mongo:3.6.6     
      rabbitmq_test:
        image: rabbitmq:3-management 
        healthcheck: 
            test: ["CMD", "rabbitmqctl", "status"]
            interval: 10s
            timeout: 02s
            retries: 5 
      tests:
        build: 
          context: ./
          dockerfile: Dockerfile.integration  // dockerfile naszych testów      
        depends_on: // czekamy aż nasza infrastruktura będzie gotowa
            mongodb_test:
              condition: service_started
            rabbitmq_test:
              condition: service_healthy

    3. Na serwerze CI musimy teraz uruchomić naszego docker-compose oraz sczytać kod wyjścia. Możemy to zrobić w powershellu, czy innym języku skryptowym, którego używamy. Gdy testy się powiodą, nasza komenda powinna zwrócić kod wyjścia 0.  Na przykład w jenkinsie możemy użyć klauzuli try catch. Nasz skrypt w powershellu może wyglądać następująco:

    $process = start-process cmd.exe -windowstyle Hidden -ArgumentList 
     "/c docker-compose up --build --renew-anon-volumes --no-start 
     & docker-compose run --rm tests" -PassThru -Wait
    $process.ExitCode

    Parametr `–renew-anon-volumes` zapewni nam, że nasze wolumen będzie zawsze postawiony od zera. `–build` przebuduje nasze obrazy. Ma to zapobiec uruchomieniu starych/wcześniejszych testów. `–no-start` nie uruchomi naszych kontenerów. Zrobimy to w następnej komendzie run ponieważ jest w ten sposób łatwiej wyciągnąć kod wyjścia naszych testów. Na koniec usuwamy nasz kontener z testami.

     

    Powyższy plan działania z sukcesem zautomatyzuje nasz testy integracyjne. Jednak jest kilka mankamentów z tym rozwiązaniem. Po pierwsze, dla każdego projektu testów integracyjnych musimy tworzyć 2 pliki – dockerfile oraz docker-compose. W erze mikroserwisów tych projektów może być sporo. Po drugie, jeśli chcemy odpalić testy lokalnie, to najpierw musimy uruchomić jakimś narzędziem docker-compose , poczekać aż infrastruktura będzie gotowa, potem uruchomić testy i ewentualnie usunąć kontenery po zakończonych testach. Spełnienie tych kroków kłóci się trochę z ideą testów automatycznych. Więc…

    WaitForDocker

    Aby zautomatyzować powyższy proces napisałem bibliotekę, która to umożliwia. Jej algorytm przedstawia się następująco: 

    1. Sczytaj plik docker-compose

    2. Wydziel usługi i ich porty ze znacznika services

    3. Sprawdź, czy powyższe porty są już aktualnie zajęte

    4. Uruchom docker-compose

    5. Uruchom zdefiniowane health checki i czekaj, aż wszystkie zwrócą pozytywną odpowiedź. Jeśli któryś z nich nie powiódł się, rzuć wyjątek.

     

    Wszystkie powyższe operacje mają dosyć szczegółowe logowanie. Na ten moment dostępne health checki to TCP, HTTP oraz COMMAND. Domyślnie WaitForDocker użyje health checku typu TCP i sprawdzi, czy dany port jest otwarty.

    Przykładowe użycie WaitForDocker dla RabbitMQ może wyglądać tak:

    var config = new WaitForDockerConfigurationBuilder()
                     .AddHealthCheck(check => check.WithCmd("rabbitmq", "rabbitmqctl status"))
                     .Build();
    await WaitFor.DockerCompose(config);

    Domyślnie uruchomiona zostanie następującą komenda:

    docker-compose -p  waitfordocker up -d --no-color --renew-anon-volumes

    Przebieg całego procesu zostanie zalogowany. Po uruchomieniu biblioteki na serwerze CI (w tym przypadku AppVeyor) dostajemy opis całego procesu: 

     

    Po uruchomieniu testów możemy zatrzymać nasze kontenery.

    await WaitFor.DockerKill();

    Więcej o samej bibliotece można przeczytać tutaj.

    Podsumowanie

    Automatyzacja testów integracyjnych z użyciem dockera nie jest skomplikowana. Jednakże główną jej wadą jest to, że lokalnie chcemy uruchamiać nasze testy z poziomu Visual Studio. W tej sytuacji musimy uruchomić najpierw docker-composez naszą infrastrukturą, a dopiero potem testy. W przypadku WaitForDocker mamy wszystko w jednym miejscu. Poza tym, osobiście o wiele bardziej wolę pisać kod w C#, niż w yamlu.