Avez-vous déjà eu à vous occuper d’une plante ? Vous n’avez peut-être pas la main verte, mais vous avez probablement une idée des soins à donner aux plantes. Vous savez qu’il leur faut de l’eau, mais pas trop. Vous savez qu’elles ont besoin de lumière. Quoi d’autre ? De la terre ? En fonction de la plante, vous vous souviendrez de certains principes quand vous en aurez besoin. Ce ne sont pas des règles, mais plutôt des lignes directrices.
Le développement de logiciels a beaucoup de grands principes pour vous aider à éviter les erreurs bêtes et à écrire du meilleur code. Deux grands ingénieurs, Tim Ottinger et Brett Schuchert, qui ont écrit certains chapitres du livre Clean Code (en anglais), ont inventé un acronyme décrivant les principes qu’ils utilisent pour enseigner comment écrire d’excellents tests : F.I.R.S.T.
Écrivez des tests de qualité avec les principes F.I.R.S.T.
F pour fast (rapide)
Qu’appelle-t-on rapide ? Vous devriez viser de nombreuses centaines ou milliers de tests par seconde. ⚡️ Cela vous semble trop ? Pas si chaque test unitaire ne teste qu’une classe.
Vous saurez si votre test n’est pas assez rapide une fois que vous l’aurez lancé ! D’après Tim Ottinger, même un quart de seconde est péniblement lent. Les tests peuvent s’accumuler – surtout si vous en avez des milliers ! J’ai connu des suites de tests qui prenaient des heures, ce qui les rendait impossibles à exécuter régulièrement.
Votre IDE permet de vérifier les temps d'exécution des tests, comme le montre la figure ci-dessous :
Si vous subissez des ralentissements à cause d'accès réseau ou disque, essayez d’en faire abstraction en utilisant des mocks !
I pour isolé et indépendant
Avez-vous déjà eu à résoudre un problème complexe en le divisant en petites entités sur lesquelles vous pouviez travailler séparément ? Parfois, cela aide à rester concentré et à résoudre les problèmes. Un problème ==> une cause du problème ==> une solution. C’est pareil pour les tests. Quand ils échouent, il vous faut comprendre pourquoi.
Les principes AAA ou GIVEN/WHEN/THEN que nous avons vus précédemment dans ce cours peuvent vous aider ici : chaque test organise sa propre classe de sous-tests, et ne fait qu’une assertion par test. Cela garantit que vos tests utilisent des données séparées : vous pouvez les isoler pour qu’ils n’interfèrent pas les uns avec les autres. Il y a très peu de recoupements à partir de là, vos tests restent donc indépendants.
Voici un exemple de tests simples qui ne respectent pas l'isolation :
public class CalculatorTest {
private Calculator calculatorUnderTest;
private int cacheFactorial;
@Test
public void fact12_shouldReturnsTheCorrectAnswer() {
// GIVEN
final int number = 12;
// WHEN
// Calculer 12! et sauve la valeur pour un autre test
cacheFactorial = calculatorUnderTest.fact(number);
// THEN
assertThat(cacheFactorial).isEqualTo(12 * 11 * 10 * 9 * 8 * 7 * 6 * 5 * 4 * 3 * 2);
}
@Test
public void digitsSetOfFact12_shouldReturnsTheCorrectAnswser() {
// GIVEN
// 12! est mis en cache par le test précédent
// WHEN
final Set<Integer> actualDigits = calculatorUnderTest.digitsSet(cacheFactorial);
// THEN
assertThat(actualDigits).containsExactlyInAnyOrder(0, 1, 4, 6, 7, 9);
}
}
Si le premier test échoue pour n'importe quelle raison ou que l'ordre des tests est changé, le deuxième test peut aussi être impacté ! En effet, la variable cacheFactoria
l (ligne 5) est affectée d'une valeur au premier test (ligne 14) puis un deuxième test s'appuie sur cette valeur pour qu'il soit en succès (ligne 27).
Vous voulez rendre votre test reproductible et éviter d’avoir des effets de bord, c'est-à-dire un état de votre système à tester qui change et dont le changement se répercute sur d'autres tests. Pour cela, pendant l'étape Arrange ou Given, vous devez vous assurer de commencer vos tests avec une nouvelle instance de votre système (pas une instance déjà utilisée par un autre test) et des données dédiées à chacun de vos tests.
Comme vous pouvez le voir, les tests peuvent facilement échouer pour des raisons externes quand ils sont liés par une dépendance partagée. Contrairement au code de l'application, lorsque vous testez, il peut être acceptable de vous répéter, si cela préserve l’isolement de vos tests.
R pour répétable
Si vous écrivez un test qui vous donne la confiance nécessaire en votre code, il doit vous dire la même chose, peu importe où, ou combien de fois, vous l’exécutez. Mais parfois les tests deviennent instables et doivent être lancés plusieurs fois pour réussir.
Par exemple, l'utilisation du "hasard", c'est-à-dire utiliser des nombres ou des chaînes de caractères aléatoires, peut provoquer des comportements inattendus pour vos tests. Sur l'exemple de notre calculateur, cela peut consister à utiliser des nombres aléatoires pour le test suivant : multiplier par un nombre puis diviser par ce même nombre redonne le nombre de départ :
@Test
public void multiplyAndDivide_shouldBeIdentity() {
// GIVEN
final Random r = new Random();
final int a = r.nextInt() % 100; // Nombre aléatoire entre 0 et 99
final int b = r.nextInt() % 10; // Nombre aléatoire entre 0 et 9
// WHEN on multiplie a par b puis on divise par b
final int c = calculatorUnderTest.divide(calculatorUnderTest.multiply(a, b), b);
// THEN on ré-obtient a
assertThat(c).isEqualTo(a);
}
Ce test fonctionne... sauf si ce nombre utilisé pour multiplier et diviser est 0 ! (variable b à la ligne 6). Auquel cas, le test va échouer à cause d'une exception de division par zéro !
Vos tests doivent aussi être reproductibles quel que soit l'environnement d'exécution. En général, votre application, et a fortiori vos tests, ne dépendent pas du système sur lequel ils sont exécutés (Windows, Linux, OSX), ni de l'encodage ou des paramètres locaux du système.
Voici un exemple de code non reproductible à cause de paramètres liés au système. Voyons cela avec JShell. Pour formater un nombre, si vous êtes sur un système configuré pour être français, avec JShell, vous devriez obtenir ces résultats :
jshell> String.format("%,d", 123456789); $1 ==> "123 456 789"
Mais si votre système est américain, ou que vous simulez un système américain avec la commande :
jshell -R-Duser.language="us-US"
vous obtiendrez un résultat différent pour le formatage des nombres !
jshell> String.format("%,d", 123456789); $1 ==> "123,456,789"
Il existe beaucoup d'exemples comme celui-ci : formatage des caractères de fin de ligne, encodage du système par défaut, formatage des dates, etc.
S pour self-validating (autovalidation)
Ce terme a clairement été inventé par un groupe d’ingénieurs. 🙂 Levez la main si vous pensez savoir ce que l’autovalidation signifie.
Une chose qui se valide… elle-même ? 🤷🏽♂️
Cela signifie en fait que l’exécution de vos tests ne laisse aucun doute sur leur succès ou leur échec. JUnit accomplit cela et échoue en rouge, ce qui vous laisse faire le rouge-vert-refactor. De plus, l’échec acceptable n’existe pas, contrairement à ce que l’on entend parfois. Si un test n’est pas fiable, il ne doit pas être exécuté. C’est pour cela que nous pouvons utiliser @Disabled temporairement ! Voire supprimer le code du test.
En utilisant un framework de test comme JUnit, des bibliothèques d’assertions, et en écrivant des tests spécifiques, vous pouvez garantir qu’en cas d’échec d’un test, vous aurez des rapports clairs et sans ambiguïté qui vous diront exactement ce qui a réussi ou échoué. En complément, nous avons vu en première partie qu'utiliser AssertJ vous aide aussi à obtenir des rapports encore plus clairs grâce à des assertions plus sémantiques.
Il peut encore rester des cas limites de test avec des résultats ambigus, en voici un exemple. Toujours sur le formatage des nombres, si on s'assure que l'application formate un résultat au format français :
@Test
public void format_shouldFormatAnyBigNumber() {
// GIVEN
int number = 1234567890;
// WHEN
String result = solutionFormatter.format(number);
// THEN
assertThat(result).isEqualTo("1 234 567 890");
}
Si vous copiez-collez ce code ci-dessus, votre test va échouer :
L'explication de l'échec du test n'est pas très parlante, au contraire ! On pourrait passer des heures à chercher à comprendre, ou bien utiliser @Disabled que l'on a vu au chapitre précédent, pour jeter l'éponge !
Dans ce cas précis, il se trouve que les caractères espaces entre les triplets de chiffres ne sont pas des espaces standard. Si vous copiez-collez le code ci-dessous :
@Test
public void format_shouldFormatAnyBigNumber() {
// GIVEN
int number = 1234567890;
// WHEN
String result = solutionFormatter.format(number);
// THEN
// Attention les espaces entre les chiffres ci-dessous ne sont pas standards.
assertThat(result).isEqualTo("1 234 567 890");
}
votre test va passer en succès. Bizarre, non ? Évitez donc de perdre trop de temps avec ce genre de tests, source de confusion, ou commentez votre code pour bien expliquer !
T pour thorough
Aujourd’hui, on considère souvent que le T signifie approfondi (« thorough »), c’est-à-dire que votre code est testé largement pour des cas négatifs et positifs. Étant donné que la meilleure manière d’écrire des tests approfondis est de s’assurer d’avoir écrit du code largement testable, ces deux aspects tendent vers le même résultat.
Alors… que dois-je tester ?
Pour piloter la conception de votre code avec le TDD, et établir la base de votre pyramide de tests, posez-vous certaines des questions suivantes :
Est-ce que j’ai un test de scénario nominal pour chaque cas que j’ai codé ?
Est-ce que j’ai pensé aux scénarios alternatifs et cas limites ? Et si la date de naissance d’un vampire était après sa mort, en raison de sa résurrection ? Est-ce que mon système pourrait le gérer ? En supposant que vous travaillez en Transylvanie ? 🦇
Est-ce que chacune de mes exceptions lancées est testée ?
Est-ce qu’il existe un scénario où, sans changer le type de données utilisé dans mon test, je peux causer un comportement inattendu ? Que se passera-t-il si je passe une chaîne nulle ou vide ?
Est-ce que j’ai pensé à la sécurité en priorité ? Malheureusement, ce point est toujours le dernier de la liste. Est-ce que ce code peut uniquement être exécuté par les utilisateurs qui ont le droit de le faire ? Et si ce n’est pas le cas ?
Le fait de vous poser ces questions, et de combiner vos tests avec les autres étages de la pyramide, peut vous donner l’assurance que vous avez été aussi exhaustif que possible.
Rappelez-vous que ces principes sont là pour vous guider. À mesure que vous faites des choix dans la conception de votre produit, vous pouvez vous reposer sur eux pour vous rappeler ce que vous devez prendre en compte. En fin de compte, le choix vous revient.
Les bons tests vous apportent sans cesse des points positifs, alors que les mauvais tests deviennent des éléments auxquels vous ne pouvez pas vous fier. Si vous suivez l’acronyme F.I.R.S.T., vous réunissez les conditions pour vous assurer une fondation solide de la pyramide de tests.
Quelques exemples concrets
Dans le screencast ci-dessous, nous allons analyser quelques exemples de tests ne respectant pas le principe F.I.R.S.T et trouver des solutions. Le code de départ de ce screencast se trouve sur le même dépôt Git. Si vous n'avez pas encore cloné ce dépôt, voici pour rappel la commande :
git clone https://github.com/geoffreyarthaud/oc-testing-java-cours.git
Et choisissez la branche correspondant à ce chapitre :
git checkout p2ch3
Nommez vos tests unitaires
Les conventions de nommage sont indissociables du F.I.R.S.T. et sont extrêmement importantes pour construire du code lisible. Comment décider du nom que vous allez donner à votre test unitaire ? Le nom de la classe est facile : vous groupez généralement vos tests par CUT (classe sous test), donc la classe de test et la classe portent des noms correspondants. Par exemple, MyClassTest testerait MyClass. Facile, non ? Mais qu’en est-il des noms de méthode ?
Pendant longtemps, les développeurs (et en particulier les développeurs Java), avaient l'habitude de suivre cette même convention en écrivant les tests unitaires. Qu’est-ce que la méthode testAdd() vous dit sur le test ? Eh bien, vous savez qu’elle teste le « add » (addition), mais est-ce une assertion de bon ou de mauvais chemin ? Est-ce que je teste les nombres négatifs, positifs, ou est-ce que je vérifie simplement que la méthode existe ? De façon plus importante, est-ce qu’elle prouve les cas de tests sur lesquels je travaille ? Le nom du test ne me permet pas de le savoir, donc je ne serai absolument pas plus avancé si le test échoue !
Les développeurs ont créé de nombreuses conventions de nommage pour les tests. En Java, on utilise généralement le « camel case », qui est un mélange de lettresEnMajusculesEtEnMinusculesSansUnderscore
. Néanmoins, quand vous écrivez des tests, votre objectif est de communiquer avec d’autres personnes, et d’obtenir des résultats de test dont vous pouvez discuter dans la vraie vie. On a souvent besoin d'exprimer plusieurs éléments au travers du titre ; par exemple, décrire chaque étape de Arrange/Act/Assert ou Given/When/Then.
Ce que l'on peut faire, c'est du camel case pour chaque élément à décrire et les lier par des caractères underscore. Voici quelques exemples, choisissez ce qui vous convient, tant que vous restez cohérent et descriptif :
MethodName_StateUnderTest_ExpectedBehavior
Exemple : add_twoPositiveIntegers_returnsTheirSum()
Variante : Add_TwoPositiveIntegers_ReturnsTheirSum()
Note : Ceci n’est pas du “camel case”, vous pouvez donc décider si vous mettez une capitale à chaque nouvel élément ou non.
MethodName_ExpectedBehavior_StateUnderTest
Exemple : add_returnsTheSum_ofTwoPositiveIntegers()
Variante : Add_ReturnsTheSum_OfTwoPositiveIntegers()
givenStateUnderTest_whenMethodAction_thenExpectedBehavior
Exemple : givenTwoPostiveIntegers_whenAdded_thenTheyShouldBeSummed
Variante : givenTwoPositiveIntegerWhenAddedThenTheyShouldBeSummed()
Pour rappel, ce ne sont que quelques exemples des nombreux styles que vous pouvez trouver ! Le but sous-jacent de tous ces styles de nommage est de garantir que vous communiquiez clairement sur ce sur quoi portent vos tests. Vous avez déjà vu que JUnit5 vous permet d’utiliser l’attribut @DisplayName pour mieux nommer vos tests. Cela vient en complément d’un bon nom de méthode, et vous permet de vous assurer que vous communiquez clairement sur ce que vous testez. Quoi que vous choisissiez, ou trouviez dans un projet, souvenez-vous d’être cohérent.
En résumé
Assurez-vous que vos tests portent des noms qui décrivent clairement ce qu’ils testent. Choisissez un style et tenez-vous à celui-ci !
Utilisez les principes F.I.R.S.T. pour que vos tests demeurent :
PRINCIPE | Qu’est-ce que cela signifie ? |
Fast (Rapide) | Une demi-seconde, c’est déjà trop lent. Vous avez besoin que des milliers de tests soient exécutés en moins de quelques secondes. |
Isolé et indépendant | Quand un test échoue, il vous faut savoir ce qui a échoué. Testez un élément à la fois et n’utilisez plus d’une assertion que s’il le faut vraiment. |
Répétable | Vous ne pouvez pas vous fier aux tests qui échouent certaines fois et réussissent d’autres. Vous devez construire des tests qui n’interfèrent pas les uns avec les autres et qui sont assez autonomes pour donner toujours le même résultat. |
Self-validating (Autovalidation) | Utilisez les bibliothèques d’assertions disponibles, écrivez des tests spécifiques, et laissez votre framework de test s’occuper du reste. Vous pouvez vous y fier pour exécuter vos tests et générer des rapports de tests clairs. |
Thourough (Approfondi) | Utilisez le TDD et écrivez vos tests en écrivant votre code. Explorez toute la gamme d’actions possibles de votre méthode et écrivez beaucoup de tests unitaires. |
La dernière lettre T mérite qu'on s'y attarde un peu plus. En effet, il est important que vous maîtrisiez la manière de tester différents scénarios, et en particulier les cas limites. Ça se passe... au prochain chapitre !