Bouchon (mock) et implémentation mémoire dans les tests

Un projet sur lequel je travaille actuellement utilise dans ses tests des bouchons (ou mock en anglais), mais également des implémentations mémoires de service travaillant avec des objets. Aussi, on m’a demandé l’intérêt d’utiliser l’un ou l’autre et je trouvais le sujet assez intéressant pour en écrire un billet.

Lorsque l’on écrit des tests, il a grosso modo deux écoles. La première va utiliser les bouchons (mock) pour décrire le fonctionnement attendu d’une méthode d’un objet. La seconde, quant à elle, va limiter leur utilisation au maximum (voir totalement). Ce sont ces derniers qui auront alors plus tendance à utiliser des implémentations mémoires des services. Chacune des deux méthodes a son lot d’avantages et inconvénients. À chacun de se faire sa propre opinion et d’utiliser l’approche qui lui convient dans le contexte qui lui est donné.

Commençons par les bouchons. Ces derniers sont généralement simples et rapides à créer. C’est d’ailleurs un de leur principal avantage et c’est aussi pour cela qu’on a tendance à beaucoup les utiliser. D’autant plus les frameworks de test proposent généralement de nombreux outils pour nous aider dans cette tâche. Derrière cette simplicité de mise en place et d’utilisation se cache également quelques inconvénients. Tout d’abord, bien souvent la gestion des bouchons n’est pas centralisée, on se retrouve donc à dupliquer le(s) comportement(s) attendu(s) dans l’ensemble des cas de tests. De ce fait, lorsqu’un contrat d’utilisation change, il sera nécessaire de repasser sur chaque définition de bouchons pour faire le mettre à jour. Un autre inconvénient est que l’on peut généralement faire tout et n’importe quoi avec un bouchon, il n’est pas si rare de se retrouver à tester des comportements qui ne pourraient pas se produire dans la réalité.

Avec les implémentations mémoires et contrairement aux bouchons, on ne va pas créer une classe dans le but de simuler une implémentation d’un service. L’implémentation en mémoire est l’implémentation d’un service applicatif qui va travailler avec des données uniquement en mémoire. Bien qu’il soit un peu plus verbeux qu’un bouchon, on se retrouve donc indirectement à centraliser (plus facilement) la gestion du comportement dans une classe unique et en cas de modification, les tests pourront être adaptés plus facilement.

Autre aspect très intéressant, contrairement à un bouchon où l’on est obligé de connaitre le détail d’implémentation des services à simuler (la simulation du comportement étant nécessairement rattachée à l’appel d’une fonction), dans le cadre d’une implémentation mémoire on se contente de passer une instance du service que l’on souhaite utiliser. C’est un gros point, car le test va pouvoir se concentrer uniquement sur le comportement du test sans nécessiter d’avoir la connaissance de comment l’ensemble est implémenté. Il est alors dans ce cas plus facile d’avoir des tests pérennes dans le temps en les rendant moins fragiles. C’est d’autant plus intéressant qu’il sera possible d’écrire des tests de contrat afin de s’assurer que l’implémentation en mémoire (que l’on appel également InMemory) se comporte exactement de la même manière que l’implémentation réelle. Un point qui est beaucoup plus difficile à mettre en place avec les bouchons.

A noter également que les implémentations mémoires sont généralement très utilisées dans les architectures hexagonales. Elles permettent de démarrer la mise en place d’un projet en amont d’avoir choisi les outils et/ou technologies qui seront utilisés. Par exemple, la mise en place d’un InMemoryRepository permet d’avoir une implémentation mémoire de la persistance de données avant d’avoir choisi le moteur de stockage qui sera utilisé dans le projet.

Comme je le disais en introduction, il y a donc deux écoles. A vous choisir la vôtre en fonction du contexte. Comme toujours en informatique, il y a une multitude de choix d’implémentation à chacun de trouver la manière de travailler qui lui convienne.


Et en bonus voici un exemple pour illustrer ce billet:

interface CommandBus
{
    public function handle(object $command): void;
}

// implémentation mémoire
class InMemoryCommandBus implements CommandBus
{
    private array $handledCommands;

    public function __construct()
    {
        $this->handledCommands = [];
    }

    public function handle(object $command): void
    {
        $this->handledCommands = $command;
    }

    public function getHandledCommands(): array
    {
        return $this->handledCommands;
    }
}

// test utilisant les bouchons
class MockImplementationTest extends TestCase
{
    public function testCommandShouldBeHandled(): void
    {
        $expectedCommand = new stdClass;

        // ici le bouchon va simuler l'appel et faire l'assertion sur les données
        $bus = $this->createMock(CommandBus::class);
        $bus->expect(self::once()) 
            ->method('handle')
            ->with($exepectedCommand);

        $subject = new TestedClass($bus);
        $subject->doAction();

        // réalisation d'autres assertions
    }
}

// test utilisant l'implémentation mémoire
class InMemoryImplementationTest extends TestCase
{
    // on notera également que ce test respecte la structuration
    // Arrange, Act, Assert d'écriture des tests
    public function testCommandShouldBeHandled(): void
    {
        $bus = new InMemoryCommandBus();

        $subject = new TestedClass($bus);
        $subject->doAction();

        self::assertCount(1, $this->bus->getHandledCommands());
        self::assertContainsEquals(new stdClass, $this->bus->getHandledCommands());
        // réalisation d'autres assertions
    }
}