Destructief testen met Testcontainers

Geschreven door Rob van Geloven op

Een vraag waar we in onze dagelijkse werkzaamheden misschien niet direct stil bij staan, maar waarom testen we nu eigenlijk? De antwoorden hierop zijn uiteenlopend en variëren van het wel heel pragmatische “om de code coverage te verhogen” tot de beschrijving van mogelijke gevolgen “om zo min mogelijk bugs te hebben”.

Het antwoord op deze vraag is wat mij betreft “we testen om een optimale dienstverlening voor onze klanten te kunnen garanderen”. Optimaal is hier wat mij betreft het sleutelwoord: we hoeven niet alles perfect te testen. We testen enkel wat noodzakelijk is om de dienstverlening optimaal te kunnen garanderen.

Helaas zien we in de praktijk vaak dat er vooral de focus ligt op het proces: de code coverage moet aan een bepaald percentage voldoen of pull-requests die worden pas goedgekeurd als de nieuwe code ook bijbehorende testen heeft. En hoewel ik een voorstander ben van een goed proces ligt er te vaak de nadruk op randvoorwaarden ten koste van het resultaat.

De testpiramide

Ik merk vaak dat de onderbouwing voor dit soort processen volgt uit de aard van de testen: we gebruiken in de praktijk eigenlijk alleen unittesten. Maar waarom? Als we de testpiramide erbij pakken, zien we dat dit het fundament is, maar dat er nog een hele piramide aan testen op staat:

Afbeelding van een testpiramide

Vaak vloeit dit voort uit het feit dat unittesten doorgaans goed onderhoudbaar zijn en zich ook uitstekend lenen om volledig te automatiseren. Hoe hoger je in de testpiramide komt, hoe meer je bezig bent om de ‘buitenkant’ van de applicatie te testen en minder de ‘binnenkant’. Dat betekent vaak ook dat er allerlei externe afhankelijkheden moeten worden weggemockt.

Wat zijn destructieve testen?

Destructieve testen zijn testen waarbij de staat van een systeem onherroepelijk wordt gewijzigd. Waarbij je dus, om in database termen te blijven, de transactie ook daadwerkelijk commit naar een store. Testen waarbij je de oude files ook daadwerkelijk verwijderd van een file share. Testen die dus niet zo makkelijk nog een keer zijn uit te voeren. Regelmatig zie je dat hier weken voorbereidend werk nodig is om alle testdata goed op te zetten in alle onderliggende systemen om vervolgens na een dag testen eigenlijk weer opnieuw te kunnen beginnen. Dat moet toch anders kunnen!

Een eerste opzet

Maar hoe pakken we zoiets nu aan in de praktijk? Laten we eens kijken naar een veelvoorkomend probleem: het mocken van datastores. Als voorbeeld pakken we een simpele webshop met een database. In deze database zit de volgende tabel:

CREATE TABLE IF NOT EXISTS accounts (
    user_id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    email VARCHAR ( 255 ) UNIQUE NOT NULL,
    password VARCHAR ( 50 ) NOT NULL,
    first_name VARCHAR ( 50 ) NOT NULL,
    last_name VARCHAR ( 50 ) NOT NULL,
    created_on TIMESTAMP NOT NULL,
    last_login TIMESTAMP
);

We gebruiken ORM tooling in de vorm van Entity Framework Core voor het benaderen van onze database. Ons doel is om onze eerste applicatietest te gaan schrijven voor een nieuw stuk functionaliteit waar onze verkopers behoefte aan hebben: het kunnen verwijderen van accounts die nooit actief zijn geweest in de webshop. We maken gebruik van Specflow om onze testgevallen te beschrijven. Specflow is een .NET port van de open source sofware Cucumber. Deze software maakt gebruik van de Gherkin syntax om in natuurlijke taal testgevallen te kunnen beschrijven. Onze tester is al zo vriendelijk geweest om een SpecFlow file aan te leveren waarin de test beschreven staat.

#language: nl-NL

Functionaliteit: Het klantenbestand kan worden opgeschoond

Scenario: Een medewerker kan het klantenbestand opschonen
    Gegeven de medewerker Isabella
    Als de medewerker het klantenbestand opschoont
    Dan bevat het klantenbestand geen inactieve klanten meer die zijn aangemaakt voor 2020-01-01

Omdat we onze database aanspreken via Entity Framework core lijkt het voor de hand te liggen dat we deze afhankelijkheid mocken. Een korte inventarisatie leert ons dat we gelukkig niet DbSet hoeven te mocken maar dat Entity Framework Core een in-memory implementatie heeft die gebruikt kan worden voor testen.

[When(@"de medewerker het klantenbestand opschoont")]
public void WhenDeMedewerkerHetKlantenbestandOpschoont()
{
_numberOfAccountsRemoved = _numberOfAccountsRemoved = _accountService.RemoveAllStaleAccounts();
}

Waarbij de functie “RemoveAllStaleAccounts()” het volgende SQL commando uitvoert: “DELETE FROM Accounts WHERE last_login is null AND created_on < '2020-01-01'” We schrijven onze specflow test en zorgen ervoor dat onze code doet wat hij zou moeten doen. Echter staat onze applicatie nog geen dag live of de functionaliteit, die we dachten goed getest te hebben, blijkt niet te werken.

Wat is er aan de hand?

Omdat we niet gebruik hebben gemaakt van een echte database in onze testen maar slechts een mock hiervan is er een stuk gedrag van de uiteindelijke database ten opzichte van onze mock niet naar voren gekomen: onze mock is niet case sensitive, de database wel. Waarbij in de mock de tabel “Accounts” wel bestaat is deze niet beschikbaar in de echte database waar hij “accounts” heet met enkel kleine letters. Een makkelijk te missen verschil, maar eentje waar we eigenlijk van verwachten dat we deze hadden kunnen ontdekken via onze testen.

Dit onderstreept ook direct het probleem van mocks: hoe goed ze ook zijn en hoeveel ze ook lijken op de echte afhankelijkheden in onze code, uiteindelijk zijn ze slechts representaties van de werkelijkheid en zullen ze nooit volledig sluitend zijn in hun gedrag. Maar een echte database gebruiken in testen wordt vaak gezien als een moeilijke, omslachtige of zelfs onmogelijke stap om te maken, maar is dat ook zo?

Er zijn tegenwoordig een veelvoud aan ondersteunende tools om juist de externe afhankelijkheden te mocken in plaats van de code. Voorbeelden hiervan zijn de Azurite emulator voor het testen van software met een afhankelijkheid van Azure Storage of WireMock voor het testen van software met een afhankelijkheid op externe HTTP servers.

Testcontainers

Testcontainers zijn een mogelijk antwoord op het fundamentele probleem dat we niet testen met echte afhankelijkheden. Met Testcontainers kunnen we lichtgewicht instanties maken van zaken als databases, API’s, of wat er ook maar in een Docker container past. Vervolgens kunnen we deze instanties gebruiken in onze, al dan niet, geautomatiseerde testen.

Dat klinkt misschien nog wat hoog over, maar laten we het voorbeeld van onze webshop pakken om er een concreet voorbeeld van te maken. De eerste vraag is natuurlijk: hoe maak je een Testcontainer en hoe start je deze vervolgens? Allereerst moeten we het package Testcontainers installeren via het commando ‘dotnet add package Testcontainers --version 2.2.0’.

Vervolgens hebben we met deze kleine console applicatie al onze eerste Testcontainer draaiend:

using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;

var testcontainersBuilder =
    new TestcontainersBuilder<TestcontainersContainer>()
    .WithImage("hello-world")
    .WithName("hello-world")
    .WithOutputConsumer(Consume.RedirectStdoutAndStderrToConsole());

await using (var testcontainers = testcontainersBuilder.Build())
{
    await testcontainers.StartAsync();
    Console.ReadKey();
}

Meer is er niet nodig: onder de motorkap zorgt Testcontainers voor het binnenhalen van het Docker image en het instantiëren van de container.

Als we vervolgens de database van de webshop erbij pakken dan zou dat er als volgt uit kunnen zien:

public PostgreSqlFixture()
{

var testcontainersBuilder =
new TestcontainersBuilder<PostgreSqlTestcontainer>()
.WithDatabase(new PostgreSqlTestcontainerConfiguration()
{
Database = "db",
                    Username = "db_user",
                    Password = "db_password",
})
.WithOutputConsumer(Consume.RedirectStdoutAndStderrToConsole())
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted($"pg_isready -h 'localhost' -p '5432'"));

            Container = testcontainersBuilder.Build();
}

public async Task UseBackupFile(byte[] backupFile)
{
await Container.CopyFileAsync("/tmp/db_backup.dump", backupFile);

var command = "pg_restore --username=db_user --dbname=db -1 /tmp/db_backup.dump";

await Container.ExecAsync(command.Split(' '));
}

Laten we kijken wat dit stuk code voor ons doet, allereerst is het belangrijk om te zien dat we in plaats van de generieke “TestcontainersContainer” in de TestContainersBuilder we nu gebruik maken van de class “PostgreSqlTestcontainer”. Dit is een class van het Testcontainers package die een aantal PostgreSql specifieke zaken al voor ons heeft geconfigureerd.

Met het “WithDatabase” commando specificeren we een aantal extra opties de willen gebruiken voor onze database, namelijk welke database we willen gebruik, en met welke gebruikersnaam en wachtwoord we straks op de database kunnen inloggen.

De regel “WithOutputConsumer” is voor productie scenarios niet per se nodig, deze regel zorgt er voor dat we via de console de output van het Docker image kunnen zien. Dit is vooral handig tijdens debuggen en niet nodig tijdens het uitvoeren van de geautomatiseerde testen.

De regel “WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted(\$"pg_isready -h 'localhost' -p '5432'"))” is een belangrijke. Dit commando blokkeert executie van ons programma totdat de database opgestart is. Dit is belangrijk want als we direct verder zouden gaan en willen interacteren met de container dan zou dit onherroepelijk leiden tot fouten aangezien de database nog niet beschikbaar is.

Met het commando “StartAsync” starten we de container om vervolgens met het commando “CopyFileAsync” een bestand van de lokale storage naar de container te kopiëren.

Als laatste stap herstellen wij de database met het backup bestand “db_backup.dump” via het commando “ExecAsync”.

Integratie met een geautomatiseerde test

Nu we een instantie hebben van onze database, willen we deze graag gebruiken in onze SpecFlow test. Belangrijke tip hierbij is om deze container bij voorkeur slechts één keer per feature op te starten. Ondanks dat de container binnen een aantal seconden gestart is en de backup is teruggezet kan het bij grote hoeveelheden testgevallen al snel vertragend gaan werken. Het continue downloaden van alle Docker images kan al snel oplopen tot vele minuten extra tijd tijdens het testen, afhankelijk van hoeveel images er benodigd zijn.

In de “BeforeFeature” kunnen we dan dit stuk code toevoegen:

[BeforeFeature]
public static async Task BeforeFeature(FeatureContext featureContext)
{
PostgreSqlFixture postgreSqlFixture = new();

await postgreSqlFixture.InitializeAsync();
await postgreSqlFixture.UseBackupFile(await File.ReadAllBytesAsync("Support/db_backup.dump"));

var optionsBuilder = new DbContextOptionsBuilder<StoreContext>();
optionsBuilder.UseNpgsql(postgreSqlFixture.Connection!);

featureContext.Set(postgreSqlFixture);
}

Dit stuk code instantieert onze Entity Framework Core DbContext met connectionstring van onze Testcontainer. Hiermee kunnen we vervolgens onze SpecFlow test onveranderd uitvoeren met een kopie van onze live database, die we conform AVG wetgeving hebben geanonimiseerd, in plaats een mock. De test zal uiteraard falen omdat we niet een juist SQL commando proberen uit te voeren, maar hiermee hebben we de bug gevonden voordat hij naar productie ging.

Testontainers als testomgeving

Met Testcontainers kunnen we meer dan slechts een enkele container opstarten, we kunnen er in principe alles mee wat Docker ondersteunt. Als voorbeeld hiervan gaan we ook de UI testen van onze applicatie als Testcontainers opzetten. De volledige broncode van dit voorbeeld is te vinden op de GitHub site van XPRTZ.

Allereerst moeten we er voor zorgen dat onze webapplicatie als Docker container beschikbaar is om te gebruiken, dit kan door simpelweg een dockerfile in de directory te plaatsen waar ook de solution file staat. Vervolgens creëren we een class die de interface IDockerImage van Testcontainers en de interface IAsyncLifetime van XUnit implementeert. In de functie “InitializeAsync” voegen we vervolgens deze code toe:

public async Task InitializeAsync()
{
    await _semaphoreSlim.WaitAsync();

    try
    {
        await new ImageFromDockerfileBuilder()
            .WithName(this)
            .WithDockerfileDirectory(CommonDirectoryPath.GetSolutionDirectory(), string.Empty)
            .WithDockerfile("Dockerfile")
            .WithBuildArgument("RESOURCE_REAPER_SESSION_ID", ResourceReaper.DefaultSessionId.ToString("D"))
        .WithDeleteIfExists(false)
        .Build();
    }
    finally
    {
        _semaphoreSlim.Release();
    }
}

Met behulp van deze class hebben we nu een Testcontainer image gedefinieerd welke we nu kunnen gebruiken in onze testen. Het stuk code voor het initialiseren van de database container breiden we uit met het statement “WithNetworkAliases” wat er voor zorgt dat we de database niet alleen kunnen benaderen met een ip-adres maar ook met een hostnaam.

Vervolgens creëren we een Docker network zodat de twee containers met elkaar kunnen communiceren:

_demoAppNetwork = new TestcontainersNetworkBuilder()
          .WithName(Guid.NewGuid().ToString("D"))
          .Build();

Als laatste stap creëren we de Testcontainer voor de frontend:

new TestcontainersBuilder<TestcontainersContainer>()
          .WithImage(Image)
          .WithNetwork(_demoAppNetwork)
          .WithPortBinding(DemoAppImage.HttpsPort, true)
          .WithEnvironment("ASPNETCORE_URLS", "https://+")
          .WithEnvironment("ASPNETCORE_Kestrel__Certificates__Default__Path", DemoAppImage.CertificateFilePath)
          .WithEnvironment("ASPNETCORE_Kestrel__Certificates__Default__Password", DemoAppImage.CertificatePassword)
          .WithEnvironment("ConnectionStrings__StoreConnectionString", connectionString)
          .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(DemoAppImage.HttpsPort))
          .Build();

Nu we alle onderdelen hebben kunnen we deze stack gebruiken in onze testen:

public async Task Get_Accounts_Should_Return_100_Pages_Of_Accounts()
{
    // Arrange
    string ScreenshotFileName() => $"{nameof(Get_Accounts_Should_Return_100_Pages_Of_Accounts)}_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}.png";

    using var chrome = new ChromeDriver(_chromeOptions);

    // Act
    chrome.Navigate().GoToUrl(_demoAppContainer.BaseAddress);

        chrome.GetScreenshot().SaveAsFile(Path.Combine(CommonDirectoryPath.GetProjectDirectory().DirectoryPath, ScreenshotFileName()));

        chrome.FindElement(By.Id("accounts_link")).Click();

        await Task.Delay(TimeSpan.FromSeconds(5));

    chrome.GetScreenshot().SaveAsFile(Path.Combine(CommonDirectoryPath.GetProjectDirectory().DirectoryPath, ScreenshotFileName()));

        // Assert
        var span = chrome.FindElement(By.ClassName("pager-display")).FindElement(By.TagName("span"));

        span.Text.Should().Be("1 of 100");
}

Door het runnen van deze test worden de volgende stappen uitgevoerd:

  1. Er wordt een network aangemaakt
  2. De database wordt opgestart, gerestored en aangemeld op het netwerk
  3. De frontend wordt gecompileerd en er wordt een binary van gemaakt
  4. De frontend wordt opgestart en aangemeld op het netwerk
  5. De automatische testen worden uitgevoerd
  6. Het network, de database en de frontend worden opgeruimd

Hiermee hebben wij dus on-the-fly een testomgeving gecreëerd waarin we representatieve testen kunnen uitvoeren!

Deze test duurt echter wel langer dan de voorgaande test, namelijk 20 a 30 seconden in totaal. Deze extra tijd zit voornamelijk in het aanmaken van een eigen image en het opstarten van de extra container. Eenmaal gestart verlopen de testen net zo snel alsof we alle afhankelijkheden hadden weggemockt. Om deze reden is het dan ook zeer aan te raden om hier gebruik te maken van één Testcontainer set per logische set aan testen.

Een andere punt van aandacht is het cachen van Docker images: indien gebruik wordt gemaakt van een CI/CD pipeline kan het gebeuren dat bij iedere run de images opnieuw worden gedownload wat resulteert in tests die, afhankelijk van de netwerksnelheid, een flink aantal minuten langer kunnen duren.

Tot slot

Met behulp van testcontainers is het mogelijk om volledig geautomatiseerd een complete en complexe testomgeving op te zetten. Deze kan vervolgens gebruikt worden om geautomatiseerde testgevallen uit te voeren die de applicatie in een “productie like” omgeving testen. Met een integratie in een CI/CD pipeline is het vervolgens mogelijk om deze testgevallen al in een vroeg stadium uit te voeren en kunnen we al veel eerder zien hoe onze applicatie zich gedraagt wanneer deze op een live omgeving deployed is.

En hiermee komen we tot een belangrijke vraag: hebben we nog wel een testomgeving nodig?

We hebben met Testcontainers de mogelijkheid om een complete testomgeving geautomatiseerd op te zetten en deze alleen gebruiken wanneer we ook daadwerkelijk testen. Hiermee kunnen we voorspelbaarder en goedkoper testen: we hebben tenslotte de omgeving alleen “aan” staan wanneer hij in gebruik is en hebben volledige controle over de data die aanwezig is in de omgeving.

Ik denk dat de tijd is aangebroken om van de klassieke "ontikkel, test, acceptatie en productie” omgevingen over te stappen op “ontwikkel, acceptatie en productie” omgevingen.

Wie doet er mee?

Rob van GelovenRob is een breed ontwikkelde en ervaren .NET expert, met focus op backend software development en sofware architectuur. Rob werkt op dit moment bij BMN waar hij ondersteund bij het ontwerpen en implementeren van een nieuw integratie platform.
← Terug
XPRTZ