Les mocks : bonne ou mauvaise pratique ?
Les deux mon capitaine ! Voyons donc à quel moment les utiliser, et à quel moment ne surtout pas les utiliser.
L’une des premières difficultés que l’on rencontre la première fois que l’on souhaite écrire des tests unitaires concerne les mocks.
Je ne vais pas rentrer en détails dans les différents types de mock dans cet article, ça sortirait du périmètre de celui-ci.
Quand ne PAS utiliser de mocks
Résumons simplement la notion de mock à :
“ Un mock permet de remplacer une dépendance afin d’en contrôler le comportement et éventuellement faire des vérifications sur la façon dont cette dépendance à été appelée. ”
Le mot clé ici est : “dépendance”. Commençons par un exemple de ce qui ne faut pas faire :
export const computeMean = (numbers) => { | |
const total = numbers.reduce((total, currentNumber) => total + currentNumber); | |
return total / numbers.length; | |
}; |
export const computeStudentsMeanGrade = (theStudents) => | |
theStudents.map((student) => ({ | |
name: student.name, | |
mean: computeMean(student.grades), | |
})); |
import { computeMean } from '../computeMean'; | |
import { computeStudentsMeanGrade } from '../computeStudentsMeanGrade'; | |
jest.mock('../computeMean'); | |
describe('computeStudentsMeanGrade', () => { | |
it('computes the mean grade', () => { | |
// arrange | |
const students = [ | |
{ | |
name: 'Paul', | |
grades: [10, 15.5, 8, 12], | |
}, | |
{ | |
name: 'Sophie', | |
grades: [18, 16, 9.5], | |
}, | |
{ | |
name: 'Julie', | |
grades: [5.75, 10, 11.5, 8, 14], | |
}, | |
]; | |
// act | |
computeStudentsMeanGrade(students); | |
// assert | |
expect(computeMean).toHaveBeenNthCalledWith(1, students[0].grades); | |
expect(computeMean).toHaveBeenNthCalledWith(2, students[1].grades); | |
expect(computeMean).toHaveBeenNthCalledWith(3, students[2].grades); | |
}); | |
}); |
Dans cet exemple on cherche à tester la fonction computeStudentsMeanGrade.
Comme on nous appris qu’il fallait garder nos tests unitaires en “mockant” ses dépendances, on mock la fonction computeMean
et on s’assure donc qu’elle a bien été appelée avec les bons paramètres.
La fonction computeMean
aura donc son propre test :
export const computeMean = (numbers) => { | |
const total = numbers.reduce((total, currentNumber) => total + currentNumber); | |
return total / numbers.length; | |
}; |
import { computeMean } from '../computeMean'; | |
describe('computeMean', () => { | |
it('computes the mean', () => { | |
expect(computeMean([1,2,3,4,5]).toEqual(3) | |
}) | |
}); |
Le problème avec type de mock c’est qu’il couple totalement l’implémentation au test. Si l’on décide de modifier la fonction computeStudentsMeanGrade
pour utiliser la fonction mean de lodash par exemple, notre test va échouer quand bien même le comportement de la fonction reste le même. C’est que l’on appelle un test fragile car il empêche toute refactorisation du code.
Un test ne doit avoir qu’une seule et unique raison d’échouer : si le comportement testé a été modifié.
Le test de la fonction computeMean
est ici inutile, c’est un détail d’implémentation. Il est préférable de tester directement l’état attendu depuis le test de la fonction computeStudentsMeanGrade :
import { computeStudentsMeanGrade } from '../computeStudentsMeanGrade'; | |
describe('computeStudentsMeanGrade', () => { | |
it('computes the mean grade', () => { | |
// arrange | |
const students = [ | |
{ | |
name: 'Paul', | |
grades: [10, 15.5, 8, 12], | |
}, | |
{ | |
name: 'Sophie', | |
grades: [18, 16, 9.5], | |
}, | |
{ | |
name: 'Julie', | |
grades: [5.75, 10, 11.5, 8, 14], | |
}, | |
]; | |
// act | |
const studentsMeanGrade = computeStudentsMeanGrade(students); | |
// assert | |
expect(studentsMeanGrade).toEqual([ | |
{ | |
name: 'Paul', | |
mean: 11.375, | |
}, | |
{ | |
name: 'Sophie', | |
mean: 14.5, | |
}, | |
{ | |
name: 'Julie', | |
mean: 9.85, | |
}, | |
]) | |
}); | |
}); |
On souhaite test un comportement, ici le comportement à tester est le calcul de la moyenne des étudiants passés en paramètre. Ce comportement est notre unité à tester. Même si cette fonction fait appel à d’autres fonctions, le test reste unitaire, car nous testons une unité de comportement et pas chaque fonction une à une. Ainsi, les détails d’implémentation sont testés de façon transitive, à travers la fonction principale.
Quand est-il nécessaire d’utiliser les mocks ?
Les mocks s’utilisent principalement à la frontière de notre application, pour simuler l’I/O, c’est-à-dire les entrées et les sorties (vers une base de données, vers un serveur, etc.)
Imaginons que nous souhaitons maintenant sauvegarder les moyennes de nos étudiants dans une base de données. Notre fonction pourrait ressembler à ça :
import { computeMean } from './computeMean'; | |
import { saveStudents } from '../infra/database/saveStudents'; | |
export const computeStudentsMeanGrade = async (theStudents) => { | |
const studentsMeans = theStudents.map((student) => ({ | |
name: student.name, | |
mean: computeMean(student.grades), | |
})); | |
return saveStudents(studentsMean) | |
} |
Supposons que la fonction saveStudents
fasse l’insertion en base de données, comment peut-on maintenant tester cette fonction ? Cette fois, nous pouvons utiliser un mock :
import { saveStudentsMeanGrade } from '../saveStudentsMeanGrade'; | |
import { saveStudents } from '../../infra/database/saveStudents'; | |
jest.mock('../../infra/database/saveStudents'); | |
describe('saveStudentsMeanGrade', () => { | |
it('correctly update students mean grade', async () => { | |
// arrange | |
let savedStudentsMeans; | |
saveStudents.mockImplementation(studentsMeans => { | |
savedStudentsMeans = studentMeans; | |
return Promise.resolve(); | |
}); | |
const students = [ | |
{ | |
name: 'Paul', | |
grades: [10, 15.5, 8, 12], | |
}, | |
{ | |
name: 'Sophie', | |
grades: [18, 16, 9.5], | |
}, | |
{ | |
name: 'Julie', | |
grades: [5.75, 10, 11.5, 8, 14], | |
}, | |
]; | |
// act | |
await saveStudentsMeanGrade(students); | |
// assert | |
expect(savedStudentsMeans).toEqual([ | |
{ | |
name: 'Paul', | |
mean: 11.375, | |
}, | |
{ | |
name: 'Sophie', | |
mean: 14.5, | |
}, | |
{ | |
name: 'Julie', | |
mean: 9.85, | |
}, | |
]); | |
}) | |
}) |
Grâce au framework de test Jest et à son système de mock, on a pu ici remplacer l’implémentation réelle de la fonction saveStudents
qui faisait appel à la base de données par une fausse implémentation.
L’utilisation du mock est ici nécessaire pour ne pas ralentir notre test en devant démarrer une vraie base de données par exemple.
Dans cet exemple très simplifié, utiliser le système de mock proposé par Jest est suffisant. En pratique, on préfèrera toutefois utiliser l’injection de dépendance pour éviter d’avoir à importer la fonction saveStudents
directement dans le fichier saveStudentsMeanGrade.js et ainsi se coupler fortement à elle. Mais ce sera le sujet d’un autre article :)
Happy Coding !