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é