Des tests super lisibles

| 6 min read

i_Cet article est une traduction et légère adaptation d’un article précédent écrit il y a déjà plusieurs années sur ce même blog. Les techniques exposées ici me sont toujours utiles et je pense qu’elles peuvent aider un grand nombre de personnes à améliorer leurs tests automatisés. Bonne lecture !_

Avec cet article je souhaite partager certaines des techniques que j’utilise régulièrement lorsque j’écris des tests. Ces techniques permettent de fortement améliorer la lisibilité des tests, les rendant plus faciles à comprendre. Certaines d’entre elles ont également un impact positif sur la maintenabilité de l’ensemble des tests. On verra un exemple dans l’article.

D’autres personnes ont déjà partagé certaines de ces idées et cet article, ainsi que ma manière d’aborder les tests, s’appuie en partie sur les travaux de Sandro Mancuso, Matthias Verraes, Dan North, et également sur des idées exposées par Nat Pryce et Steve Freeman dans Growing Oriented Object Software Guided By Tests

Dans cet article, nous allons refactorer le test suivant une étape après l’autre afin de le rendre davantage lisible.
Ici on utilise PhpUnit mais les idées partagées ici peuvent l’être avec d’autres frameworks (certaines des idées sont d’ailleurs inspirées par PhpSpec qui est également un très bon outil) ou même dans d’autres langages que PHP.

<?php
class SendWelcomeEmailToMemberTest extends PHPUnit_Framework_TestCase {

    protected function tearDown() {
        Mockery::close();
    }

    /**
     * @test
     */
    public function it_sends_a_welcome_email_to_a_member() {
        $mailSender = Mockery::mock(MailSender::class);

        $welcomeMailSender = new WelcomeMailSender($mailSender);

        $mailSender->shouldReceive('send')
           ->with(Mockery::any(new Email(
           'charles@test.fr',
           'us@chorip.am',
           'Welcome',
           'Hey! Welcome!')
           ))
           ->once();

        $welcomeMailSender->sendTo(
            new Member('Charles', 'charles@test.fr')
        );
    }
}

Nom du test en Snake_case

Comme vous le voyez dans ce test on n’utilise pas la convention de nommage classique en camelCase, testWhatItsSupposedToDo, mais plutôt du snake-case. De plus le nom de la méthode de test débute par it_, ce qui, nous oblige à utiliser l’annotation @test. Démarrer le nom du test avec it_ aide à formuler une phrase qui décrit le comportement que l’on veut tester dans un langage naturel. Il est alors possible d’utiliser la liste de tous les tests pour générer une documentation, ce qui se fait facilement avec l’option testdox de PhpUnit.

Arrange - Act - Assert

Le premier changement est de réorganiser le test pour qu’il suive la forme "Arrange - Act - Assert". Lorsque tous les tests sont sous cette forme il est très simple de savoir quelles sont les préconditions du test - ce que j’appelle la mise en place du petit monde -, l’action, et les assertions, qui sont toujours à la fin du scénario.

Dans la version actuelle, une des assertions, celle à propos de l’envoi de l’email, est faite avant l’étape d’action.

Pour permettre de déplacer l’assertion à la fin du test on a besoin d’utiliser un Spy à la place d’un Mock. Pour en savoir plus sur les différents types de doublures vous pouvez consulter cet article.

<?php
    /**
     * @test
     */
    public function it_sends_a_welcome_email_to_a_member() {
        // Arrange    
        // Utilisation d’un spy à la place d’un mock
        $mailSender = Mockery::spy(MailSender::class); 

        $welcomeMailSender = new WelcomeMailSender($mailSender);

        // Act
        $welcomeMailSender->sendTo(
            new Member('Charles', 'charles@test.fr')
        );

        // Assert
        $mailSender->shouldHaveReceived('send')
            ->with(Mockery::any(new Email(
                'charles@test.fr',
                'us@chorip.am',
                'Welcome',
                'Hey! Welcome!')
                ))
            ->once();
    }

Builder

Faisons maintenant un petit effort d’imagination : la classe Member est une des classes centrales de notre application et on la retrouve dans de nombreux tests. Dans ces tests, on utilise le constructeur de Member pour instancier des membres.

Un jour arrive une nouvelle fonctionnalité qui nous pousse à vouloir ajouter la notion de date de naissance pour les membres. On ajoute alors un nouveau paramètre $birthDate au constructeur de la classe Member. Le problème, c’est que cela va faire échouer tous les tests qui font un appel à ce constructeur. En route pour la correction de dizaines de tests. Quelle joie !

Pour éviter ce problème ma solution favorite est d’utiliser un builder qui va encapsuler l’appel au constructeur de Member. Désormais quand on veut effectuer un changement sur le constructeur il n’est plus nécessaire de passer dans chacun des tests. Seul un changement dans la méthode build du builder est nécessaire.

En utilisant un builder pour Member et Email le test est alors le suivant :

<?php

    /**
     * @test
     */
    public function it_sends_a_welcome_email_to_a_member() {
        $mailSender = Mockery::spy(MailSender::class);

        $welcomeMailSender = new WelcomeMailSender($mailSender);

        $charles = (new MemberBuilder)
            ->withFirstName('Charles')
            ->withEmailAddress('charles@test.fr')
            ->build();

        $welcomeMailSender->sendTo($charles);

        $welcomeEmail = (new EmailBuilder)
            ->from('us@chorip.am')
            ->to('charles@test.fr')
            ->withSubject('Welcome')
            ->withContent('Hey! Welcome!')
            ->build();

        $mailSender->shouldHaveReceived('send')->with(Mockery::any($welcomeEmail))->once();
    }

Et voici le code du MemberBuilder dans une version simple :

<?php
final class MemberBuilder {
    private $firstname;
    private $emailAddress;
    private $birthDate;

    public function __construct() {
        $faker = \Faker\Factory::create();
        $this->emailAddress = $faker->email;
        $this->firstname = $faker->firstname;
        $this->birthDate = $faker->date;
    }

    public function build() : Member {
        return new Member(
         $this->firstname,
         $this->emailAddress,
         $this->birthDate
         );
    }

    public function withFirstName($firstname) : static {
        $this->firstname = $firstname;

        return $this;
    }

    public function withEmailAddress($emailAddress) : static {
        $this->emailAddress = $emailAddress;

        return $this;
    }

    public function bornOn($birthDate) : static {
        $this->birthDate = $birthDate;

        return $this;
    }
}

En plus d’encapsuler la création d’une instance de Member a un unique endroit l’utilisation d’un builder à un second avantage. Dans le constructeur du builder une valeur par défaut est assignée pour chacune des informations nécessaires à la création d’un membre. Cela nous permet de n’avoir à spécifier que les informations pertinentes pour le test que l’on est en train d’écrire. Si on a besoin d’un Member et que seul son prénom nous intéresse on peut appeler (new MemberBuilder)->withFirstName('John')->build() et omettre de préciser son adresse email et sa date de naissance. Cela permet de réduire le bruit lors de la mise en place du test, ce qui le rend plus facile à comprendre.

De plus ici on utilise Faker, une library qui permet de créer de fausses données de manière aléatoire. En utilisant des données aléatoires on réduit le risque que notre implémentation ne se base sur des valeurs fixes.

Tu en as marre de tes tests ?

J’ai créé une formation vidéo qui aide les développeuses et développeurs à améliorer leurs tests automatisés.

Dans cette formation je partage les idées et techniques qui permettent de rendre des tests lents, qui cassent à chaque modification du code et sont incompréhensibles en des tests avec lesquels on a plaisir à travailler.

Fonction de création pour les builders

Pour faciliter encore davantage la lisibilité du test, il est possible de masquer la création des builders derrières des fonctions.
En introduisant les deux fonctions suivantes :

<?php

function aMember() {
    return new MemberBuilder;
}

function anEmail() {
    return new EmailBuilder;
}

On peut modifier le test sous cette forme :

<?php
    /**
     * @test
     */
    public function it_sends_a_welcome_email_to_a_member() {
        $mailSender = Mockery::spy(MailSender::class);

        $welcomeMailSender = new WelcomeMailSender($mailSender);

        $charles = aMember() // Création d’un Member
            ->withFirstName('Charles')
            ->withEmailAddress('charles@test.fr')
            ->build();

        $welcomeMailSender->sendTo($charles);

        $welcomeEmail = anEmail() // Création d’un Email
            ->from('us@chorip.am')
            ->to('charles@test.fr')
            ->withSubject('Welcome')
            ->withContent('Hey! Welcome!')
            ->build();

        $mailSender->shouldHaveReceived('send')
            ->with(Mockery::any($welcomeEmail))->once();
    }

Méthodes d’aide

Pour nous permettre d’employer le vocabulaire du métier, ou a minima nous permettre d’utiliser un langage plus proche du langage naturel, il est aussi possible d’ajouter des méthodes d’aide :

<?php
class SendWelcomeEmailToMemberTest extends PHPUnit_Framework_TestCase {
    private $mailSender;
    private $welcomeMailSender;

    public function setUp() {
        $this->mailSender = Mockery::spy(MailSender::class);
        $this->welcomeMailSender = new WelcomeMailSender($this->mailSender);
    }

    protected function tearDown() {
        Mockery::close();
    }

    /**
     * @test
     */
    public function it_sends_a_welcome_email_to_a_member() {
        // it exists a member with first name 'Charles'
        // and with email adress 'charles@test.fr'
        $charles = $this->it_exists(aMember()
            ->withFirstName('Charles')
            ->withEmailAddress('charles@test.fr')
        );

        $this->welcomeMailSender->sendTo($charles);

        // It should send an email from 'us@chorip.am' to 'charles@test.fr'
        // with subject 'Welcome' and with content 'Hey! Welcome!'
        $this->it_should_send(anEmail()
            ->from('us@chorip.am')
            ->to('charles@test.fr')
            ->withSubject('Welcome')
            ->withContent('Hey! Welcome!')
        );
    }

    private function it_exists(MemberBuilder $member) {
        return $member->build();
    }

    private function it_should_send(EmailBuilder $email) {
        $this->mailSender->shouldHaveReceived('send')
            ->with(Mockery::any($email->build()))->once();
    }
}

Le test se lit alors bien plus aisément et des personnes non techniques serait tout à fait en mesure de comprendre le comportement décrit par elles-mêmes. Le test peut désormais servir de documentation aussi bien pour les développeuses et développeurs mais aussi pour le reste de l’équipe.

Aller plus loin avec une extension pour PhpUnit

On pourrait encore améliorer la lisibilité du test en replaçant l’utilisation de $this pour appeler les méthodes que nous venons d’introduire par given() et then(). J’ai tenté une expérimentation qui fonctionne mais n’ai jamais pris le temps de pousser l’idée jusqu’à créer une extension pour PhpUnit qui permette de le faire facilement.

Voilà ce à quoi ressemble le test en appliquant cette idée :

<?php

    /**
     * @test
     */
    public function it_sends_a_welcome_email_to_a_member() {
        $charles = given()->it_exists(aMember()
            ->withFirstName('Charles')
            ->withEmailAddress('charles@test.fr')
        );

        $this->welcomeMailSender->sendTo($charles);

        then()->it_should_send(anEmail()
            ->from('us@chorip.am')
            ->to('charles@test.fr')
            ->withSubject('Welcome')
            ->withContent('Hey! Welcome!')
        );
    }

Conclusion

Toutes ces techniques peuvent être utilisées indépendamment les unes des autres. On peut les introduire pas à pas, en fonction des problèmes que l’on rencontre ou des améliorations que l’on souhaite apporter, et nous permettent d’obtenir des tests très facilement lisibles une fois combinées.

Comme pour chaque outil il y a bien sûr un cout d’introduction qui doit être mis en regard de l’intérêt du projet sur lequel on est en train de travailler. Je dois admettre que de mon côté, j’essaye de mettre en place des builders le plus vite possible. Ils améliorent énormément la lisibilité des tests, facilitent l’écriture des tests et leur maintenabilité au cours du temps.

Quoiqu’il en soit je vous encourage à garder ces techniques en tête pour pouvoir vous en servir en case de besoin et pour vous permettre d’avoir des tests avec lesquels vous preniez plaisir à travailler pendant un bon bout de temps !

Si vous voulez aller encore plus loin, je vous conseille ma formation vidéo sur l'amélioration des tests automatisés dans laquelle nous allons plus en profondeur sur des techniques permettant d'écrire des tests extrèmement lisibles et facilement maintenables. Il nous est aussi possible de nous rencontrer et voir ensemble comment je peux vous apporter mon aide.