Fiche 14.05 — Swift Testing : le framework de test moderne
Objectif
Maîtriser Swift Testing, le framework de test introduit avec Xcode 16 qui remplace progressivement XCTest. Tu vas voir @Test, #expect, #require, les @Suite, les tests paramétrés, les tests async/throwing, et la table d'équivalence depuis XCTest pour migrer sans douleur.
1. Le réflexe XCTest puis l'équivalent moderne
En XCTest tu écrivais une classe héritant de XCTestCase, des méthodes func testX(), et des XCTAssert* :
En Swift Testing, c'est une struct (ou une classe), des fonctions annotées @Test, et une seule macro #expect :
Différences clés :
- pas besoin de préfixer par
test, le nom de fonction est libre ; #expect(condition)au lieu deXCTAssert*— une seule macro pour tout, avec une expression booléenne normale ;- une nouvelle instance de la struct est créée pour chaque test (isolation automatique, pas de state partagé qui fuit entre tests).
2. #expect : l'assertion universelle
#expect prend une expression booléenne. Si elle est fausse, le test échoue mais continue : les assertions suivantes s'exécutent quand même.
La macro capture l'expression : en cas d'échec tu vois les valeurs réelles (age était 17), pas juste « assertion failed ». Plus besoin de XCTAssertEqual, XCTAssertTrue, XCTAssertGreaterThan… tout passe par ==, >, contains, etc.
Tu peux ajouter un commentaire affiché en cas d'échec :
3. #require : échoue ET arrête
#expect continue après un échec. #require arrête le test immédiatement s'il échoue — c'est l'équivalent moderne de XCTUnwrap et des assertions « bloquantes ».
Usage typique : déballer un optionnel avant de continuer. try #require(optionnel) renvoie la valeur dépaquetée, ou stoppe le test si c'est nil.
Réflexe : #require dès qu'une assertion suivante n'a plus de sens si celle-ci échoue (un nil qui ferait planter le reste, un cast obligatoire). #expect pour les vérifications indépendantes.
4. @Suite : grouper les tests
Une struct contenant des @Test est déjà une suite implicite. @Suite rend le groupement explicite, donne un nom lisible et permet d'attacher des traits (tags, .serialized, etc.).
Le setup/teardown se fait naturellement : pas de setUp()/tearDown(). L'init de la struct sert de setup (rejoué à chaque test), et deinit (sur une classe) sert de teardown.
5. Tests async et throwing
Un test asynchrone se déclare simplement async. Pas de XCTestExpectation, pas de wait(for:) : tu fais await directement.
Rappel du pont depuis XCTest : là où tu jonglais avec XCTestExpectation + fulfill() + wait(for: [exp], timeout: 5), ici async + await suffit. Beaucoup moins de code et zéro timeout arbitraire.
Un test throwing se déclare throws : si une erreur non attendue est lancée, le test échoue automatiquement avec le détail de l'erreur.
6. Tester qu'une erreur est bien lancée
En XCTest, vérifier qu'une fonction throw était pénible (XCTAssertThrowsError avec une closure). Ici c'est intégré à #expect.
Tu peux matcher juste le type d'erreur :
Ou vérifier qu'aucune erreur n'est lancée :
Pour inspecter l'erreur capturée, utilise #require(throws:) qui renvoie l'erreur :
7. Tests paramétrés : @Test(arguments:)
C'est l'arme la plus rentable de Swift Testing. Un seul test est exécuté avec plusieurs jeux de données — chaque cas apparaît séparément dans le rapport (et tu peux relancer juste celui qui échoue).
En XCTest tu écrivais soit une boucle for dans un test (échec illisible), soit 5 méthodes copiées-collées. Ici :
Avec des paires d'entrée/sortie via des tuples :
zip pour combiner deux collections en parallèle (pas le produit cartésien) :
⚠️ Avec deux tableaux séparés arguments: [1,2], [10,20], Swift Testing fait le produit cartésien (4 cas). Utilise zip si tu veux des paires alignées.
8. Tags : organiser et filtrer
Les tags permettent de regrouper des tests à travers plusieurs suites (ex. tous les tests « réseau » ou « critiques ») et de les filtrer dans le Test Navigator de Xcode ou via un test plan.
Dans Xcode tu filtres par tag depuis le Test Navigator (ou un test plan qui n'inclut que certains tags). En ligne de commande, SwiftPM n'a pas de flag dédié aux tags : swift test ne propose que --filter <regex> (sur le nom) et --skip <regex>. Pour cibler un tag en CI, le moyen le plus simple reste de nommer les tests de façon cohérente, ou de passer par un test plan.
Autres traits utiles : .disabled("raison") (au lieu de commenter ou de renommer le test), .bug("https://...", "titre") (lien vers un ticket), .timeLimit(.minutes(1)).
9. Tester un ViewModel @Observable
Cas concret pour l'entretien. Un ViewModel moderne en @Observable, isolé sur @MainActor :
Le test annoté @MainActor pour matcher l'acteur du ViewModel. On lit directement les propriétés @Observable (pas de @Published, pas de Combine) :
Avec une charge async (fetch via un service mocké) :
10. Table d'équivalence XCTest → Swift Testing
| XCTest | Swift Testing |
|---|---|
XCTAssertEqual(a, b) | #expect(a == b) |
XCTAssertNotEqual(a, b) | #expect(a != b) |
XCTAssertTrue(x) | #expect(x) |
XCTAssertFalse(x) | #expect(!x) |
XCTAssertNil(x) | #expect(x == nil) |
XCTAssertNotNil(x) | #expect(x != nil) |
XCTAssertGreaterThan(a, b) | #expect(a > b) |
let v = try XCTUnwrap(x) | let v = try #require(x) |
XCTAssertThrowsError(try f()) | #expect(throws: (any Error).self) { try f() } |
XCTAssertNoThrow(try f()) | #expect(throws: Never.self) { try f() } |
XCTFail("msg") | Issue.record("msg") |
class … : XCTestCase | struct … (ou @Suite) |
func testX() | @Test func x() |
setUp() / tearDown() | init() / deinit |
XCTestExpectation + wait(for:) | @Test … async + await |
Cohabitation : les deux frameworks tournent dans la même target de test, dans le même projet. Tu n'es pas obligé de tout migrer d'un coup — garde tes anciens fichiers XCTest et écris les nouveaux tests en Swift Testing. Xcode lance les deux. Limite à connaître : on ne met pas de fonctions @Test à l'intérieur d'une sous-classe XCTestCase (et on n'appelle pas les XCTAssert* depuis un @Test). Les deux styles peuvent vivre dans le même fichier tant qu'ils sont dans des types distincts.
Points à connaître
#expectcontinue,#requirearrête. Mauvais réflexe : enchaîner des#expectqui déréférencent un optionnelnil→ cascade d'échecs. Utilisetry #requirepour stopper net dès la première condition bloquante.- Nouvelle instance par test. Contrairement à une crainte fréquente, le state d'une struct
@Suitene fuit pas entre tests : chaque@Testa son propreinit. N'utilise pas destatic varmutable pour partager du state. - Produit cartésien vs
zip.@Test(arguments: a, b)croise tout (a.count × b.countcas). Pour des paires alignées, passezip(a, b). Erreur classique qui fait exploser le nombre de cas. @MainActorsur les tests de ViewModel. Un ViewModel@Observable @MainActorexige que le test soit aussi sur le main actor, sinon erreur de concurrence (surtout sous Swift 6). Annote la suite@MainActor.
Exercice
Réécris ces deux tests XCTest en Swift Testing, dont un en version paramétrée.
Objectif (10-20 min) : un fichier StringTests.swift avec import Testing, une @Suite, un @Test pour reversed, et un seul @Test(arguments:) paramétré couvrant au moins 3 paires (mot, estPalindrome) pour remplacer testIsPalindrome.
Question d'entretien
« Swift Testing vs XCTest, qu'est-ce qui change concrètement ? »
Swift Testing (Xcode 16, framework
Testing) remplace les classesXCTestCasepar des structs avec des fonctions@Test, et remplace toute la familleXCTAssert*par deux macros :#expect(condition)qui continue après un échec, et#require(condition)qui arrête le test (équivalent deXCTUnwrap+ assertions bloquantes). Les gros gains : une nouvelle instance par test donc isolation automatique, les tests paramétrés via@Test(arguments:)qui remplacent les boucles ou méthodes dupliquées, l'async natif (async/awaitau lieu deXCTestExpectation), la vérification d'erreurs avec#expect(throws:), et les tags pour filtrer transversalement. En cas d'échec, les macros affichent les valeurs réelles de l'expression. Les deux frameworks cohabitent dans la même target, donc la migration est progressive — pas besoin de tout réécrire d'un coup.
« Différence entre #expect et #require ? »
#expectenregistre l'échec mais laisse le test continuer (utile pour vérifier plusieurs propriétés indépendantes en un seul run).#requireestthrows: il interrompt le test si la condition échoue, ettry #require(optionnel)déballe la valeur. On utilise#requirequand la suite du test n'a aucun sens si la condition échoue (unnilqui ferait planter le reste).
Résumé
import Testing,@Test func, et une struct au lieu d'une classeXCTestCase.#expect(expr)= assertion universelle qui continue ;#require(expr)arrête et déballe les optionnels.@Suitepour grouper,init/deinitpour setup/teardown, nouvelle instance par test.@Test(arguments:)= un test, plusieurs jeux de données ;zippour des paires alignées (sinon produit cartésien).- Tests
async(justeasync),throwspour les erreurs attendues,#expect(throws: MyError.self)pour vérifier qu'une erreur est lancée. - Tags (
.tags(...)) et traits (.disabled,.bug) pour organiser et filtrer. - ViewModel
@Observable @MainActor→ suite de test@MainActor, on lit les propriétés directement. - Table d'équivalence :
XCTAssertEqual(a,b)→#expect(a == b),XCTUnwrap→#require, etc. - XCTest et Swift Testing cohabitent dans la même target : migration progressive.