Les mocks sont-ils nos amis?

Au début, lorsque j’ai commencé à tester mon code, j’éprouvais un certain sentiment de sécurité. J’avais l’impression de coder avec un filet de sécurité, mon code était plus sûr. :-D

Toutefois, même si tous les tests passaient individuellement, un code composé de nombreuses classes pouvait mal se comporter, car les différentes pièces ne s’emboîtaient pas toujours très bien. Un peu comme si, en construisant une voiture, on avait bien unit-testé le clignoteur, mais qu’on l’avait monté au milieu du capot… (Par contre, qu’est-ce qu’il clignote bien mon cligno!)


C’est là qu’est apparu tout l’intérêt des tests d’intégration: Est-ce que le comportement global fonctionnait dans sa longue liste d’actions? Est-ce que la cohérence du tout était assuré dans son ensemble? Avec de nouvelles questions qui arrivaient cependant: est-ce que les test unitaires conservent toute leur raison d’être? A partir de quand unit-tester, et quand « global »-tester? Le code doit-il alors être couvert deux fois par des tests redondants?

Beaucoup de questions où les bonnes pratiques se heurtent à la réalité d’entreprise. La réalité, c’est qu’il faut tester ce qui est pertinent d’une part, et réaliser des tests pertinents d’autre part. Je ne sais pas s’il existe des règles théoriques pour cela…?

Quoiqu’il en soit, loin de vouloir donner de grands conseils ou d’édicter de grandes règles que je connais pas, voici juste un petit exemple simpliste à propos des mocks: Comment introduire un bug avec 100% de coverage? (oui c’est possible!) Juste histoire de faire gaffe, parce que les mocks n’ont pas que des avantages…

Step 1: tout va bien!

Classe A

On a ce petit code (mais imaginez que le code est trèèès compliqué):

1
2
3
4
5
6
7
class A
{
    function foo()
    {
        echo 'foo';
    }
}

Très facile à tester:

1
2
3
4
5
6
7
8
9
class ATest extends PHPUnit_Framework_TestCase
{
    function testFoo()
    {
        $a = new A();
        $this->expectOutputString('foo');
        $a->foo();
    }
}

Classe B

La classe A est utilisée par la classe B à l’aide d’une injection de dépendance:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class B
{
    private $a;
   
    function __construct(A $a)
    {
        $this->a = $a;
    }
   
    function bar()
    {
        $this->a->foo();
        echo '-bar';
    }
}

Son utilisation:

1
2
$b = new B(new A());
$b->bar(); //"foo-bar"

Pour tester la fonction « bar », on utilise un joli mock de A pour isoler le comportement des classes, et tout est super cool. Quand on appelle « bar », on teste aussi l’appel de « foo ».

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class BTest extends PHPUnit_Framework_TestCase
{
    function testBar()
    {
        $mockedA->expects($this->once())
            ->method('foo')
            ->will($this->returnCallback(function(){
                    echo 'foo';
                })
            );

        $b = new B($mockedA);
        $this->expectOutputString('foo-bar');
        $b->bar();

    }
}

Bien sûr, un tel code, si simple, n’a pas besoin de mock… C’est juste un exemple simpliste que vous devez essayer de transposer dans un code ample et complexe.

Au final, ce qu’il faut retenir, c’est que nous avons un coverage à 100%. (on peut être fier!)

Step 2: tout va bien… ah ben non!

Puis, deux ou trois mois plus tard, quand tout le monde a oublié comment marchait ce code, quelqu’un vient modifier la classe A. Sans grande inquiétude puisque le code est bien testé!

1
2
3
4
5
6
7
class A
{
    function foo()
    {
        return 'foo';
    }
}

Plus de « echo » mais un « return »!

Du coup, bien évidemment, son test est en erreur:

1
2
3
4
5
6
7
8
9
There was 1 failure:

1) ATest::testFoo
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'foo'
+''

Mais on corrige ça très facilement:

1
2
3
4
5
6
7
8
class ATest extends PHPUnit_Framework_TestCase
{
    function testFoo()
    {
        $a = new A();
        $this->assertSame('foo', $a->foo());
    }
}

Et nous n’avons plus d’erreur dans nos tests… Pourtant la classe B ne fonctionne plus!

1
2
$b = new B(new A());
$b->bar(); //"bar" (et plus "foo-bar")

Le problème

Le problème, c’est que la classe A a changé de contrat, et son fonctionnement n’est plus le même. Tandis que son mock se comporte toujours comme son implémentation d’origine.

La classe B correspond donc toujours au comportement donné par ce mock et le test est bon. Nous avons été dupés par le mock!

Mais comment l’auteur des modifications pouvait-il être au courant que ce mock n’est plus valide? Rien dans les tests ne l’indique… Surtout que, étant donné le coverage de 100%, on se reposait justement sur les tests unitaires pour effectuer sans risque les modifications dans l’ensemble du code.

Donc, nous avons introduit un bug dans un code testé à 100%…

Conclusion

On a tendance à utiliser les mocks pour simplifier les tests unitaires, et ne tester explicitement que le code propre à la classe.

Toutefois, l’utilisation des mocks est à double tranchant. Rien ne garantit que l’objet est correctement mocké. Rien ne garantit que le mock représente effectivement le comportement de sa classe.

Et, en cas de modification de la classe mockée, ses mocks peuvent potentiellement être à revoir.

Les tests d’intégration demeurent la seule possibilité d’être avertit d’un souci d’interaction entre différentes classes.

9 réflexions au sujet de « Les mocks sont-ils nos amis? »

  1. Même si oui en effet tu tentes de suggérer que ce sont des tests d’intégrations qui devraient couvrir ce genre de cas (et tu as raison) je pense que tu minimises la responsabilité du développeur dans l’histoire.

    En effet si tu modifies le contrat d’une fonction ou d’une classe donnée c’est à toi de vérifier si tu impactes ou pas le code(Alt + F7 sur les bons IDEs).

    De plus un test unitaire écrit correctement aura d’office une fonction getAMock() qui DOIT avoir:
    – le FQDN de ta classe au niveau du call de getMock() (peut être un IDE pourrait il aider dans ce sens)
    – un tag @return dans les commentaires pour préciser le type renvoyé: celui ci serait défini à ‘A’ et on aurait donc trace de son utilisation dans le test.

    Dans les deux cas c’est au développeur de regarder et corriger les éventuels impacts.

    C’est aussi pour cela qu’il est très important que ton IDE sache en permanence avec quel objet tu travailles que ce soit pas typehinting ou via les tags @return ou @var. Et pour cela que les Pimple et autres dependency injection container doivent être tjrs défini à un moment ou à un autre.
    Un petit

    1
    2
    3
    4
     /** @return EntityManager */
    public function getEntityManager(){
        return $this->container['entity_manager'];
    }

    est capital pour ne pas avoir des soucis de contrats mal respectés.

  2. À mon sens, les tests d’intégrations doivent servir à tester le parcours optimal, c’est à dire quand toutes les données sont correctes. Cela permet de vérifier qu’aucune grosse erreur n’est présente. Les tests unitaires eux servent à vérifier le comportement de la classe dans tous les cas, fonctionnement normal et cas d’erreur.

  3. En relisant ça je me demande si le pb ne vient pas d’un mélange au niveau des TU de la classe B.

    Normalement, les TU de B ne doivent concerner que la classe B, et uniquement sa spec, son contrat.
    Y inclure un mock de la classe A – bien qu’a priori imposé par l’injection de dépendance – n’est elle pas une erreur ? Car quelque part, on inclus la spec de A dans les TU de B. Donc on « teste » A dans les TU de B. => ce n’est plus unitaire.

    Est-ce qu’il ne vaudrait pas mieux définir le(s) mock(s) de A dans les TU de A, et importer ce mock dans les TU de B, de la meme façon qu’on importe A dans la classe B dans le code « officiel » ?

    Dans ce cas, quand un développeur touche au contrat de A dans le code réel, il modifie aussi les TUs de A, et les mocks de A dans le meme temps. Et peut ensuite voir les impacts sur tous les codes utilisant A.

  4. Bon je me relis et c’est peut être pas très clair.
    Ce que je voulais dire, c’est que TestB inclus une définition de A qu’il ne devrait pas inclure.

    Dans le code officiel vous avez surement une structure du genre :
    fichier A.class.php :

    1
    2
    3
    class A {
        ...
    }

    fichier B.class.php :

    1
    2
    3
    4
    require_once('A'); // voire même pas de require avec un autoload
    class B {
        ...
    }

    Il ne viendrait même pas à l’esprit d’écrire :
    fichier B.class.php :

    1
    2
    Class A {} // sachant qu'en plus ça provoque une erreur PHP "class A déjà définie ailleurs".
    Class B {}

    Or c’est ce que vous faites dans les TU de B :
    TestB.php :

    1
    2
    3
    4
    5
    6
    class testB {
        testB() {
            définition classe A (le mock)
            test
        }
    }

    A la place, il vaudrait mieux un truc du genre :
    fichier mockA.php :

    1
    // définition du/des mock

    fichier testB.php :

    1
    2
    require('mockA');
    class TestB {}

    Et ça fait le café :p

    (mockA, café… *sort*)

  5. Salut, merci pour l’article !
    En fait tu as raison sur la forme, il est tout à fait possible d’avoir un test coverage de 100% sans avoir un code fonctionnel (principalement grâce aux mocks).
    Cela dit, si on suit l’exemple que tu utilises :
    1 – Tu as un code fonctionnel, avec des tests qui couvrent le tout.
    2 – Tu modifies le contrat d’une fonction testée
    3 – Tu modifies le test de cette fonction

    Le problème dans ce workflow, c’est que tu ne t’occupes pas du tout de la dépendance, alors que tu casses tout ce qui va utiliser cette fonction. Tu dois vérifier les différents appels, les corriger, et corriger les tests unitaires afférents, pour garder un ensemble cohérent et fonctionnel.

    • C’est tout le problème de la (non)rétrocompatibilité des interfaces.

      Et là encore, on est dans le cas où on maîtrise tout le logiciel, donc on peut corriger tous les appels.
      Mais imaginez une API utilisée par des milliers de partenaires ?

      Heureusement il y a des solutions à ce problème : documentation des versions, support et publication de plusieurs versions en parallèle (quitte à informer des éléments dépréciés, et à charge aux utilisateurs de se mettre à jour à temps)

  6. Merci pour tous ces commentaires intéressants! :)

    Je remarque que plusieurs avis concordent en disant -si je résume de manière caricaturale- que ce problème relève un peu de la responsabilité du développeur.

    Il y a toutefois quelque-chose de paradoxal avec cet axiome. En effet, le but d’un test automatisé, par essence, c’est qu’il remplace l’intervention humaine.

    Si l’humain doit s’assurer que le code est effectivement toujours valide, c’est que le test n’est plus automatisé. Il ne répond plus à sa fonction.

    Pour moi, un tel test devient donc potentiellement inutile, voire même contre-productif, car il demande un effort supplémentaire de maintenance.

    De plus, l’erreur est humaine. Comment assurer que le dev fasse correctement son job, si le test n’est pas là pour lui dire?

    Bien sûr je me fais l’avocat du diable… Mais c’est juste pour dire qu’il y a potentiellement un problème dans le fait que la détection du changement ne soit pas automatisée.

    • C’est tout le problème de « mais qui teste les tests alors ? ».

      Après il faut faire la part des choses :
      * Les tests qui permettent de « prouver » qu’une spec est implémentée => responsabilité du QA
      * L’implémentation en elle même => responsabilité de l’équipe de développement

      Rien n’impose dans l’absolu que ce soit le dev final qui code à la fois les tests et l’implémentation finale.
      On peut très bien avoir une team QA qui fournit les tests, et une autre team dev plus classique qui implémente les fonctionnalités.

      Enfin, il y a des outils et des process qui peuvent aider à ne pas se planter dans les tests.
      Déjà, si la spec est suffisament claire, les TU devraient être évidents :
      « la fonction doit renvoyer un entier » => assertEntier(maFonction())
      Si les tests sont difficiles/impossibles à écrire, c’est peut être que la spec est elle même trop imprécise et doit etre retravaillée.
      Certains outils permettent même « d’écrire » des tests comme on parle couramment (cucumber par exemple), réduisant ainsi le risque d’erreurs.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Vous pouvez utiliser ces balises et attributs HTML : <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>