Matteo Manferdini

De nombreux développeurs trouvent les tests unitaires déroutants. Cela est aggravé par les techniques avancées dont vous avez besoin pour tester les classes ou le code asynchrone, comme celui des requêtes réseau.

En réalité, à la base des tests unitaires, il y a des concepts fondamentaux simples. Une fois que vous les avez saisis, les tests unitaires deviennent soudainement plus abordables.

Architecture d’applications SwiftUI avec MVC et MVVM

OBTENEZ LE LIVRE GRATUIT MAINTENANT

Contenu

  • Le test unitaire expliqué en termes simples, compréhensibles
  • Les tests unitaires vous permettent de vérifier les cas limites difficiles à atteindre et de prévenir les bugs
  • Écrire des tests unitaires dans Xcode
  • Une bonne architecture rend votre code plus facile à tester
  • Traiter les types de valeurs comme des fonctions pures pour simplifier les tests unitaires
  • .

  • Vérifier votre code en utilisant les fonctions assertions du framework XCTest
  • Mesurer la couverture de code d’un test dans Xcode
  • Pourquoi viser une couverture de code spécifique n’a pas de sens et ce que vous devriez faire à la place

Les tests unitaires expliqués en termes simples, termes compréhensibles

Lorsque vous écrivez une application, vous passez par un cycle consistant à écrire du code, à l’exécuter et à vérifier s’il fonctionne comme prévu. Mais, à mesure que votre code se développe, il devient plus difficile de tester l’ensemble de votre appli manuellement.

Les tests automatisés peuvent effectuer ces tests pour vous. Donc, pratiquement parlant, les tests unitaires sont du code pour s’assurer que le code de votre app est correct.

L’autre raison pour laquelle les tests unitaires peuvent être difficiles à comprendre est que de nombreux concepts l’entourent : les tests dans Xcode, l’architecture de l’app, les doubles de tests, l’injection de dépendances, et ainsi de suite.

Mais, à la base, l’idée des tests unitaires est assez simple. Vous pouvez écrire des tests unitaires simples en Swift ordinaire. Vous n’avez pas besoin d’une fonction spéciale de Xcode ou d’une technique sophistiquée, et vous pouvez même les exécuter à l’intérieur d’un Playground Swift.

Regardons un exemple. La fonction ci-dessous calcule la factorielle d’un entier, qui est le produit de tous les entiers positifs inférieurs ou égaux à l’entrée.

1
2
3
4
5
6
7

func factorial(de nombre : Int) -> Int {
var result = 1
for factor in 1…nombre {
résultat = résultat * facteur
}
return result
}

Pour tester cette fonction manuellement, vous la nourrissez de quelques nombres et vérifiez que le résultat est celui que vous attendez. Ainsi, par exemple, vous l’essayeriez sur quatre et vérifieriez que le résultat est 24.

Vous pouvez automatiser cela, en écrivant une fonction Swift qui fait le test pour vous. Par exemple :

1
2
3
4
5

func testFactorial() {
if factorial(of : 4) != 24 {
print(« La factorielle de 3 est fausse »)
}
}

C’est un code Swift simple que tout le monde peut comprendre. Tout ce qu’il fait est de vérifier la sortie de la fonction et d’imprimer un message s’il n’est pas correct.

C’est le test unitaire en un mot. Tout le reste est des cloches et des sifflets ajoutés par Xcode pour rendre les tests plus simples.

Ou du moins, ce serait le cas si le code de nos applications était aussi simple que la fonction factorielle que nous venons d’écrire. C’est pourquoi vous avez besoin du support de Xcode et de techniques avancées comme les doubles tests.

Néanmoins, cette idée est utile pour les tests simples et complexes.

Les tests unitaires vous permettent de vérifier les cas limites difficiles à atteindre et de prévenir les bugs

Vous pouvez tirer plusieurs avantages des tests unitaires.

Le plus évident est que vous n’avez pas à tester continuellement votre code manuellement. Il peut être fastidieux d’exécuter votre application et d’arriver à un endroit spécifique avec la fonctionnalité que vous voulez tester.

Mais ce n’est pas tout. Les tests unitaires vous permettent également de tester les cas limites qui pourraient être difficiles à créer dans une application en cours d’exécution. Et les cas limites sont là où vivent la plupart des bogues.

Par exemple, que se passe-t-il si nous introduisons zéro ou un nombre négatif dans notre fonction factorielle(de 🙂 du dessus ?

1
2
3
4
5

func testFactorialOfZero() {
if factorial(of : 0) != 1 {
print(« La factorielle de 0 est fausse »)
}
}

Notre code se casse :

Dans une vraie application, ce code provoquerait un crash. Le zéro est hors de la plage dans la boucle for.

Mais notre code n’échoue pas à cause d’une limitation des plages Swift. Il échoue parce que nous avons oublié de considérer les entiers non positifs dans notre code. Par définition, la factorielle d’un entier négatif n’existe pas, alors que la factorielle de 0 est 1.

1
2
3
4
5
6
7
8
9
10
11
12
13

func factorielle(de nombre : Int) -> Int ? {
if (nombre < 0) {
return nil
}
if (nombre == 0) {
return 1
}
var result = 1
for factor in 1…number {
result = result * factor
}
return result
}

Nous pouvons maintenant exécuter à nouveau nos tests et même ajouter une vérification pour les nombres négatifs.

1
2
3
4
5

func testFactorialOfNegativeInteger() {
if factorial(of : -1) != nil {
print(« La factorielle de -1 est fausse »)
}
}

Vous voyez ici le dernier et, à mon avis, le plus important avantage des tests unitaires. Les tests que nous avons écrits précédemment s’assurent que, lorsque nous mettons à jour notre fonction factorielle(de :), nous ne cassons pas son code existant.

Ceci est crucial dans les apps complexes, où vous devez ajouter, mettre à jour et supprimer du code continuellement. Les tests unitaires vous donnent la confiance que vos changements n’ont pas cassé le code qui fonctionnait bien.

Écrire des tests unitaires dans Xcode

Avec une compréhension des tests unitaires, nous pouvons maintenant examiner les fonctionnalités de test de Xcode.

Lorsque vous créez un nouveau projet Xcode, vous avez la possibilité d’ajouter des tests unitaires directement. Je vais utiliser, à titre d’exemple, une application pour suivre les calories. Vous pouvez trouver le projet Xcode complet ici.

Lorsque vous cochez cette option, votre projet modèle comprendra une cible de test déjà configurée. Vous pouvez toujours en ajouter une plus tard, mais je coche généralement l’option par défaut. Même si je ne vais pas écrire des tests tout de suite, tout sera configuré quand je déciderai de le faire.

La cible de test commence déjà avec du code de modèle pour nos tests.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

import XCTest
@testable import Calories
class CaloriesTests : XCTestCase {
override func setUpWithError() throws {
// Mettez le code de configuration ici. Cette méthode est appelée avant l’invocation de chaque méthode de test dans la classe.
}
override func tearDownWithError() throws {
// Mettez le code de démontage ici. Cette méthode est appelée après l’invocation de chaque méthode de test dans la classe.
}
func testExample() throws {
// Ceci est un exemple de scénario de test fonctionnel.
// Utilisez XCTAssert et les fonctions connexes pour vérifier que vos tests produisent les bons résultats.
}
func testPerformanceExample() throws {
// Ceci est un exemple de scénario de test de performance.
self.measure {
// Mettez ici le code dont vous voulez mesurer le temps.
}
}
}

  • La ligne @testable import Calories vous donne accès à tous les types de la cible principale de votre projet. Le code d’un module Swift n’est pas accessible de l’extérieur, sauf si tous les types et méthodes sont explicitement déclarés publics. Le mot-clé @testable vous permet de contourner cette limitation et d’accéder même au code privé.
  • La classe CaloriesTests représente un scénario de test, c’est-à-dire un regroupement de tests connexes. Une classe de scénario de test doit descendre de la XCTestCase.
  • La méthode setUpWithError() permet de mettre en place un environnement standard pour tous les tests d’un scénario de test. Si vous devez faire un peu de nettoyage après vos tests, vous utilisez tearDownWithError(). Ces méthodes s’exécutent avant et après chaque test dans un scénario de test. Nous n’en aurons pas besoin, vous pouvez donc les supprimer.
  • La méthode testExample() est un test comme ceux que nous avons écrits pour notre exemple factoriel. Vous pouvez nommer les méthodes de test comme bon vous semble, mais elles doivent commencer par le préfixe test pour que Xcode les reconnaisse.
  • Et enfin, la méthode testPerformanceExample() vous montre comment mesurer les performances de votre code. Les tests de performance sont moins standards que les tests unitaires, et vous ne les utilisez que pour le code crucial qui doit être rapide. Nous n’utiliserons pas non plus cette méthode.

Vous pouvez trouver tous vos cas de test et tests relatifs listés dans le navigateur Test de Xcode.

Vous pouvez ajouter de nouveaux cas de test à votre projet en créant simplement de nouveaux fichiers .swift avec du code comme celui ci-dessus, mais il est plus facile d’utiliser le menu d’ajout en bas du navigateur de test.

Une bonne architecture rend votre code plus facile à tester

Dans un projet réel, vous n’avez généralement pas de fonctions libres comme dans notre exemple factoriel. Au lieu de cela, vous avez des structures, des énumérations et des classes représentant les différentes parties de votre app.

Il est crucial d’organiser votre code selon un design pattern comme MVC ou MVVM. Avoir un code bien architecturé ne rend pas seulement votre code bien organisé et plus facile à maintenir. Il facilite également les tests unitaires.

Cela nous aide également à répondre à une question courante : quel code devez-vous tester en premier ?

La réponse est : commencez par vos types de modèles.

Il y a plusieurs raisons à cela :

  • Si vous suivez le pattern MVC ou l’un de ses dérivés, vos types de modèles contiendront la logique métier du domaine. Toute votre application dépend de l’exactitude de ce code, donc même quelques tests ont un impact significatif.
  • Les types de modèles sont souvent des structures, qui sont des types de valeurs. Ceux-ci sont beaucoup plus faciles à tester que les types de référence (nous verrons pourquoi dans un moment).
  • Les types de référence, c’est-à-dire les classes, ont des dépendances et provoquent des effets secondaires. Pour écrire des tests unitaires pour ceux-ci, vous avez besoin de doubles de test (dummies, stubs, fakes, spies et mocks) et utilisez des techniques sophistiquées.
  • Les contrôleurs (en termes MVC) ou les modèles de vue (en termes MVVM) sont souvent connectés à des ressources telles que le stockage sur disque, une base de données, le réseau et les capteurs de périphériques. Ils pourraient également exécuter du code parallèle. Ceux-ci rendent les tests unitaires plus difficiles.
  • Le code de vue est celui qui change le plus fréquemment, produisant des tests fragiles qui se cassent facilement. Et personne n’aime réparer des tests cassés.

Traiter les types de valeurs comme des fonctions pures pour simplifier les tests unitaires

Alors, ajoutons des types de modèles à notre application.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

struct FoodItem {
let name : String
let caloriesFor100Grams : Int
let grammes : Int
}
struct Meal {
private(set) var items : =
var calories : Int {
var calories = 0
for item in items {
calories += (item.caloriesFor100Grams / 100) * item.grammes
}
return calories
}
mutating func add(_ item : FoodItem) {
items.append(item)
}
}

La structure Meal contient une logique simple pour ajouter des éléments alimentaires et calculer le total des calories des aliments qu’elle contient.

Le test d’unité porte ce nom car vous devez tester votre code dans des unités séparées, en supprimant toutes les dépendances (tester les dépendances ensemble est plutôt appelé test d’intégration). Chaque unité est alors traitée comme une boîte noire. Vous lui fournissez des entrées et vous testez sa sortie pour vérifier si elle correspond à ce que vous attendez.

C’était facile dans le cas de notre exemple de factorielle car il s’agissait d’une fonction pure, c’est-à-dire une fonction dont la sortie ne dépend que de l’entrée, et qui ne crée aucun effet secondaire.

Mais cela ne semble pas être le cas pour notre type Meal, qui contient un état.

La valeur renvoyée par la propriété calculée calories n’a pas d’entrée. Elle dépend uniquement du contenu du tableau items. La méthode add(_ :), au contraire, ne renvoie aucune valeur et ne fait que modifier l’état interne d’un repas.

Mais les structures sont des types de valeurs, et nous pouvons les considérer comme des fonctions pures. Comme elles n’ont pas de dépendances, on peut considérer l’état initial d’une structure comme l’entrée d’une méthode et son état après l’appel de la méthode comme la sortie.

(C’est une des raisons pour lesquelles il faut s’abstenir de mettre des types de référence à l’intérieur des types de valeur).

Vérifier votre code en utilisant les fonctions assertions du framework XCTest

Nous avons maintenant du code à tester, et un endroit dans notre projet Xcode où écrire les tests.

Il nous manque un dernier ingrédient : un moyen d’exprimer si notre code est correct ou non.

Dans l’exemple au début de cet article, j’ai utilisé de simples instructions print. Ce n’est pas pratique pour de vrais tests unitaires. Vous ne voulez pas perdre du temps à passer au crible les journaux pour identifier les tests qui ont échoué. Nous avons besoin d’un moyen qui pointe directement vers les tests qui échouent.

Dans le framework XCTest, vous trouvez une série de fonctions d’assertion qui font que Xcode pointe vers les tests qui ne passent pas.

Avec ceux-ci, nous pouvons écrire notre premier test.

Comme je l’ai mentionné plus haut, les tests unitaires sont utiles pour vérifier les cas limites, alors commençons par nous assurer qu’un repas vide n’a pas de calories.

1
2
3
4
5
6

classe CaloriesTests : XCTestCase {
func testEmptyMeal() throws {
let meal = Meal()
XCTAssertEqual(meal.calories, 0, « Un repas vide devrait avoir 0 calories »)
}
}

Le XCTest offre une fonction générique XCTAssert qui vous permet d’affirmer n’importe quelle condition. Vous pourriez l’utiliser pour n’importe quel test que vous écrivez, mais il est préférable d’utiliser des fonctions d’assertion plus spécialisées lorsque cela est possible, comme XCTAssertEqual (ci-dessus), XCTAssertNil, et d’autres. Celles-ci produisent de meilleures erreurs dans Xcode que XCTAssert.

Vous pouvez exécuter un seul test en cliquant sur le diamant à côté de son nom dans la gouttière de l’éditeur de code.

Lorsqu’un test réussit, il devient vert, tandis que les tests qui échouent sont marqués en rouge. Vous pouvez changer le 0 du test par n’importe quel autre nombre pour voir à quoi ressemble un test qui échoue.

Vous pouvez exécuter tous les tests d’un scénario de test en cliquant sur le diamant à côté de la déclaration de la classe. Vous pouvez également utiliser le navigateur Test que nous avons vu plus haut pour exécuter des tests ou des cas de test individuels.

Vous voulez souvent exécuter tous les tests de votre projet en une seule fois, ce que vous pouvez faire rapidement en frappant cmd+U sur votre clavier ou en sélectionnant l’option Test dans le menu Produit de Xcode.

Mesurer la couverture de code d’un test dans Xcode

La question suivante, la plus courante, concernant les tests unitaires est la suivante : quelle part de votre code devez-vous tester ?

Cela se mesure généralement par la couverture de code, c’est-à-dire , le pourcentage de code couvert par vos tests. Donc, avant de répondre à cette question, voyons comment vous pouvez mesurer la couverture de code de vos tests dans Xcode.

Tout d’abord, vous devez laisser Xcode rassembler la couverture pour votre cible de test. Cliquez sur le bouton avec le nom du projet dans le contrôle segmenté de la barre d’outils Xcode (à côté du bouton d’arrêt). Ensuite, sélectionnez Edit scheme… dans le menu contextuel.

Là, sélectionnez Test dans le contour, puis allez dans l’onglet Options. Et, enfin, sélectionnez Gather coverage for. Vous pouvez laisser son option à toutes les cibles.

Vous pouvez maintenant réexécuter vos tests, ce qui permettra à Xcode de rassembler les données de couverture. Vous trouverez le résultat dans le navigateur Rapport, en sélectionnant l’élément Couverture du code dans le contour.

Ici, vous pouvez vérifier les pourcentages de code couvert par vos tests pour l’ensemble du projet et chaque fichier.

Ces chiffres ne sont pas si significatifs pour notre exemple, puisque nous n’avons pratiquement pas écrit de code. Pourtant, nous pouvons voir que la méthode add(_ 🙂 du type Meal a une couverture de 0%, ce qui signifie que nous ne l’avons pas encore testée.

La propriété calories computed a, au contraire, une couverture de 85,7%, ce qui signifie qu’il y a des chemins d’exécution dans notre code que notre test n’a pas déclenché.

Dans notre exemple simple, il est facile de comprendre de quel chemin il s’agit. Nous avons seulement testé les calories d’un repas vide, donc le code dans la boucle for n’a pas été exécuté.

Dans des méthodes plus sophistiquées, cependant, cela pourrait ne pas être aussi simple.

Pour cela, vous pouvez faire ressortir la bande de couverture de code dans l’éditeur Xcode. Vous pouvez trouver cela dans le menu Ajuster les options de l’éditeur dans le coin supérieur droit.

Cela fera apparaître une bande qui vous montre la couverture de chaque ligne de code.

Les chiffres vous indiquent combien de fois chaque ligne a été exécutée pendant les tests. En rouge, vous pouvez voir quelles lignes n’ont pas été exécutées du tout (complètes) ou n’ont été exécutées que partiellement (rayées).

Pourquoi viser une couverture de code spécifique n’a aucun sens et ce que vous devriez faire à la place

Alors, quel est le pourcentage d’une bonne couverture de code ?

Les opinions sont largement différentes. Certains développeurs pensent que 20% est suffisant. D’autres vont pour des chiffres plus élevés, comme 70% ou 80%. Il y a même des développeurs qui pensent que seul 100% est acceptable.

À mon avis, la couverture du code est une métrique sans signification. Vous pouvez l’utiliser pour informer vos décisions sur les tests, mais vous ne devriez pas le traiter comme un objectif que vous devez atteindre.

Pour voir pourquoi, écrivons un autre test pour couvrir le code que nous n’avons pas encore testé.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

classe CaloriesTests : XCTestCase {
let banana = FoodItem(name : « Banana », caloriesFor100Grams : 89, grammes : 118)
let steak = FoodItem(name : « Steak », caloriesFor100Grams : 271, grammes : 225)
let goatCheese = FoodItem(name : « Goat Cheese », caloriesFor100Grams : 364, grammes : 28)
func testEmptyMeal() throws {
let meal = Meal()
XCTAssertEqual(meal.calories, 0, « Un repas vide devrait avoir 0 calories »)
}
func testCalories() {
var meal = Meal()
meal.add(banana)
meal.add(steak)
meal.add(goatCheese)
XCTAssertEqual(meal.items.count, 3)
XCTAssertEqual(meal.calories, 534)
}
}

Si vous réexécutez tous les tests, vous obtiendrez une couverture de 100% pour le fichier Model.swift. Cela semble bon, non ?

Maintenant, allez-y et supprimez la méthode testEmptyMeal(). Si vous exécutez le test restant seul, vous verrez que la couverture pour nos types de repas est toujours de 100%.

Cela vous montre déjà que le nombre de 100% peut vous donner un faux sentiment de sécurité. Vous savez que maintenant nous ne testons pas tous les cas limites pour la propriété calculée des calories. Et pourtant, notre couverture de test ne reflète pas cela.

J’ajouterais que la couverture à 100% n’est pas seulement trompeuse mais même préjudiciable. Certains codes de votre projet sont sujets à des changements constants, en particulier le code le plus récent et surtout dans les vues.

Couvrir 100% de celui-ci signifie que vous n’aurez qu’à produire des tests fragiles qui cassent continuellement, et que vous devez corriger. Ces tests ne détectent pas les bugs. Ils cassent parce que la fonctionnalité de votre code change.

Et ici nous arrivons au point dont personne ne parle.

Écrire des tests n’est pas amusant, malgré ce que certains développeurs prétendent. Corriger les tests est encore moins amusant. Faites-le trop souvent, et vous détesterez les tests unitaires et les mettrez complètement de côté.

La psychologie est aussi essentielle pour le développement de logiciels que les meilleures pratiques. Nous ne sommes pas des machines.

Alors, quel nombre devez-vous viser ? Cela nécessite une discussion plus étendue, mais ici je vais vous donner un résumé de mon opinion.

Écrivez des tests utiles, en commençant par le code le plus ancien de votre projet qui ne va pas changer. Utilisez la couverture du code uniquement pour identifier quel code n’est pas encore testé, mais ne l’utilisez pas comme un objectif ou pour décider quand écrire plus de tests. Une couverture de 20 % est meilleure que 80 % si vos tests sont significatifs et que vous testez le bon code.

Comme je l’ai mentionné ci-dessus, les types de modèles contiennent le code le plus critique de votre projet, qui a également tendance à changer le moins. Donc, commencez par là.

Je ne me donne généralement pas la peine de tester chaque chemin d’exécution. Par exemple, dans un projet réel, je n’écrirais pas la méthode testEmptyMeal() que je vous ai montrée ci-dessus. Oui, il est crucial de tester les cas limites, mais ils ne sont ni tous importants.

Je n’écrirais généralement que le test testCalories(). Cela me dit que mon code fonctionne, et cela me préviendra si, plus tard, je fais une erreur en modifiant cette méthode.

Sûr, cela ne couvre pas tous les chemins, mais ce code est si simple que c’est juste une perte de temps. Passer votre temps à écrire du vrai code pour des fonctionnalités qui aident vos utilisateurs est plus important que de tester chaque chemin d’exécution.

Je sais que je vais me faire tancer par certains développeurs pour cette opinion, mais je m’en fiche. La couverture de test est l’un de ces débats qui ne se terminera jamais.

Le code que vous écrivez est généralement le plus souvent juste. Il n’y a pas besoin d’être paranoïaque. Avoir une pléthore de tests qui cassent chaque fois que vous changez quelque chose est un passif, pas un atout.

Votre temps et votre volonté sont limités. Consacrez-les à des tâches plus importantes.

Architecture des applications SwiftUI avec MVC et MVVM

Il est facile de créer une application en jetant du code ensemble. Mais sans les meilleures pratiques et une architecture robuste, vous vous retrouvez rapidement avec un code spaghetti ingérable. Dans ce guide, je vous montrerai comment structurer correctement les applications SwiftUI.

GET THE FREE BOOK NOW

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée.