Nicolas Le Borgne

Développeur

Les builders de test

Le 30 janvier 2021

Quand j'ai commencé à m'intéresser aux tests automatisés, je suis tombé sur la bonne pratique de découper un test en trois blocs "Arrange, Act, Assert".

Sauf qu'une fois qu'on a dit ça, on regarde nos outils, et on se rend compte que ce n'est pas vraiment encouragé. En effet, nos différents frameworks nous poussent à utiliser des modules permettant de charger des "fixtures" automatiquement :

it’s common to use fake or dummy data in the test database. This is usually called “fixtures data” and Doctrine provides a library to create and load them — Documentation de Symfony

Ou encore :

A fixture is a collection of data that Django knows how to import into a database. For example, if your site has user accounts, you might set up a fixture of fake user accounts in order to populate your database during tests. — Documentation de Django

Avec lesquels on se retrouve avec un test où seul les parties "Act, Assert" sont visibles. Cela a pour inconvénients de :

  • Perturber la compréhension du test, on ne voit pas les préconditions.
  • Inciter à partager les fixtures entre plusieurs tests, si on met à jour les fixtures, on risque de casser le test du copain.
  • Un changement de modèle demande de changer les fixtures.

Faut-il instancier/persister nos préconditions directement dans les tests ?

Oui mais non. Sur un cas simple, pourquoi pas, mais sur un cas réel, ça va vite être compliqué : c'est long, fastidieux, et si on rajoute un paramètre au constructeur, il faut toujours changer les tests 😢 :

/** @test */
public function routeCrossEquator()
{
    $from = new Port(
        'Saint-Nazaire',
        47.2833,
        -2.2
    );
    $to = new Port(
        'Cape Town',
        -33.9248,
        18.4240
    );

    $route = $this->whenTracingRoute($from, $to);

    $this->assertRouteCrossEquator($route);
}

À l'échelle ça ne marche donc pas. C'est là que le builder intervient !

Ça ressemble à quoi un builder ?

C'est une simple classe avec une interface fluent qui nous permet de construire pas-à-pas un élément du contexte de notre test. Par défaut, il fournit le cas nominal.

class PortBuilder
{
    private ?string $name = null;
    private ?float $latitude = null;
    private ?float $longitude = null;

    public function withName(string $name): PortBuilder
    {
        $this->name = $name;

        return $this;
    }

    public function withLatitude(float $latitude): PortBuilder
    {
        $this->latitude = $latitude;

        return $this;
    }

    public function withLongitude(float $longitude): PortBuilder
    {
        $this->longitude = $longitude;

        return $this;
    }

    public function build(): Port
    {
        return new Port(
            $this->name ?? 'Un port quelque part',
            $this->latitude ?? 49.4943,
            $this->longitude ?? 0.1079
        );
    }
}

Notre test est un peu plus lisible, et si l'on souhaite rajouter, par exemple, la capacité d'accueil du port en 4ème argument, il suffit de rajouter une valeur nominale dans le builder 🎉 :

/** @test */
public function routeCrossEquator()
{
    $from = (new PortBuilder())->withLatitude(47.2833)->withLongitude(-2.2)->build();
    $to = (new PortBuilder())->withLatitude(-33.9248)->withLongitude(18.4240)->build();

    $route = $this->whenTracingRoute($from, $to);

    $this->assertRouteCrossEquator($route);
}

On peut pousser plus loin. Mon expert métier m'informe que la notion de port situé dans l'hémisphère nord ou sud est capitale, on va en avoir souvent besoin. Du coup, on va rajouter une factory dans le builder :

/** @test */
public function routeCrossEquator()
{
    $from = PortBuilder::northernHemispherePort();
    $to = PortBuilder::southernHemispherePort();

    $route = $this->whenTracingRoute($from, $to);

    $this->assertRouteCrossEquator($route);
}

Là on est un encore mieux 🙃. Attention à ne pas être trop gourmand et tomber dans le travers du pattern "Object Mother", si vous ajoutez des factories, limitez-vous aux cas vraiment récurrents.

Ça demande de pratiquer un peu, surtout si vos objets sont complexes, le builder peut vite grossir et devenir imbuvable.

On récapitule

Le builder nous permet :

  • De garder des préconditions visibles.
  • D'isoler les tests. À chaque test son jeu de données.
  • D'apporter un point de découplage, permettant de modifier les modèles sans modifier tous les tests en même temps.

Mais attention :

  • Être vigilant sur la vie du builder, il peut vite "pourrir".
  • Les builders sont un peu longs à écrire, mais une fois que c'est fait, les gains de temps et de confort lors de l'écriture des tests sont significatifs.

Sources

© 2021 Nicolas Le Borgne