Pour en finir avec les Stubs et les Mocks

stubmockgear.pngLes termes de "Stub" et "Mock" sont aujourd'hui utilisés de manière assez courante[1] quand il s'agit de parler de tests unitaires, mais, faute de véritable référence, tout le monde n'utilise pas ces mots avec la même signification.

Une affirmation assez courante, consiste à dire "les stubs c'est pour faire des vérifications d'état, les mocks c'est pour faire des vérifications de comportement". Cette affirmation ne me convient pas car elle simplifie à outrance la diversité des techniques d'écriture de test.

Je préfère, de loin, les définitions proposées par Gerard Meszaros dans ses xUnit Patterns :

  • Un stub, c'est une doublure qui remplace un objet réel en fournissant des entrées indirectes à l'objet à tester
  • Un mock, c'est une doublure qui remplace un objet réel en vérifiant les sorties indirectes de l'objet à tester

Et la liste ne serait pas complète si on ne rajoutait pas l'espion :

  • Un spy, c'est une doublure qui remplace un objet réel en enregistrant les sorties indirectes de l'objet à tester pour vérification ultérieure par le test

C'est cette nomenclature que j'ai utilisée l'an dernier pour mon atelier "Stub et Mock montent sur scène" car elle me parait apporter la précision nécessaire à la compréhension des mécanismes de test.
Quand on parle des doublures, ces objets qui remplacent les objets réels lors des tests, les caractéristiques à identifier (celles qui auront le plus d'impact sur la stratégie d'implémentaiton du test) sont la mise en oeuvre des entrées indirectes et des sorties indirectes.

Voyons un exemple avec l'implémentation en Ruby[2] d'une partie de "Stub et Mock montent sur scène". On a un thermomètre qui sait calculer la température maximale observée à partir de relevés effectués sur un capteur et afficher cette température maximale sur l'écran qui lui est rattaché :

class Thermometre

  def initialize(capteur,ecran)
    @ecran = ecran
    @capteur = capteur
    @temperature_maximale = -273.15
  end

  def affiche_temperature_maximale
    temperature_courante = @capteur.lis_temperature_courante
    if temperature_courante > @temperature_maximale
      @temperature_maximale = temperature_courante
    end
    @ecran.ecris(@temperature_maximale)
  end

end

Une stratégie possible de test est la vérification de l'état de notre thermomètre. Puisqu'il mémorise la température maximale observée après chaque mesure, il suffit de vérifier la valeur mémorisée. Pour cela, on prend un stub pour doubler le capteur, un dummy pour doubler l'écran et on rend accessible l'état de notre thermomètre :

def test_avec_un_stub
  capteur = flexmock("capteur")
  capteur.should_receive(:lis_temperature_courante).and_return(21, 18, 25)

  ecran = Object.new
  def ecran.ecris(texte)
  end

  thermometre = Thermometre.new(capteur, ecran)
  def thermometre.temperature_maximale
    @temperature_maximale
  end

  thermometre.affiche_temperature_maximale
  assert_equal( 21, thermometre.temperature_maximale )
  thermometre.affiche_temperature_maximale
  assert_equal( 21, thermometre.temperature_maximale )
  thermometre.affiche_temperature_maximale
  assert_equal( 25, thermometre.temperature_maximale )
end

Une autre stratégie possible est de vérifier le comportement de notre thermomètre. Pour cela, on va toujours utiliser un stub pour les entrées indirectes fournies par le capteur mais on va prendre un mock pour vérifier ce que le thermomètre envoie à l'écran :

def test_avec_un_stub_et_un_mock
  capteur = flexmock("capteur")
  capteur.should_receive(:lis_temperature_courante).and_return(21, 18, 25)

  ecran = flexmock("ecran")
  ecran.should_receive(:ecris).with(21).times(2).should_receive(:ecris).with(25).once.ordered

  thermometre = Thermometre.new(capteur, ecran)
  3.times { thermometre.affiche_temperature_maximale }
end

Enfin, une troisième stratégie pourrait consister à vérifier les conséquences du comportment de notre thermomètre. On garde notre stub pour les entrées indirectes du capteur et on prend un spy pour enregistrer les conséquences des envois de notre thermomètre à l'écran :

def test_avec_un_stub_et_un_spy
  capteur = flexmock("capteur")
  capteur.should_receive(:lis_temperature_courante).and_return(21, 18, 25)

  ecran = Object.new
  def ecran.ecris(texte)
    @texte = texte
  end
  def ecran.affiche
    @texte
  end

  thermometre = Thermometre.new(capteur, ecran)
  thermometre.affiche_temperature_maximale
  assert_equal( 21, ecran.affiche )
  thermometre.affiche_temperature_maximale
  assert_equal( 21, ecran.affiche )
  thermometre.affiche_temperature_maximale
  assert_equal( 25, ecran.affiche )
end

Quelle est, de manière générale, la meilleure stratégie ?
Il n'y a pas de réponse unique à cette question. Cela dépend fortement de ce que l'élément que l'on est en train de tester.

Pour ce qui est de cet exemple précis, on peut toutefois avancer quelques arguments.

La simple vérification de l'état du thermomètre n'est peut être pas le meilleur choix. Imaginons que, lors d'une modification de code, un développeur supprime la ligne @ecran.ecris(@temperature_maximale) de notre thermomètre. On aurait donc ce code là :

def affiche_temperature_maximale
  temperature_courante = @capteur.lis_temperature_courante
  if temperature_courante > @temperature_maximale
    @temperature_maximale = temperature_courante
  end
end

Et le test de vérification d'état serait toujours vert malgré l'absence totale d'affichage à l'écran...

La vérification de comportement nous aurait signalé le problème :

Failure:
test_avec_un_stub_et_un_mock:
in mock 'ecran': method 'ecris(21)' called incorrect number of times. <2> expected but was <0>.

La vérification des conséquences du comportement en aurait fait de même :

Failure:
test_avec_un_stub_et_un_spy:
<21> expected but was .

Dans un exemple aussi simple, le cas est peu probable diront certains[3] mais dans du code un peu plus compliqué, ça arrive plus souvent qu'on ne le croit. D'autres diront[4] que la pratique du TDD n'aurait jamais pu conduire à cette situation : le code d'envoi à l'écran n'aurait jamais été écrit sans test qui ne passe pas.
Quoi qu'il en soit, un critère de qualité d'un test qu'il faut toujours avoir à l'esprit : un bon test passe au rouge si le code devient mauvais.

La vérification du comportement de notre thermomètre pose d'autres questions : si on vérifie le comportement de manière trop stricte, n'arrive-t-on pas à de la sur-spécification inutile ? Par exemple, un développeur qui passe sur notre code pourrait envisager une optimisation "Pas besoin d'écrire sur l'écran à chaque fois : il suffit de le faire lorsque le maximum change" et faire la modification suivante :

def affiche_temperature_maximale
  temperature_courante = @capteur.lis_temperature_courante
  if temperature_courante > @temperature_maximale
    @temperature_maximale = temperature_courante
    @ecran.ecris(@temperature_maximale)
  end
end

Et là, il se retrouve avec un test rouge pour une raison inutile qui l'oblige à aller modifier le test.

Failure:
test_avec_un_stub_et_un_mock:
in mock 'ecran': method 'ecris(21)' called incorrect number of times. <2> expected but was <1>.

Les deux autres stratégies de test n'ont pas ce problème là.

Donc, dans cet exemple précis[5], le couple stub+spy est, à mon avis, le bon choix. Le stub permet de simuler la lecture sur un capteur et le spy permet de vérifier les conséquences du comportement de l'élément testé.

Notes

[1] Google est d'accord

[2] Si un spécialiste Ruby passe par ici, qu'il n'hésite pas à pointer mes erreurs de débutant. J'ai choisi d'utiliser ce langage dans mes exemples pour son aspect dynamique qui simplifie grandement l'écriture des doublures

[3] et ils n'auront pas forcément raison

[4] et ils auront tout à fait raison

[5] N'en faisons pas une généralité

sfui

Très intéressant.

Si je peux me permettre un commentaire…

Dans ton exemple, on se pose la question de "que doit-on tester ?" car la méthode fait 2 choses : (1) elle change l'état du thermomètre et (2) elle affiche l'état du thermomètre. D'où l'envi (voire le besoin) de vouloir tester les 2.
Si on pousse la réflexion plus loin, dans ton exemple, on peut considérer que le changement d'état est une implémentation, que la propriété correspondante est privée et que donc nous ne chercherons pas (voire ne pouvons pas) à tester. Ce qui nous débarrasse du test stub seul.

Ensuite, pour choisir entre les 2 autres tests, je pense qu'aborder la question par le *comment* illustre parfaitement que la vrai question est le *quoi*. Le refactore qui fait "planter" le test avec mock change tout de même le comportement du thermomètre.
Il me semble donc important que cette question du comportement soit posée au PO ; le test devant refléter sa décision. Ainsi on pourra choisir la meilleure technique pour tester le comportement choisi.
Tous les refactores ne permettent pas alors forcément de maintenir cette décision…

sfui 12 avril 2011 - 11:24
Oaz

@sfui,

Tout à fait d'accord avec ton commentaire. En fait, mon billet visait plus à mettre en évidence la diversité des xunit patterns par rapport au trop simpliste et trop souvent entendu "test d'état vs test de comportement" mais, sur la fin, je me suis laissé aller à quelques propos hors sujet par rapport à mon but initial.

Pour ce qui est du changement de code du thermomètre qui fait planter le test sur le comportement, la question sous-jacente, c'est "quel est le contrat représenté par la méthode 'écris' ?"
Est-ce un appel qui permet de connaitre la dernière valeur observée ?
Est-ce un appel qui permet de connaitre tous les changements d'état de la variable observée ?
Est-ce un appel qui permet de connaitre toutes les observations ?

La réponse dépend du PO, mais pas forcément de manière directe. Si, dans la réalité, l'écran est toujours un simple afficheur d'une valeur unique alors le contrat pourrait être renommé en "ecris_la_derniere_valeur_observee" et l'ambiguité serait ainsi levée. Si l'écran est un traceur de graphe des diverses valeurs observées à intervalle régulier alors le choix sera probablement différent.

Oaz 12 avril 2011 - 17:55
Guillaume

Réflexion très intéressante. Cependant j'ai du mal à voir ce qui différencie mock et spy tels que tu les définis. Je ne connais rien à Ruby mais la seule différence entre eux semble être que les expectations sur le mock d'écran sont fixées directement sur lui par le framework d'isolation (ecran.should_receive(...)) alors qu'avec le spy d'écran cela se fait "manuellement" en deux étapes, enregistrement des valeurs dans le spy puis test de l'état du spy avec des assert classiques ?
Ca me parait quand même très proche, je parlerais plus volontiers de différence d'implémentation dans ce cas-là plutôt que d'instruments de test d'une nature réellement distincte.

S'il est encore besoin de prouver que personne n'a la même définition de mock et stub, voici celles que je retiens personnellement ;-)

Un stub est une dépendance factice dont l'état ne sert pas à déterminer le résultat du test (on n'asserte pas dessus).
Un mock est une dépendance factice dont l'état final va déterminer le résultat du test.

En résumé le stub va nous servir lorsque le TU aimerait s'en ficher de savoir que l'objet ou la méthode testée se sert d'une dépendance pour arriver à ses fins (l'objet pourrait tout faire lui même, ça serait pareil) tandis que le mock sert au TU à vérifier précisément comment notre objet testé utilise cette dépendance.

Ce sont bien entendu des définitions mockistes, c'est à dire considérant comme "unitaires" seulement les tests qui isolent l'objet testé de l'extérieur, et comme "d'intégration" tous les autres.

Dan le même ordre d'idées, une autre théorie intéressante est celle de JB Rainsberger "Integration Tests Are a Scam" (http://www.infoq.com/presentations/...) où il distingue 3 types de tests :

- Les tests d'état : est-ce que mon objet se parle bien à lui-même et se met lui-même dans un bon état ? (=une partie de ton premier test)
- Les tests de contrat : est-ce que mon objet accepte les questions qui lui sont posées de l'extérieur et fournit-il les bonnes réponses ?
- Les tests de collaboration : est-ce que mon objet pose à ses dépendances les bonnes questions et est-il capable de gérer leurs réponses ?

Cependant là où tu expliques qu'on peut choisir une stratégie de test parmi plusieurs selon le cas, cette théorie affirme qu'un test n'est complet qu'à partir du moment où l'on a utilisé ces 3 stratégies sans exception. C'est de l'extrémisme "isolationniste" sans aucun doute, mais cela permet peut-être de couvrir tous les cas possibles en évitant de se poser des questions existentielles de type fromage ou dessert pour mes TU ?

Oaz

@Guillaume

Précisons tout d'abord que ce ne sont pas mes définitions de mock et spy mais celles de Meszaros. Ca fait quand même une différence.
Ces deux patterns sont effectivement très proches car ils portent tous deux sur la vérification des sorties indirectes du système à tester
La principale différence de concept que j'observe entre ces deux patterns c'est que l'un vérifie directement le comportement, l'autre se focalise sur certaines (choisies par le testeur) conséquences de ce comportement.
Sur http://xunitpatterns.com/, Meszaros va bien plus loin et explique les différences d'utilisation entre les deux patterns. Je n'y reviendrai pas.

En ce qui concerne les 3 niveaux de tests de JB Rainsberger, je suis un peu sceptique sur la systématisation de cet extrémisme. D'après mon expérience, qui ne vaut pas plus que ce qu'elle vaut, les objets ayant un état intéressant et les objets ayant un comportement intéressant forment deux ensembles plus ou moins distincts.

Quant à la différence entre tests de contrat et tests de collaboration, je ne la comprends pas. Je ne vois pas ça comme 2 éléments ayant une nature distincte, c'est plutôt un détail d'implémentation joue sur l'initiation de la séquence de test :-)

Oaz 13 avril 2011 - 01:37
Guillaume

J'ai relu les définitions de Meszaros et je comprends mieux la différence entre mock et spy. Je ne m'en souvenais pas mais il définit effectivement un mock comme un objet qui fait lui-même des vérifications en interne quand il est appelé et remplace donc le recours à Assert ("The test need not do any assertions at all! " - http://xunitpatterns.com/Mock%20Obj...). Dans ce cadre je comprends effectivement qu'on puisse préférer un spy qui rend le test plus expressif. De toute façon je suis beaucoup plus fan de la forme de test Arrange-Act-Assert que des autres, notamment Record-Replay. En revanche je reste toujours persuadé que spy et mock définis ainsi vérifient exactement la même chose...

En ce qui concerne les tests de contrat et de collaboration, tout test de collaboration sur l'objet X a pour pendant un test de contrat sur sa dépendance Y, les 2 se complètent et sont nécessaires. Pour reprendre ton exemple, cette méthode préconiserait d'avoir à la fois un test qui vérifie que le thermomètre appelle bien la bonne méthode de l'écran et lui passe les bonnes valeurs (test de collaboration du thermomètre), ET un test qui vérifie que l'écran peut accepter ces valeurs et se comporter comme attendu étant donné ces valeurs (test de contrat de l'écran).

Oaz

@Guillaume,

Et pourtant, malgré leur cousinage (leurs vérifications portent sur les sorties indirectes) spy et mock ne vérifient pas la même chose.
Je vais essayer une analogie.
Le spy, c'est le stagiaire dont on se sert pour faire des taches demandant peu de prise de responsabilité. Il ne se souvient pas forcément de tout ce qui se dit mais il fait remonter les infos nécessaires à son patron (le test) pour que celui-ci prenne la décision de vérification adéquate en utilisant des données que le spy n'a pas toujours en sa possession.
Le mock, c'est quelq'un de plus expérimenté. Le patron lui fait confiance pour faire des vérifications. On aime bien l'utiliser car il est rapidement opérationnel à moindre coût mais si la situation se complique par rapport à ce qu'il sait faire vite et bien, le patron préfèrera faire les choses lui même à l'aide d'un spy.

Pour ce qui est des "tests de contrat et de collaboration", je suis tout à fait d'accord avec la nécessité d'avoir 2 tests, un de chaque côté de "ecris". Mais dans les 2 cas j'appelle ça "test de contrat". Je ne vois aucune raison pour donner 2 noms différents. L'utilisation du sens des dépendances n'apporte, à mon sens, aucune information utile à la définition du scénario de test.
Dans l'état actuel, le thermomètre dépend d'un écran et on met en place le système en injectant un écran dans le thermomètre. Si je comprends bien ton raisonnement (et celui de jbrain), c'est ce qui permet de dire que l'écran ne fait qu'exposer un contrat et que le thermomètre s'attend à travailler avec un collaborateur.
Donc, dans le cas où je déciderais de modifier ma conception pour injecter le thermomètre dans l'écran en utilisant, par exemple, un système d'évènements levés par le thermomètre quand il y a qqe chose à afficher et auquel on abonnerait l'écran, j'aurais exactement le même scénario mais, en tant que thermomètre, mon test de collaboration avec un écran se transformerait en test de contrat où j'expose des évènements.
Cette distinction artificielle ne m'apporte rien. Quel que soit le sens des dépendances, l'information circule du thermomètre vers l'écran et c'est la seule chose qui m'importe vraiment pour mettre en place mes tests.

Oaz 13 avril 2011 - 23:58
Guillaume

Je comprends la subtilité, je dis juste qu'entre créer un spy qui surcharge des méthodes pour y placer des "mouchards" qui vont témoigner du passage dans ces méthodes/des valeurs passées, et utiliser un mock issu d'un framework d'isolation qui fait ça magiquement en interne, c'est du kif kif, surtout dans ton exemple, les deux détectent les mêmes erreurs. Si les frameworks d'isolation n'existaient pas, on utiliserait sans doute un seul terme pour désigner ce type d'objet dont le but est de tester la bonne collaboration, ou plus exactement la bonne sollicitation d'un objet par un autre.

"Si je comprends bien ton raisonnement (et celui de jbrain), c'est ce qui permet de dire que l'écran ne fait qu'exposer un contrat et que le thermomètre s'attend à travailler avec un collaborateur."

> Exactement.

"Donc, dans le cas où je déciderais de modifier ma conception pour injecter le thermomètre dans l'écran en utilisant, par exemple, un système d'évènements levés par le thermomètre quand il y a qqe chose à afficher et auquel on abonnerait l'écran, j'aurais exactement le même scénario"

> Pas vraiment. Je vais essayer d'expliquer pourquoi même si JBR ne parle pas d'événements mais juste d'appels de méthodes. Le but des tests en isolation (et un des enjeux essentiels de la POO) est de bien gérer la notion de couplage, de dépendance entre objets. Un Thermomètre qui lèverait simplement un événement ne serait plus couplé à l'Ecran, il n'aurait plus de référence vers lui puisqu'il n'appellerait plus directement une de ses méthodes. En revanche, l'Ecran serait fortement couplé au Thermomètre puisqu'il s'abonnerait à un de ses événements. Dans ce cadre, la dépendance est inversée, il est légitime que le test de collaboration avec l'écran se transforme en test de contrat.

On raisonne donc beaucoup plus sous l'aspect "qui dépend de qui" que sous l'aspect "dans quel sens circule l'information".

"Cette distinction artificielle ne m'apporte rien."

> La nécessité d'avoir à la fois des tests de collaboration et de contrat fait partie de ce que JBR appelle "basic correctness" et qui serait selon lui un moyen de vérifier que chaque objet fonctionne en harmonie avec toutes ses dépendances externes sans avoir à recourir à des tests d'intégration hasardeux et sans fin. J'imagine qu'on distingue collaboration et contrat car 1/ on veut avoir un vocabulaire précis pour identifier tous les types de tests nécessaires sur un objet (y compris dans l'API d'un hypothétique framework basé sur cette théorie qui verrait le jour), 2/ on ne teste pas la même chose dans ces deux types de test et 3/ ils n'utilisent pas les mêmes instruments de test (typiquement un test de collaboration va + utiliser un mock).

Oaz

1/ on veut avoir un vocabulaire précis pour identifier tous les types de tests nécessaires sur un objet

On peut inventer toutes sortes de typologies, ça ne les rend pas utiles pour autant

2/ on ne teste pas la même chose dans ces deux types de test

Dans les 2 cas on teste le comportement d'un SUT face à son environnement

3/ ils n'utilisent pas les mêmes instruments de test (typiquement un test de collaboration va + utiliser un mock).

Non. C'est faux.

Ce que montre fort justement Meszaros, c'est que l'important dans un test de comportement, ce sont les sorties indirectes et que le choix de mise en oeuvre du test dépend donc principalement du sens d'écoulement des données.

Qu'ils soient dans un test de "contrat" ou de "collaboration", le stub est toujours du côté de l'entrée des données et le mock ou le spy toujours du côté de la sortie.

Dans le billet, on test sur le thermomètre une soi-disante "collaboration" avec l'écran et l'écran est un mock ou un spy. Si j'inverse la dépendance, on teste un soi-disant "contrat" pour le thermomètre mais l'écran est toujours un mock ou un spy parce que c'est un contrat de réception de données.

La preuve en images (et en plus ça me fait pratiquer mon Ruby).

J'inverse la dépendance en définissant un thermomètre"observable" :

class ThermometreObservable

  include Observable

  def initialize(capteur)
    @capteur = capteur
    @temperature_maximale = -273.15
  end

  def affiche_temperature_maximale
    temperature_courante = @capteur.lis_temperature_courante
    if temperature_courante > @temperature_maximale
      @temperature_maximale = temperature_courante
    end
    changed
    notify_observers(@temperature_maximale)
  end

end

Je modifie le test avec un mock en conséquence tout en gardant mon ecran comme mock :

  def test_avec_un_stub_et_un_mock
    capteur = flexmock("capteur")
    capteur.should_receive(:lis_temperature_courante).and_return(21, 18, 25)

    ecran = flexmock("ecran")
    ecran.should_receive(:update).with(21).times(2).should_receive(:update).with(25).once.ordered

    thermometre = ThermometreObservable.new(capteur)
    thermometre.add_observer(ecran)
    3.times { thermometre.affiche_temperature_maximale }
  end

Ou je peux aussi faire avec un spy :

  def test_avec_un_stub_et_un_spy
    capteur = flexmock("capteur")
    capteur.should_receive(:lis_temperature_courante).and_return(21, 18, 25)

    ecran = Object.new
    def ecran.update(texte)
      @texte = texte
    end
    def ecran.affiche
      @texte
    end

    thermometre = ThermometreObservable.new(capteur)
    thermometre.add_observer(ecran)
    thermometre.affiche_temperature_maximale
    assert_equal( 21, ecran.affiche )
    thermometre.affiche_temperature_maximale
    assert_equal( 21, ecran.affiche )
    thermometre.affiche_temperature_maximale
    assert_equal( 25, ecran.affiche )
  end
Oaz 15 avril 2011 - 00:00

Fil des commentaires de ce billet

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.