Quelques rappels sur les tests unitaires et les mocks

Publié le

4 oct. 2024

Ce texte a initialement été rédigé suite à l'observation de défaut récurrents sur les tests unitaire de l'API.

Quels sont les critères essentiels d'un test unitaire ?

- Lisible,
- Simple,
- Rapide à écrire et à modifier.

Il faut écrire ses tests unitaires comme on écrirait de la documentation. C'est la meilleure doc qui soit, car si elle n'est plus à jour, elle casse.
Il explique le comportement de la brique testée d'abord par le langage via le libellé, puis par l'exemple via le test en lui-même.
Pour qu'un test unitaire remplisse correctement son rôle de documentation, il est essentiel qu'il soit simple et concis.

Input / output

La notion d'input/output doit être au cœur des tests unitaires. Du point de vue de l'appelant, l'implémentation n'a pas d'importance, c'est le service rendu en fonction des informations passées qui l'est.

Le test unitaire idéal ne comporte que trois étapes :

Déclaration des données passées en entrée,
Appel de la fonction,
Assertion(s) sur les données retournées.

Une fonction qui retourne une valeur modifiée se prête mieux au test unitaire qu'une fonction qui finit par un appel à un service.

Les mocks

Quand est-ce qu'un mock a un sens ?

La principale raison qui justifie l'utilisation de mocks dans un test est le déclenchement d'effets de bord. On ne veut pas déclencher d'effets de bord dans un test unitaire (pas d'appels réseau, pas d'appels au filesystem, pas d'appels à l'horloge interne de la machine, etc.). Pour des raisons de performances et de mises en place.

Les autres bonnes raisons d'avoir recours à des mocks sont marginales.

Comment organiser son code pour éviter les mocks ?

Les effets de bord les plus courants sur une API web en général concerne les échanges avec des services externes ou avec la base de donnée.

Prenons un cas d'usage commun : Je dois récupérer une donnée depuis un service externe, validée et transformée cette donnée, pour la sauvegarder en BDD.

Il faut donc séparer la récupération et la sauvegarde des données (effets de bords, implique des mocks), pour extraire le reste (les vérifications et les transformations) dans une classe ou fonction à part, qui prend les données en paramètre et les retourne modifiées en sortie.

Cela conduit en plus à un meilleur design : la logique "pure" qu'on a extraite, facile et intéressante dans le cadre d'un test unitaire, contient probablement toutes les règles métiers.

Et la fonction passe-plat du quelle on a extrait la logique, comment la tester ?
Pas besoin dans la plupart des cas de la tester unitairement. Si ce n'est qu'un passe-plat, elle n'est pas intéressante à documenter. Un test d'intégration ou end-to-end la déclenchant indirectement sera suffisant pour la protéger des régressions.

En pratique

Pour illustrer, prenons un exemple caricaturalement simple, avec cette fonction php et son test phpspec :

public function createUser(
        string $email,
        string $password,
        string $companyId,
        string $job
): void {
        $company = $this->companies->find($companyId);

        if (!$company) {
            throw new CompanyNotFoundException();
        }

        if (
            strlen($password) < 8
            || strlen($password) > 20
        ) {
            throw new InvalidPasswordException();
        }

        $formattedJob = strtolower($job);

        $this->users->add(
            new User(
                $email,
                $password,
                $company,
                $job,
            )
        );
}
function it_creates_a_user($companyRepository, $userRepository)
{
    $company = new Company();
    $companyRepository->find('companyId')->willReturn($company);

    $userRepository->add(Argument::that(fn (User $user) =>
        $user->getCompany() === $company
        && $user->getEmail() === 'email'
        && $user->getPassword() === 'password'
        && $user->getJob() === 'job'
    ))->shouldBeCalled();

    $this->createUser(
        'email',
        'password',
        'companyId',
        'JoB'
    );
}

Ici, $userRepository et $companyRepository sont des classes chargées d'interagir avec une couche de persistance, disons une base de donnée. Dans le cadre d'un test unitaire, elles doivent donc être mockées, car il n'existe pas de connection avec une BDD dans ce contexte.

Voici quelques critiques que l'on pourrait faire sur ce test :

La logique contenue dans la fonction n'est pas décrite explicitement,
Il est nécessaire de mettre en place deux mocks,
Les assertions se font sur les paramètres envoyés à la méthode d'un mock.

Pour améliorer ça, on peut extraire les fonctions de validations et de formatages :

private function validatePassword(string $password): void
{
    if (
        strlen($password) < 8
        || strlen($password) > 20
    ) {
        throw new InvalidPasswordException();
    }

    return $password;
}

private function formatJob(string $job): string
{
    return strtolower($job);
}

Et les tester une par une en décrivant précisement leur comportements :

function it_throws_an_exception_if_password_shorter_than_8()
{
    $this->shouldThrow(
        InvalidPasswordException::class
    )->during(
        'validatePassword', ['short']
    );
}

function it_converts_job_to_lower_case()
{
    $actual = $this->formatJob('JoB');
    $expected = 'job';

    Assert::eq($actual, $expected);
}

...

Ces tests décrivent explicitement les règles métiers et sont de bons exemples d'utilisation en conditions réelles.

Pour le reste de la logique, il s'agit essentiellement d'échanges avec une BDD. Si on mock ces échanges, le test n'a plus d’intérêt, il n'apporte pas de garantie. Il serait bien plus pertinent d’exécuter la fonction createUser directement ou indirectement dans un test end-to-end ou d'intégration mettant en place une connection avec une couche de persistance.

Publié par

Louis Lebrault
Louis Lebrault

Self taught, Louis is now an experienced web developer. He discovered his passion for functional programming languages, especially Haskell.

Commentaires