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 zien hoe ik slimmer omga met het schrijven van tests. Het tweede deel ging over snellere testruns, en coverage gebruiken om inzicht te geven. Dit derde en laatste deel laat ik zien hoe stubs, polymorphisme, lookup maps en fixtures toepas om mijn tests simpeler te maken. En het komt de kwaliteit van mijn code ook ten goede.  Aan het eind van deze serie schrijf je tests als nooit tevoren!

Tip 1 t/m tip 4 lees je terug in het eerste deel van dit drieluik over testautomatisering.
• Tip 1: Test de grenswaarden
• Ttip 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 t/m 9 lees je terug in het eerste deel van dit drieluik over testautomatisering.
• Tip 5: Zorg dat je tests snel zijn
• Tip 6: Code coverage is niet het doel maar een middel
• Tip 7: Gebruik transactions bij integration tests
• Tip 8: Maak code coverage inzichtelijk voor iedereen
• Tip 9: Schrijf code die makkelijk te testen is

Tip 10: Gebruik eens een stub in plaats van een mock

Meestal gebruik ik mocks om een deel van mijn code geïsoleerd te testen. Er bestaan echter ook andere soorten test doubles. Een van die andere soorten is een stub. Die gebruik ik als er een gefixeerde waarde voor een method teruggegeven moet worden. Bijvoorbeeld altijd null, of altijd een Exception, etc.

Een voorbeeld: Stel, ik wil test schrijven dat een mail gemarkeerd wordt als “niet verzonden” als er geen verbinding kan worden gemaakt met de mail server. De mail is in dit geval een record in de database. De code zou er ongeveer zou uit kunnen zien:

class MailerService
{
    public function __construct(MailClientInterface $mailer)
    {
        // assign to property
    }

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

        try {
            $this->mailer->send($mail);
        } catch (ConnectException $exception) {
            $mail->markUnsent();
        }
    }
}

Met een mock is dit prima te testen:

class MailerServiceTest extends TestCase
{
    public function testItMarksAsUnsentWhenSendingFailed()
    {
        $mailerMock = $this->getMockForAbstractClass(MailClientInterface::class)->getMock();

        $mailerMock
            ->expects($this->once())
            ->method('send')
            ->willThrowException(new ConnectException('Could not connect to the mail server'));
            ;

        $service = new MailerService($mailerMock);

        $service->send(/* .. */);

        // assert the mail was marked as unsent
    }
}

Wat opvalt aan deze manier is dat de mock “klaargezet” moet worden. Als ik dit voor meerdere tests moet doen dan kopieer en plak ik het voor elke test, of ik schrijf een “generieke” methode die ik in elke test aanroep. Op de ene manier krijg ik dubbele code in mijn test, op de andere manier krijg ik het later moeilijk als ik voor bepaalde situaties andere mocks moet teruggeven.

Met een stub kan ik het ook testen. Dan zien mijn test en mijn stub er zo uit:

class UnconnectableMailClientStub implements MailClientInterface
{
    public function send(/* some parameters */): void
    {
        throw new ConnectException('Could not connect to the mail server');
    }
}
class MailerServiceTest extends TestCase
{
    public function testItMarksTheMailAsUnsentWhenConnectingFailed()
    {
        $service = new MailerService(new UnconnectableMailClientStub());

        $service->send(/* .. */);

        // assert the mail was marked as unsent
    }
}

De truc is om een losse class, de stub, te maken die dezelfde interface implementeert. De method retourneert dan altijd een gefixeerde waarde; in dit geval is dat een ConnectException. De testcase bevat geen enkele regel code meer om de test “klaar te zetten”. De test die de stub gebruikt is heel compact geworden. Ook als ik later enkele tests wil aanpassen dan kun ik heel simpel een nieuwe stub introduceren die een andere gefixeerde waarde teruggeeft.



Stubs zet ik altijd in een eigen “Stubs” namespace binnen de tests/ map.



Tip 11: Houd je tests simpel

Bij het schrijven van tests probeer ik eigenlijk zoveel mogelijk te voorkomen dat ik if-statements gebruik. Als ik op basis van een bepaalde parameter of data moet bepalen op welke manier er ge-assert moet worden dan wordt het eigenlijk al te onoverzichtelijk. Meestal voorkom ik dit door afzonderlijke testcases te gebruiken. Het klinkt misschien superlogisch, maar misschien maakt onderstaand voorbeeld het probleem duidelijk.

class CalculatorTest extends TestCase
{
    public function divisionDataProvider(): array
    {
        return [
            [1.0, 1.0, 1.0],
            [1.0, 2.0, 0.5],
            [1.0, 0.0, null],
        ];
    }

    /**
     * @dataProvider divisionDataProvider
     */
    public function testCanDivide(float $nominator, float $denominator, ?float $expectedOutcome): void
    {
        if ($denominator === 0.0) {
            $this->expectException(DivideByZeroException::class);
        }

        $outcome = $this->calculator->divide($nominator, $denominator);

        if ($denominator !== 0.0) {
            $this->assertEquals($expectedOutcome, $outcome);
        }
    }
}

In bovenstaand voorbeeld is een testcase met drie scenarios waarbij in het derde scenario wordt getest dat delen door nul een DivideByZeroException oplevert. Echter, om dit in de huidige testcase op te lossen zijn twee if-statements gebruikt. Dit werkt wel, maar ik kies er dan liever voor om een aparte testcase te schrijven. Die ziet er dan als volgt uit:

class CalculatorTest extends TestCase
{
    public function validDivisionsDataProvider(): array
    {
        return [
            [1.0, 1.0, 1.0],
            [1.0, 2.0, 0.5],
        ];
    }

    /**
     * @dataProvider validDivisionsDataProvider
     */
    public function testCanDivide(float $nominator, float $denominator, float $expectedOutcome): void
    {
        $outcome = $this->calculator->divide($nominator, $denominator);

        $this->assertEquals($expectedOutcome, $outcome);
    }

    public function testCanDividingByZeroRaisesAnException()
    {
        $this->expectException(DivideByZeroException::class);

        $this->calculator->divide(1.0, 0);
    }
}

Het aantal scenarios dat getest wordt is hetzelfde (drie). Maar op deze manier voorkom ik twee if-statements en bovendien maakt de naamgeving van de tests duidelijker wat de bedoeling van de test is.

Tip 12: Gebruik fixtures voor API responses

Om tests te schrijven tegen code als het gaat om bijvoorbeeld het afhandelen van API requests van een derde partij dan gebruik ik mocks om te voorkomen dat de API requests daadwerkelijk verstuurd worden. Die API responses stop ik dan in de mock. Meestal is zo’n response in JSON of XML formaat. Omdat dat een hele lap tekst is is het misschien niet zo slim om die als string in mijn test te plakken. De test wordt hiervan zo groot dat het niet ten goede komt van de leesbaarheid.

Deze manier zie ik vaak, maar is niet zo handig:

class ExternalApiServiceTest extends TestCase
{
    public function testCanRetrieveDataFromApi(): void
    {
        // some big array
        $response = [
            'elements' => [
                'type' => 'node',
                'id' => 35352001,
                'lat' => 50.2106234,
                'lon' => 8.5856189,
                'timestamp' => '2012-12-08T23:04:02Z',
                'version' => 6,
                'changeset' => 14206963,
                'user' => 'HoloDuke',
                'uid' => 75317,
            ],
            ...
        ];

        // or like this

        $response = '{"elements":{"type":"node", "id": 35352001, "lat": 50.2106234, "lon": 8.5856189, ... }}';

        // the rest of the test
    }
}

Hoewel een array notatie voor JSON responses nog valt te overzien, zo is een platte string voor JSON (of XML) al veel minder leesbaar. Om dit probleem op te lossen gebruik ik fixture bestanden. In zo’n bestand plaats ik dan het response; in plaats van in mijn test. Het voordeel is dat het bestand syntax highlighting krijgt en ook meteen (door je favoriete IDE) geformat kan worden. Twee dingen die de leesbaarheid ten goede komen.

{
    "elements": [
        {
            "type": "node",
            "id": 35352001,
            "lat": 50.2106234,
            "lon": 8.5856189,
            "timestamp": "2012-12-08T23:04:02Z",
            "version": 6,
            "changeset": 14206963,
            "user": "HoloDuke",
            "uid": 75317
        }
    ],
    ...
}
class ExternalApiServiceTest extends TestCase
{
    public function testCanRetrieveDataFromApi(): void
    {
        $response = file_get_contents(__DIR__ . '../fixtures/externalapi/full.json'));

        // the rest of the test
    }
}

De test wordt er korter en leesbaarder van.



Ik zet de fixture bestanden in een aparte map binnen de tests/ map met daarbinnen submappen per externe API. Zo zie ik in één oogopslag wat er bij elkaar hoort.



Tip 13: Pas polymorphisme toe

Vaak begint een nieuwe applicatie eenvoudig en worden en gaandeweg meer features toegevoegd. Langzaamaan wordt de code complexer en vanzelf komt er een punt dat je je gaat afvragen of het niet simpeler kan. Ik probeer te voorkomen dat het überhaupt complexer wordt door te refactoren op het moment dat ik zie dat een class of method onnodig complex wordt.

Stel, ik heb onderstaande class ACoffeeMachine:

class ACoffeeMachine
{
    public function brew(): Coffee
    {
        $water = $this->takeWater(30);
        $coffee = $this->grindBeans(7.0);

        return $this->make($water, $coffee);
    }
}

En er wordt mij gevraagd om functionaliteit toevoegen zodat er naast gewone koffie ook espresso gezet kan worden. Het meest voor de hand liggend is het dan om de class als volgt uit te breiden:

class ACoffeeMachine
{
    public function brew(string $type): Coffee
    {
        if ($type === 'espresso') {
            $water = $this->takeWater(30);
            $coffee = $this->grindBeans(7.0);

            return $this->make($water, $coffee);
        }

        if ($type === 'regular') {
            $water = $this->takeWater(110);
            $coffee = $this->grindBeans(7.0);

            return $this->make($water, $coffee);
        }

        throw new InvalidArgumentException('Coffee type ' . $type . ' is not supported on this machine.');
    }
}

Zo. Klaar! Dit had ook een switch-statement kunnen zijn, maar het maakt voor het idee nu niet uit. Echter, omdat er nu gevarieerd wordt op een parameter ($type), dien ik dit af te vangen middels een InvalidArgumentException en dit scenario leg ik ook vast in een test. Een extra test dus. Bovendien, elke keer als er een type bij komt, moet ik in de bestaande method aanpassingen doen. Het is dan mogelijk dat ik onbedoeld iets verander aan de berekening van de andere types koffie.

De oplossing is polymorphisme. In feite ben je dezelfde actie (koffiezetten) op meerdere manieren aan het doen, de implementatie van het koffiezetten verschilt alleen. Als ik zie dat ik meerdere manieren aan het programmeren ben voor dezelfde actie, dan refactor ik naar polymorphisme. Ik breek de afzonderlijke "berekeningen" op per class.

De oplossing met polymorphisme ziet er als volgt uit:

interface CoffeeMachine
{
    public function brew(): Coffee;
}

class EspressoMachine implements CoffeeMachine
{
    public function brew(): Coffee
    {
        $water = $this->takeWater(30);
        $beans = $this->grindBeans(7.0);

        return $this->make($water, $beans);
    }
}

class FilterCoffeeMachine implements CoffeeMachine
{
    public function brew(): Coffee
    {
        $water = $this->takeWater(110);
        $coffee = $this->grindBeans(7.0);

        return $this->make($water, $coffee);
    }
}

Dit biedt een aantal voordelen:

  • Je hebt altijd een geldige CoffeeMachine, geen Exception handling voor een type
  • Er zijn nog 6 uitvoerbare regels code, in plaats van 9
  • Minder tests nodig, de if-statements (of switch-statement) zijn/is weg
  • Als je een aanpassing doet in de ene class heeft dat per definitie geen invloed op de andere class
  • Elke CoffeeMachine is afzonderlijk en in isolatie te testen


Ik heb gebruik in PHPStorm “Refactor -> Extract Interface” om deze refactor te doen.



Tip 14: Gebruik een lookup map

Deze tip lijkt op de de vorige tip. Mocht het introduceren van classes voor je gevoel te veel overhead hebben, bijvoorbeeld omdat je alleen maar een array op een bepaalde manier geformat wilde teruggeven, dan is een lookup map een betere optie.

Allereerst de code zonder lookup map:

class SomeClass
{
    public function getResult(string $type): array
    {
        switch ($type) {
            case 'photo':
                $result = [
                    'type' => 'Foto',
                    'id' => 'photo',
                ];
                break;
            case 'album':
                $result = [
                    'type' => 'Album',
                    'id' => 'album',
                ];
                break;
            case 'category':
                $result = [
                    'type' => 'Categorie',
                    'id' => 'category',
                ];
                break;
            default:
                throw new InvalidArgumentException('Invalid type ' . $type . ' given.');
                break;
        }

        return $result;
    }
}

Nadelen:

  • Om 100% code coverage te hebben dien je vier unit tests te schrijven; één voor elke case (+ de default case)
  • Het is een hele lap code; zeker als er meer cases zijn

Ik los dit altijd op met een lookup map. Dat ziet er zo uit:

class SomeClass
{
    protected array $map = [
        'photo' => [
            'type' => 'Foto',
            'id' => 'photo',
        ],
        'album' => [
            'type' => 'Album',
            'id' => 'album',
        ],
        'category' => [
            'type' => 'Categorie',
            'id' => 'category',
        ],
    ];

    public function getResult(string $type): array
    {
        Assert::keyExists($this->map, $type, 'Invalid type ' . $type . ' given.');

        return $this->map[$type];
    }
}

Voordelen:

  • Je hebt nog maar twee unit tests nodig: Het type komt wel of niet voor in de lookup map
  • De method bevat nog maar twee regels, wat het overzichtelijk maakt
  • De lookup map is eventueel at runtime te instantiëren zodat het dynamisch ingeladen kan worden

Tot slot

Testautomatisering zorgt er niet alleen dat je vertrouwen krijgt in je code, het kan er ook voor zorgen dat je betere, schonere en slimmere code schrijft!

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