Lors d’une récente mission, j’ai travaillé sur un projet comportant énormément de tests unitaires. Ces tests étaient composés essentiellements de ‘stubs’ et de’mocks’.
Résultat, la suite de 1300 tests ne prenait que moins d’une minute à tourner!🔥 🎉
Ces 2 notions “stubs” et “mocks” peuvent parfois prêter à confusion. Quelle est la différence ? En plus des avantages, y a t-il aussi des inconvénients à les utiliser ?
Lorsqu’on entend parler de ‘stubs’ et de ‘mocks’, on pense aux tests doubles.
Les mocks, les stubs (et les Spy) sont appelés des “test doubles”. Un “test double” est un test qui tourne le plus souvent sur de faux objets (des copies d’objets), et qui simule les comportements de ces objets.
Pour illustrer ce concept de test double, on pourrait penser à la doublure d’une actrice : elle est remplacée par une personne qui effectue les actions à sa place, ainsi elle est protégée de tout désagrément.
✅ Pour tester le code en isolation, par exemple, pour ne pas toucher à la base de données.
✅ Pour ne pas déclencher des appels trop couteux. Un service qui ferait appel à une API externe qui répond en 3 secondes induit mathématiquement que le test va prendre plus de 3s pour passer, ce qui est lent. Imaginez si on avait 100 tests qui appellent directement cette API…
✅ Pour ne pas à refaire la configuration dans les tests de certaines dépendances qui peuvent être compliquées. On peut retourner directement le résultat d’une méthode d’un service sans avoir à éxecuter cette méthode.
✅ Pour pouvoir écrire en TDD (Test Driven Development) : on peut simuler les comportements des futures méthodes avant de les implémenter. Par example, des dépendances dont l’objet testé aurait besoins dans le futur.
Dans la suite de cet article, je vais utiliser RSpec pour présenter ces 2 concepts. Vous pouvez ajouter la gem à votre Gemfile ou via :
gem install rspec
Lorsque je bouchonne une méthode, je permets à mon objet de recevoir un appel, et j’ai la possibilité de retourner une réponse de mon choix. Si je n’impose pas de valeur de retour, c’est nil qui est renvoyé par défaut. Dans la syntaxe la plus récente de RSpec, c’est avec la méthode “allow” (autoriser) que l’on créé des ‘stubs’ .
Tester une méthode qui dépend d’un objet, qui lui même fait appel à une API externe:
class Actor
def initialize(name)
@name = name
end
def change_clothes
change = ChangeClothesService.call
"#{change} blablabla"
end
end
RSpec.describe Actor do
describe '#change_clothes' do
# Je n'ai pas besoins de tester le comportement du service ChangeClothesService :
# 1. ce n'est pas le sujet du test
# 2. il doit etre testé ailleurs
it 'do something' do
actor = Actor.new(name: 'Alba Flores')
# Néenmmoins, j'ai besoins que ce service me renvoit quelque chose
# car ma méthode #change_clothes en dépend
allow(ChangeClothesService).to receive(:call).and_return('whatever')
# Maintenant que mon service est bouchonné, je peux lancer mon assertion
# et tester ce que doit renvoyer la méthode
expect(actor.change_clothes).to eq 'whatever blablabla'
end
end
end
Pour mieux comprendre Rspec, on peut tester le comportement en console irb
require 'rspec/mocks/standalone'
# On crée la doublure d'une classe non implémenté
actor = double('Actor')
# => <Double "Actor">
# Ici, actor est un faux objet qui représente une instance de la future classe Actor
actor.current_movie
# RSpec::Mocks::MockExpectationError (#<Double "Actor"> received unexpected message :current_movie with (no args))
Je n’ai pas encore autorisé la méthode #current_movie, autrement dis je n’ai pas bouchonné la méthode.
allow(actor).to receive(:current_movie).and_return('James Bond')
# <RSpec::Mocks::MessageExpectation #<Double "Actor">.current_movie(any arguments)>
# current_movie peut maintenant être appelée sur l'objet
actor.current_movie
# => "James Bond"
La première différence avec le ‘stub’ est la vérification de la méthode qui est appelée sur l’objet.
Ensuite, je peux retourner la valeur de mon choix lorsque cette méthode est appelée.
Utiliser des mocks pour tester une méthode qui déclencherait un appel à un service object
class Actor
def initialize(name)
@name = name
end
def start_acting
reponse = CurrentMovieService.call(@name)
"do something with #{reponse}"
end
end
RSpec.describe 'Actor' do
it 'call the current_movie method' do
# Ici je me base sur un objet réel
# car je veux tester le vrai comportement de la méthode #start_acting
# (avec un faux objet il faudrait bouchonner la méthode)
actor = Actor.new('Norman Reedus')
# Je vérifie que le service est bien appelé via la méthode "expect" et non plus "allow".
# Au passage, je lui impose une valeur de retour, 'result'
# pour qu'il ne fasse rien d'autre, autrement dis, je simule sont comportement.
# parce que mon test n'a pas besoins de savoir ce qu'il fait réellement.
expect(CurrentMovieService).to receive(:call).with('Emma Watson').and_return('result')
reponse = actor.start_acting
# Test du résultat attendu
expect(reponse).to eq 'do something with result'
end
end
Pour mieux comprendre les mocks en Rspec, on peut tester le comportement en console irb :
require 'rspec/mocks/standalone'
# Voici le double d'une classe non implémenté
studio = double('studio')
# => #<Double "studio">
expect(studio).to receive(:location)
# => #<RSpec::Mocks::MessageExpectation #<Double "studio">.location(any arguments)>
# On vérifie si la méthode est appelée (ce qui n'est pas le cas)
RSpec::Mocks.verify
# RSpec::Mocks::MockExpectationError ((Double "studio").location(*(any args)))
# expected: 1 time with any arguments
# received: 0 times with any arguments
Un mock est donc à la fois un bouchon (‘stub’), et une assertion (on vérifie que c’est appelé).
Comme nous l’avons vu, les stubs et les mocks de tests peuvent simplifier et accélérer drastiquement une suite de test en bouchonnant certains appels. Ce sont des outils utiles à maitriser dans une démarche TDD, et permettent aussi de simplifier certains tests.
Il faut cependant les utiliser avec précaution. Lorsqu’on bouchonne les appels, les comportements des méthodes ne sont pas réels, ils sont simulés et définis dans les tests. On peut se retrouver avec une suite de tests qui passe, alors que notre code ne fonctionne pas. En principe, les tests d’intégrations comblent cet inconvénient car ils font de vrais appels sur de vrais objets, et testent donc les comportements réels.