Group of monkeys typing on typewriters

Monkey testing

Danny Techniek

Het is vast geen verrassing dat we bij Infi groot voorstanders zijn van testen, in welke vorm dan ook. Of het nou een unit test suite betreft die alle code paden afdekt, een set integratietests die de applicatie als geheel test, of met behulp van tools als Cypress of Playwright een aantal end-to-end tests uitvoert: elk Infi project heeft wel tests. Naast het “niveau” waarop je test, zijn er ook nog andere interessante aspecten om over na te denken…

Bijvoorbeeld de kwaliteit van tests werd door Jeroen en Rachid al eerder besproken in een blogpost over Mutation testing. En collega Max schreef over de afspraken en patronen van een test.

Een ander aspect is randomness in je tests inbrengen. En dan heb ik het niet over een willekeurige volgorde waarin de test suite wordt gedraaid, want dat doet iedereen toch? Maar de randomness die hiermee wordt bedoeld is zorgen dat je test bij elke run weer een andere set aan test-data gebruikt. In de wereld van software bouwen wordt dit ook van “Monkey Testing“, of “random automated unit tests” genoemd.

Test-data

Vaak heb je in tests voorbeelddata nodig. In het geval van een naam kiest men vaak voor Jane Doe of John Doe, bij een e-mailadres wordt vaak een “…@example.com gekozen. En zo zijn er nog wel wat meer patronen die developers aanhouden om de tests van testdata te voorzien.

Hier ontstaat de mogelijkheid om je eigen blinde vlek te creëren. Het gedrag van de code lijkt aan de eisen te voldoen, maar er kan nog steeds een bug verborgen zitten die alleen aan het licht komt bij specifieke invoer waar de developer niet aan heeft gedacht.

Zo is XI een ISO-3166 Alpha 2 ISO Code die door een aantal organisaties wordt gebruikt voor Noord-Ierland. Daarnaast is root@localhost een valide e-mailadres volgens de RFC-standaard, en zo zijn er nog wel meer voorbeelden. Bijzonderheden en uitzonderingen in tijdzones of adressen zijn ook een grote bron van van bugs die op een later moment naar voren komen.

Je kan hierover als team afspreken dat je met andere namen gaat testen. Je kunt data-providers inzetten om meerdere datasets te testen, en tijdens reviews hier extra op te letten. Het alternatief is om de computer dit voor je te laten doen en elke keer als de test suite wordt gedraaid weer met een andere set namen, e-mail adressen en andere data te testen.

Voor elke programmeertaal is er een library die hierbij kan helpen. In PHP is er FakerPHP en met C# kan je gebruik maken van Bogus. Beide zijn trouwens een port van de JavaScript library FakerJS welke op zijn beurt weer een port van Perl’s Data-Faker versie is.

De voorbeelden in deze blog zijn op basis van FakerPHP gemaakt.

Simpel voorbeeld

Een simpele test case zou er als volgt kunnen uitzien:

public function testIsOddReturnsTrueForOddNumber(): void
{
    $sut = new IsOdd();
    $input = 5;
    
    self::assertTrue($sut->check($input))
}

In het voorbeeld is de $input vastgezet op de waarde 5. Een verbetering voor deze test zouden inputs als 1, -3, en nog meer kunnen zijn. Maar wanneer is het genoeg? Zijn vijf test cases voldoende? Of is pas bij tien test cases de consensus dat het goed getest is? Als we hetzelfde voorbeeld nemen en gebruik maken van FakerPHP dan zou het er zo uit kunnen zien:

public function testIsOddReturnsTrueForOddNumber(): void
{
    $sut = new IsOdd();
    $faker = \Faker\Factory::create();  
    $input = $faker->randomNumber() * 2 + 1;
    
    self::assertTrue($sut->check($input));
}

Waarbij we een willekeurig getal nemen vermenigvuldigd met 2 plus 1 waardoor het altijd een willekeurig oneven getal is. Dit maakt dat elke keer dat de test draaien er met een ander getal wordt getest die de werking van de valideert.

Randomness vs. Deterministisch

Met het toevoegen van random test-data ontstaat alleen een uitdaging met het voorheen deterministisch gedrag van een test suite. Door de willekeurige data kan het zijn dat de pipeline van de pull/merge request groen is om vervolgens na de merge een falende test te hebben in main. Gelukkig is de randomness niet volledig random maar wordt er gebruik gemaakt van een seed.

Als je de testsuite draait met een eerdere random seed zal de test-data hetzelfde zijn. Dit maakt dat je test suite met random data toch deterministisch gedrag vertoont. In het geval van een falende pipeline is het dan ook handig om de test suite met dezelfde seed te draaien en kijken waarom die specifieke test faalt. Waarschijnlijk heb je met die specifieke set aan test-data een verborgen constraint gevonden of een edge case die (nog) niet juist werd afgehandeld.

PHPUnit 9.6.19 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.2.18
Configuration: ./phpunit.xml.dist
Random Seed:   1718010686

Random objecten

In de voorbeelden hierboven worden steeds scalar data types aangehaald, een string of  integer. Natuurlijk kun je in je tests het maken van een object beschrijven met alle losse methodes van de faker library die je gebruikt.

public function testIsOddReturnsTrueForOddNumber(): void
{
    $sut = new IsFast();
    $faker = \Faker\Factory::create();  
    $input = new Car(
        engine: $faker->randomElement(Engine::cases()),
        transmission: $faker->randomElement(Transmission::cases())
    );
    
    self::assertTrue($sut->check($input))
}

Hoewel er niks mis is met de bovenstaande code, is het niet herbruikbaar door je test suite heen. Gelukkig bieden alle ports opties aan om de library uit te breiden met je eigen random objecten. In Bogus gaat dit met een DataSet, FakerJS gebruikt Factory functions en in FakerPHP kan je Providers definiëren. In de code snippet hieronder een voorbeeld over hoe een Provider er uit ziet en hoe je deze gebruikt.

class CarProvider extends \Faker\Provider\Base
{
    public function car(): Car
    {
        return new Car(
            engine: $this->generator->randomElement(Engine::cases()),
            transmisson: $this->generator->randomElement(Tranmission::cases())
        );
    }
}
public function testIsOddReturnsTrueForOddNumber(): void
{
    $sut = new IsFast();
    $faker = \Faker\Factory::create();
    $faker->addProvider(new \Tests\Concern\Faker\CarProvider($faker));
    $input = $faker->car();
    
    self::assertTrue($sut->check($input))
}

Als je de herbruikbaarheid van je test helemaal wilt verhogen zou je ook nog kunnen overwegen om andere varianten te maken waarbij parameters gefixeerd worden, zodat je in de test wel beschikt over de variant van het domein object die er nodig is. Of nog beter een Provider functie waarbij je optioneel alle inputs kan specificeren, hierdoor kan je elke denkbare variant, waarbij fixed en random data worden gemengd, van je domein object maken.

class CarProvider extends \Faker\Provider\Base
{
    public function carWithV8Engine(): Car
    {
        return new Car(
            engine: Engine::V8,
            transmisson: $this->generator->randomElement(Tranmission::cases())
        );
    }

    public function car(?Engine = null, ?Transmission = null): Car
    {
        return new Car(
            engine: $engine ?? $this->generator->randomElement(Engine::cases()),
            transmisson: $transmission ?? $this->generator->randomElement(Tranmission::cases())
        );
    }
}

Conclusie

Met behulp van Monkey testing is er een goede kans aanwezig dat je bugs ontdekt in je software, waar je mogelijk nog niet over had nagedacht. Door de randomness van je testdata ga je in tegen je eigen bias en (mogelijk) beperkte dataset die je normaal zou gebruiken. Door libraries zoals FakerPHP, Bogus, en FakerJS in te zetten, kun je eenvoudig willekeurige test data genereren zowel voor scalar types maar ook domein objecten. Hierbij kunnen custom providers je snel en eenvoudig helpen om in je testsuite gebruik te maken van deze domein objecten. Door randomness in je tests te omarmen, zorg je ervoor dat je applicaties nog beter bestand zijn tegen onverwachte situaties in productie.

Meer over testen

Een afspraak maken bij ons op kantoor of wil je even iemand spreken? Stuur ons een mail of geef een belletje.