Automatyzacja testów integracyjnych z użyciem dockera

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-compose
z 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.