FIRST : 5 principes pour guider l’écriture des tests unitaires

Écrire du code propre est essentiel, et écrire des tests propres l’est tout autant, si ce n’est plus. Design émergent, aide pour retranscrire le métier, documentation, maintenabilité et évolution du code, les tests jouent de multiples rôles au sein d’un projet. C’est pourquoi il est important d’avoir des tests de qualité.

Dans cet article, je vous propose d’explorer 5 principes qui pourront vous guider et vous challenger pour l’écriture de vos tests unitaires. Ces principes sont connus sous l’acronyme FIRST, signifiant avoir des tests :

  • Fast 🚅
  • Independent 📦
  • Repeatable ♻️
  • Self-validating ✅
  • Timely ⏱

En s’inspirant de ces bonnes pratiques, la qualité de vos tests s’améliorera et la qualité du projet et du code de production suivra. Pour mieux comprendre ces principes, faisons le tour de chacun d’eux en explorant leurs raisons d’être, les possibles problèmes en cause et des pistes de solutions.


F : Fast 🚅

Exécuter un test doit être rapide, et surtout, lancer l’ensemble des tests doit être rapide. Cela inclut le temps des setup et teardown, et même de la compilation. Un test unitaire rapide mais avec une longue compilation ou un long setup est un test long. Si vous hésitez pour lancer vos tests après une modification, c’est que vos tests sont probablement trop longs.

Lorsque les tests sont longs, les workflow de développement et de déploiement sont ralentis. Par ailleurs, un des bénéfices des tests unitaires est d’obtenir un feedback rapide. Pour conserver cet aspect, il faut que les tests soient rapides.

C’est encore plus important lorsque vous utilisez un outil qui exécute automatiquement les tests unitaires impactés par vos modifications. C’est le cas en C# par exemple, avec Visual Studio et le Live Unit Testing.

Mais alors, qu’est-ce qu’un test long ? Prenons 150ms. Ça n’a l’air de rien, mais si chaque test prend 150ms, le cumul peut devenir bien plus long. Sur un projet ayant 2000 tests, et cela peut vite augmenter si l’équipe en ajoute régulièrement, on arrive à un total de 5 minutes ! Cumulé sur une journée, c’est énorme et c’est sans compter le temps de compilation ou de setup.

Un temps aussi grand va créer un point de douleur qui va démotiver le développeur, ce qui aura pour conséquence de diminuer la fréquence à laquelle il lance les tests. Dans le pire des cas, il pourrait même ne plus du tout lancer les tests.

Pour avoir un feedback rapide, il est nécessaire de lancer très souvent les tests pendant que l’on développe. En perdant le feedback rapide, on perd aussi du temps. On obtient bien plus tard l’information que quelque chose a cassé. De nombreuses modifications ont pu être ajoutées et il faut identifier laquelle est responsable de l’erreur.

Causes

Les dépendances lourdes comme les accès à une base de données, les appels réseaux, ou les lectures de fichiers sont souvent les responsables d’un test long. Ces opérations durent des centaines de millisecondes, voire plusieurs secondes.

Parfois, les traitements asynchrones ralentissent les tests par le traitement lourd qu’ils peuvent faire, et possiblement aussi par la manière dont les tests gèrent l’asynchronisme (le test étant bloqué tant qu’une réponse n’est pas arrivée). Le multithreading peut poser le même problème.

Solutions

L’injection de dépendance, ou le principe d’inversion de contrôle, est un moyen efficace de s’abstraire des dépendances. Les implémentations réelles sont remplacées par des simulacres, comme des Test Double, qui permettent de simuler des comportements.

En créant une classe qui agira à la place d’un service effectuant une requête HTTP, par exemple, on obtient la flexibilité d’avoir la valeur que l’on souhaite immédiatement. Retourner des objets prédéfinis dans une fonction est une instruction instantanée contrairement à un appel réseau.

class TimeoutArticlesProvider: ArticlesProvider {
        
    func fetchAllArticles(completion: ((Result<String, ApiError>) -> Void)) {
        completion(.failure(.timeout))
    }
}

I : Independent 📦

Un test ne doit pas dépendre d’un autre test. Le résultat d’un test ne doit pas non plus affecter un autre test. Cela peut causer des faux positifs ou cacher un problème. On perdrait du temps à parcourir plusieurs tests pour découvrir l’origine d’une erreur.

On devrait pouvoir lancer les tests dans n’importe quel ordre. De même, on devrait pouvoir lancer n’importe quel sous-ensemble de tests : un seul, une suite de plusieurs tests, ou tous. Si ce n’est pas le cas, c’est une indication que certains tests dépendent les uns des autres.

Tendre vers des tests indépendants permet de focaliser chaque test sur un comportement précis, ce qui donne davantage de sens au mot unitaire. Un test ne devrait avoir qu’une seule raison d’échouer. Cela permet d’identifier rapidement la cause d’une erreur, on comprend tout de suite pourquoi le test a échoué. On évite ainsi une longue session de debug.

On veut donc se rendre indépendant d’une base de données par exemple, pour éviter que l’écriture dans un test A affecte le résultat d’un test B. Ce peut être la même chose pour des singletons, des fichiers. Eventuellement, on va vouloir devenir indépendant de choses lourdes comme les appels réseaux, notamment pour respecter les principes Fast et Repeatable.

Attention à ne pas tomber dans l’extrême. On ne veut pas que le SUT (System Under Test) soit isolé de tout. Non. On veut seulement rendre le test indépendant. Le SUT peut être une classe comme un ensemble de classes, un module. Tout mocker posera des problèmes et rendra le test fragile.

Causes

Compter sur un enchaînement de plusieurs tests afin d’effectuer des actions prérequises à l’exécution d’un autre test va créer un lien de dépendance. De même que partager un état avec d’autres tests. On peut par exemple retrouver ces états dans des propriétés de la classe de test, dans des singletons, dans des bases de données, dans la lecture et l’écriture de fichiers… Bref, tout ce qui est partagé.

Solutions

Ici aussi l’injection de dépendance aidera à remplacer les dépendances gênantes pour le test. A celà, on peut ajouter le modèle Arrange-Act-Assert afin de structurer l’écriture d’un test en trois grandes parties en plus d’apporter de la rigueur et de la cohérence entre les tests.

  • Arrange : on prépare toutes les données requises pour le test, ainsi que l’état dans lequel le test doit se trouver. On s’abstrait des autres tests et de l’environnement, ainsi que des dépendances en créant les Test Double nécessaires.
  • Act : la méthode testée est appelée. Les données requises ont été initialisées dans l’étape précédente, et les dépendances injectées.
  • Assert : on vérifie que le résultat est bien celui qu’on attend grâce aux différentes méthodes Assert du framework de test. Un test doit tendre à n’avoir qu’un seul assert pour n’avoir qu’une seule raison d’échouer. Dans certains cas, on peut avoir plusieurs assert lorsqu’ils se regroupent sous une même logique.
class HelloServiceTests: XCTestCase {
    
    func testMakeMessage() {
        // Arrange
        let now = 1587301244
        let dateProvider = FakeDateProvider(now: now)
        let service = HelloService(dateProvider: dateProvider)
        
        // Act
        let message = service.makeMessage()
        
        // Assert
        XCTAssertEqual(message, "1587301244 - Hello.")
    }
}

Un autre moyen de donner de l’indépendance aux tests est d’utiliser les méthodes de setup et de teardown. Ces méthodes sont respectivement appelées avant et après l’exécution de chaque test. Le teardown est très utile pour effectuer un nettoyage après chaque test, par exemple.

Le nommage des tests a son importance et peut aider à rendre des tests indépendants. Chercher à mettre en valeur l’intention du test à travers son nom force à tester un comportement plus précis. Et par la même occasion, cela facilite la compréhension pour déterminer pourquoi le test a échoué.

Certains frameworks ou IDEs proposent d’exécuter les tests dans un ordre aléatoire. Activez cette option. Vous aurez rapidement une idée quels tests ont un problème, et cela vous empêchera peut-être d’ajouter un nouveau test dépendant.


R : Repeatable ♻️

Un test doit toujours produire le même résultat. Toujours. Peu importe le nombre de fois où il est exécuté, la date ou le lieu. Si un test ne réagit parfois pas de la même manière, on ne peut pas lui faire confiance.

Comment savoir si la raison d’un échec est “normale” ou réellement problématique ? À quel point les erreurs sont bien détectées ? En se posant sans cesse des questions sur les tests, on perd en sérénité, rendant alors les changements dans le code plus difficiles et plus hésitants.

Pour un état de départ donné et une action exécutée, on doit pouvoir prédire la réponse. Sans résultat déterministe, les sessions de debug seront plus compliquées lorsque des erreurs surviendront (et idéalement, on n’a pas besoin de debugguer). Certains problèmes pourraient être difficiles, voire impossibles à reproduire à l’identique.

Un test doit éviter de dépendre de sources de données non-prévisibles. Des sources changeantes, pouvant être indisponibles, des états extérieurs ou des environnements sont des dépendances empêchant d’obtenir continuellement le même résultat.

Causes

Les bases de données sont des sources de données changeantes. Elles pourraient être modifiées par d’autres tests ou d’autres lancements. Les APIs web peuvent également exposer des données différentes à chaque appel, en plus d’être potentiellement indisponible (serveur en maintenance) ou inaccessible (panne de réseau).

Tester du code utilisant ces types de dépendances donnera un résultat incertain. Les singletons, les configurations et les environnements peuvent aussi être responsables d’un test ayant une sortie non garantie. Plus souvent oubliés : l’utilisation des dates et des fonctions aléatoires.

Solutions

On veut toujours avoir le même résultat, donc on veut contrôler entièrement le test, donc on doit rendre contrôlable certaines dépendances. L’injection de dépendance permettra de s’abstraire des sources de données non-prévisibles en ayant le contrôle sur les réponses générées. Par exemple, on peut simuler une requête échouant suite à un timeout pour les besoins d’un test, et par ailleurs simuler un succès pour un autre test.

Cette même solution peut être utilisée pour gérer les dates et les fonctions aléatoires. Pour cela, on transforme l’accès à la date en le rendant indirect, via une interface, afin d’ajouter une abstraction qui permettra de remplacer la valeur réelle par un Test Double dans les tests.

protocol DateProvider {
    var now: Date { get }
}

class DeviceDateProvider: DateProvider {
    var now: Date {
        return Date()
    }
}

class HelloService {
    
    private let dateProvider: DateProvider
    
    init(dateProvider: DateProvider = DeviceDateProvider()) {
        self.dateProvider = dateProvider
    }
    
    func makeMessage() -> String {
        let now = Int(dateProvider.now.timeIntervalSince1970)
        return "\(now) - Hello."
    }
}

En complément, le modèle Arrange-Act-Assert apporte la structure pour définir la valeur des Test Double et l’état initial. On peut aussi s’appuyer sur les méthodes de teardown pour faire un nettoyage après chaque test.


S : Self-validating ✅

Chaque test doit être autonome pour déterminer si le résultat correspond aux attentes. Personne ne devrait intervenir. Sa sortie est simple : un test a réussi ou a échoué.

Il ne devrait pas y avoir besoin d’interprétation manuelle. Si on est amené à lire un fichier, à comparer des résultats, à chercher des informations, il y a un risque de faire une erreur humaine. C’est aussi plus long et rébarbatif, ce qui pourrait décourager les développeurs de lancer les tests.

Un test ne devrait pas non plus nécessiter d’action manuelle, ni avant le lancement, ni après. Il ne faut pas avoir à intervenir pour préparer un environnement ou nettoyer des artefacts, par exemple. Si un tel besoin est présent, il faut l’automatiser ou l’intégrer dans le test.

Solutions

Avec les outils actuels, les tests respectent globalement déjà ce principe. Il suffit d’ajouter une assertion dans un test pour qu’il puisse valider lui-même le résultat ; le framework et l’outillage s’occupe du reste. L’assertion la plus basique est une déclaration indiquant que quelque chose doit être vrai.

Les frameworks de tests proposent en général plusieurs méthodes d’assertion : assert true, assert not null, assert equal, assert throw exception, etc. Au moins l’une de ces instructions doit être présente dans un test.

L’interprétation du résultat des tests peut s’améliorer avec des solutions plus visuelles. Certains frameworks et IDE proposent des interfaces graphiques listant les tests et mettant en valeur les succès et les échecs.

En ce qui concerne l’automatisation pour éviter une intervention manuelle, là encore l’injection de dépendance et la structure Arrange-Act-Assert sont utiles. En préparant et simulant un environnement spécifique pour un test, par exemple.


T : Timely ⏱

Un test doit être écrit au bon moment. Idéalement, le test est écrit juste avant le code de production qui fera réussir le test. Ce mode opératoire apporte de réels bénéfices et fait la différence. De cette manière, on avance par plus petits bouts et on se concentre sur l’essentiel. Les tests, déjà écrits, nous aident et nous guident. On voit émerger le design du code.

À l’opposé, écrire les tests après le code de production comporte des risques. L’ajout de tests a posteriori est plus long et plus difficile. Cela nécessite un travail supplémentaire : il faut souvent adapter le code de production qui n’est pas testable. Ce travail est parfois conséquent et coûteux. Plus on éloigne l’écriture des tests, plus ce risque sera grand.

Outre la difficulté, la motivation peut baisser lorsqu’il s’agit d’écrire des tests lorsque l’on a terminé. Un sentiment de culpabilité peut également naître en pensant au temps supplémentaire à passer pour ajouter des tests et adapter le code de production alors que tout est déjà prêt.

Solutions

Commencer par écrire un test fait partie de l’approche Test-Driven Development (TDD). Le développement est guidé par les tests. On est guidé pour implémenter le comportement par petites étapes. Suivre cette pratique, c’est suivre un cycle précis : RED, GREEN, REFACTOR.

Pour faire simple, on écrit d’abord un test qui échoue ou ne compile pas : c’est rouge. On cherche ensuite à passer au vert en écrivant le minimum de code de production, en allant au plus simple. Puis on refactorise, on améliore le code de production et le code des tests. La boucle est bouclée, alors on recommence. On itère jusqu’à ce qu’on ait répondu au besoin et qu’on soit satisfait du code.