In deze driedelige serie geef ik een paar tips en recepten om meer uit testautomatisering te halen voor jou als developer. Soms zijn het algemene trucs, maar soms ook hele specifieke oplossingen voor een bepaald probleem. Meestal geef ik bij elke tip praktische voorbeelden met code en testcases. De code is in PHP en de voorbeeld-testcases zijn gemaakt met behulp van PHPUnit. Soms is er een stuk Laravel specifieke code toegepast. Echter, de ideeën zijn ook toepasbaar voor andere programmeertalen. Kortom, in drie delen totaal 14 tips over testautomatisering voor elke developer!

Deel één ging over grenswaarden, tests combineren en TDD en liet ik je zien hoe ik slimmer omga met het schrijven van tests. Dit tweede deel gaat vooral over hoe ik  zorg dat de testruns snel zijn en hoe ik code coverage gebruiken om inzicht te geven. In deel drie (binnenkort online) bespreek ik stubs, polymorphisme en fixtures. Aan het eind van deze serie schrijf je tests (en code) als nooit tevoren!

Tip 1 t/m tip 4 lees je terug in het eerste deel van het drieluik over testautomatisering.
• Tip 1: Test de grenswaarden
• Tip 2: Combineer (integration) tests
• Tip 3: Zorg dat je tests een duidelijke opbouw hebben
• Tip 4: Schrijf eerst de test en dan de code (TDD)

Tip 5: Zorg dat je tests snel zijn

Een volledige testsuite zal al snel bestaan uit honderden, zo niet duizenden tests. Het uitvoeren van al die tests kan na verloop van tijd steeds meer tijd in beslag nemen. Ik zorg er dus altijd voor dat het uitvoeren van de tests snel is. Ik wil immers een korte feedback loop zodat ik snel weet dat alles nog naar behoren werkt of dat er juist fouten gevonden zijn. Ik streef ernaar om een volledige run binnen een paar minuten te doen.

Waar mogelijk parallelliseer ik het uitvoeren van tests. Paratest is bijvoorbeeld een tool dat ik soms gebruik om PHPUnit tests op te delen in meerdere processen. Ik zorg er wel voor dat de tests onderling geen afhankelijkheid hebben! Ik pak het meestal iets pragmatischer aan: Door meerdere stappen in het build-proces te maken die elk een aantal tests uitvoeren. Die stappen voer ik dan tegelijkertijd uit.

Ik gebruik pcov in plaats van XDebug om code coverage te berekenen. Pcov is een PHP module bedoeld om code coverage te berekenen en is te gebruiken vanaf PHP 7. Vanaf PHPUnit 8 en hoger wordt pcov standaard ondersteund. Pcov is tot 5x sneller dan XDebug. Dit scheelt dus aanzienlijk. Op mijn development omgeving staat Xdebug wel aan. Als ik snel een volledige testrun lokaal wil doen, dan zet ik XDebug uit.

Ik test ook niet meer dan nodig. Als ik een test ga schrijven voor een count()-methode op een Collection, dan ga ik niet eerst die Collection vullen met 10.000 gegenereerde classes.

Bij unit tests voor een berekening ga ik ook niet eerst een volledige Laravel applicatie optuigen compleet met migrations, fixtures en testdata. Ik instantieer alleen de class die ik wil unittesten. Ik extend de testcase dus van de standaard TestCase van PHPUnit en niet van die van Laravel:

use PHPUnit\Framework\TestCase;

class CalculatorTest extends TestCase
{
    public function testTheCalculation(): void
    {
        $calculator = new Calculator();

        $calculator->calculate(); // no framework needed
        
        // ...
    }
}

Tip 6: Code coverage is niet het doel maar een middel

Ik streef altijd naar code die 100% gecovered wordt door tests. Dat wil echter nog niet zeggen dat het ook bugvrij is. Als ik de gewenste functionaliteit verkeerd begrepen heb zal ik het niet alleen verkeerd programmeren, ik test ook nog de verkeerde dingen. Fouten maken is nou eenmaal menselijk.

Code coverage wordt over het algemeen uitgedrukt als line coverage. Maar er kan op één regel van alles gebeuren, terwijl er maar een deel van die regel getest wordt. Als een method dus 100 regels code heeft en 90 daarvan worden getest, dan is er een code coverage van 90%. Echter, als er regels zijn met meerdere expressions, dan is de gerapporteerde code coverage hoger dan als je expression coverage zou meten. Code coverage kan dus misleidend zijn.

class SomeClass
{
    public function positive(int $a, int $b): bool
    {
        if ($a > 0 || $b > 0) {
            return true;
        }

        return false;
    }
}

Bovenstaande method heeft een if-statement met twee sub-expressies. Om 100% line coverage te krijgen heb ik maar twee tests nodig. Ik wil ook 100% condition coverage zodat ik de twee condities in het if-statement ook test ($a > 0 en $b > 0). Ik heb dan de volgende scenarios nodig:

  • Als $a > 0 en $b niet
  • Als $b > 0 en $a niet

Door deze scenarios en het scenario waarbij geen van beiden > 0 zijn vast te leggen in tests weet ik nu dat ik genoeg getest heb. Een alternatief is om het if-statement te refactoren naar twee kleine if-statements zodat in de code coverage (lees: line coverage) naar voren komt dat er nog niet voldoende is getest.



Ik vermijd ternary operators en shorthand if-s maar gebruik gewone if-statements. In het codecoverage rapport komt dan netjes naar voren dat er regels niet zijn gecovered door een test en dus hoef ik daar niet zelf over na te denken.



Tip 7: Gebruik transactions bij Integration tests

Omdat integration tests interactie hebben met databases zullen die testen veel minder snel zijn dan unit tests. Voor elke test dient de database (er van uitgaande dat ik die gebruik in een test) weer in een “schone” toestand terechtkomen. Dat betekent dat ik de database moet weggooien, tabelstructuren opnieuw moet aanmaken en eventuele testdata moet importeren. Om dit voor elke test te doen maakt dat de totale testsuite erg langzaam. Het is slimmer om transacties te gebruiken, waarbij ik eenmalig de tabelstructuren opbouw en eventuele testdata importeer en vervolgens een transactie start. Aan het eind van elke test kan ik dan simpelweg de transactie stoppen door een rollback te doen, en mijn database bevindt zich weer in een “schone toestand”!

$ php vendor/bin/phpunit
PHPUnit 8.5.3 by Sebastian Bergmann and contributors.

...............................................................  63 / 232 ( 27%)
............................................................... 126 / 232 ( 54%)
............................................................... 189 / 232 ( 81%)
...........................................                     232 / 232 (100%)

Time: 5.62 minutes, Memory: 372.50 MB

OK (232 tests, 542 assertions)

Hierboven een testrun op een applicatie waarbij géén gebruik gemaakt wordt van transacties. De totale tijd bedraagt 5 minuten en 37 seconden.

$ php vendor/bin/phpunit
PHPUnit 8.5.3 by Sebastian Bergmann and contributors.

...............................................................  63 / 232 ( 27%)
............................................................... 126 / 232 ( 54%)
............................................................... 189 / 232 ( 81%)
...........................................                     232 / 232 (100%)

Time: 48.92 seconds, Memory: 274.50 MB

OK (232 tests, 542 assertions)

Dezelfde testrun op dezelfde applicatie waarbij wel transacties gebruikt worden. Nu duurt het nog geen 49 seconden. Dat is 688% sneller!

Tip 8: Maak code coverage inzichtelijk voor iedereen

Ik zorg ervoor dat coverage makkelijk door iedereen binnen het team ingezien kan worden op een centrale plek. In ons geval gebruiken we codecov.io, maar het kan ook op bijvoorbeeld een wiki of een buildserver. Op deze manier is iedereen op de hoogte van het verloop van de coverage door de tijd heen. Ook kan elk teamlid makkelijk opzoeken welke delen van de applicatie nog niet getest worden.

Ik houdt ook per Pull Request de code coverage bij. Mijn teamleden zien dan wat voor effect het heeft op de code coverage, bijvoorbeeld als er te weinig tests zijn geschreven.

Coverage report voor een Pull Request

In bovenstaande koppeling met codecov.io binnen Bitbucket is een comment op een Pull Request te zien waarbij de totale code coverage 0.3% omhoog gaat. Daarnaast is alle nieuw toegevoegde code 100% gecovered door een of meerdere tests gezien het feit dat er een diff coverage van 100% wordt gerapporteerd.

Wil je meer weten over hoe ik codecov.io implementeerde in onze workflow? Lees dan mijn blogpost "Hoe wij meer, betere en effectievere tests schrijven met codecov".

Tip 9: Schrijf code die makkelijk te testen is

Makkelijk te testen code klinkt makkelijker dan het is. Maar er zijn een paar trucs die ik keer op keer toepas zodat mijn code niet alleen makkelijker te testen is, maar ook nog overzichtelijker en beter leesbaar is.

Ik voorkom harde dependencies in mijn code. Elke keer als ik new <Class> typ vraag ik me af of dat niet beter via Dependency Injection opgelost kan worden. Harde dependencies zijn (bijna) niet te testen. In het onderstaande voorbeeld is er een harde dependency tussen de MailerService en de MailClient:

Ik voorkom harde dependencies in mijn code. Elke keer als ik new <Class> typ vraag ik me af of dat niet beter via Dependency Injection opgelost kan worden. Harde dependencies zijn (bijna) niet te testen. In het onderstaande voorbeeld is er een harde dependency tussen de MailerService en de MailClient:

class MailerService
{
    public function send(/* some parameters */): void
    {
        $mailer = new MailClient(/* ... */));

        // prepare the mail for sending

        $mailer->send($mail);
    }
}

Als ik een unit test schrijf voor deze method zal de mail altijd verstuurd worden. Dat wil ik natuurlijk niet! De harde dependency maakt het onmogelijk de mail niet te versturen. Ik los het als volgt op:

class MailerService
{
    protected MailClient $mailer;

    public function __construct(MailClient $mailer)
    {
        $this->mailer = $mailer;
    }

    public function send(/* some parameters */): void
    {
        // prepare the mail for sending

        $this->mailer->send($mail);
    }
}

Door de MailClient in de unit test te mocken kan ik de send()-method wel testen zonder dat de mail daadwerkelijk verstuurd wordt. Die mock voorkomt dat de echte mailserver benaderd wordt.

Over mocks gesproken: Als ik een test wil schrijven voor een stuk code waarbij veel dependencies zijn die ik eerst moet mocken, dan vraag ik me altijd af of het niet beter was gewest om die class op te splitsen in meerdere classes. Zeker als elke afzonderlijke method van die class niet alle dependencies gebruikt.

De CheckoutService hieronder is een voorbeeld van een class met “te veel” dependencies.

class CheckoutService
{
    public function __construct(
        SalesService $salesService,
        EmailService $emailService,
        ProductService $productService,
        OrderService $orderService,
        PaymentService $paymentService,
        CartService $cartService
    ) {
        // assign to properties
    }

    public function deleteAbandonedCarts(): void
    {
        // some more code here

        return $this->cartService->delete($cartIds);
    }

    // more methods
}

Als ik een integration test wil schrijven voor de deleteAbandonedCarts()-method, moet ik de CheckoutService instantieren en vervolgens alle dependencies die niet gebruikt moeten mocken. De CartService mock ik niet, want die (even hypothetisch gezien) voert de query uit naar de database waarvan ik het resultaat wil testen.

class CheckoutServiceTest extends TestCase
{
    public function testItDeletesAbandonedCarts()
    {
        $cartService = $this->app->make(CartService::class);

        // some code to insert testdata in database

        $service = new CheckoutService(
            $this->createMock(SalesService::class),
            $this->createMock(EmailService::class),
            $this->createMock(ProductService::class),
            $this->createMock(OrderService::class),
            $this->createMock(PaymentService::class),
            $cartService
        );

        $service->deleteAbandonedCarts();

        $this->assertDatabaseHasMissing('carts', ['id' => 1]);
    }
}

Dit maakt mijn test niet alleen onnodig lang, het wordt er ook nog eens minder leesbaar door. En dan is dit nog maar een simpel voorbeeld. Ik ben meer voorstander van Commands. Die hebben als voordeel dat die altijd maar een ding doen. Het wordt geen samengeraapt zooitje van methods die “toevallig” iets te maken hebben met de Checkout. Elk Command heeft alleen een handle()-method. Het Command heeft ook een expressievere naam zodat het meteen duidelijk is wat ie doet. Veel overzichtelijker! Qua refactoring zie ik dit als een “upgrade” van elke method uit een service naar een eigen class.

class DeleteAbandonedCartsCommand
{
    public function __construct(
        CartService $cartService
    ) {
        // assign to property
    }

    public function handle(): void
    {
        // some more code here

        return $this->cartService->delete($cartIds);
    }
}

De bijbehorende test ziet er dan als volgt uit.

class DeleteAbandonedCartsCommandTest extends TestCase
{
    public function testItDeletesAbandonedCarts()
    {
        $cartService = $this->app->make(CartService::class);

        // some code to insert testdata in database

        $service = new DeleteAbandonedCartsCommand($cartService);

        $service->deleteAbandonedCarts();

        $this->assertDatabaseHasMissing('carts', ['id' => 1]);
    }
}

Zowel de class als de bijbehorende testcase zien er al een stuk overzichtelijker uit.

Wil je hier meer over weten? Kijk eens naar Command Query Separation (CQS). Er zijn nog meer voordelen om Commands (en Queries) te gebruiken in je code.

Tot slot

De volgende blogpost in de serie "Haal meer uit testautomatisering" gaat over snelheid van tests, stubs, polymorphisme, lookup maps en fixtures en staat binnenkort online.

Deel 1 van de serie "Haal meer uit testautomatisering" is er ook nog. Die ging onder andere over [grenswaarden, tests combineren en TDD].

Heel veel succes! Heb je vragen of kom je er niet helemaal uit? Mail me gerust, ik help je graag!

Geschreven door: Jeroen Schouten

Meer kennis bijspijkeren? Kom dan naar onze Meetup: Ode aan de Code!

Bekijk onze Meetups

Wij zijn altijd op zoek naar getalenteerde vakgenoten!

Bekijk onze vacatures