Swift Testing : Le framework qui remplace XCTest, et ce qui reste dans XCTest
XCTest, le framework de test d’Apple depuis 2013, est en train d’être remplacé par Swift Testing. Apple ne dit pas encore « obsolète » (XCTest est toujours livré, fonctionne toujours, héberge toujours l’automatisation UI et les tests de performance), mais chaque session Apple, chaque fil sur les Swift Forums, et chaque nouveau projet d’exemple depuis la WWDC 2024 dirige le code Swift vers Swift Testing. Le framework est livré avec Xcode 16+ et Swift 61, s’exécute sur macOS, iOS, watchOS, tvOS, visionOS et Linux2, et présente un modèle mental différent : les tests sont des fonctions, les suites sont des types, la configuration repose sur des traits, et le parallélisme est le comportement par défaut.
La migration est progressive. La documentation officielle d’Apple prend explicitement en charge l’exécution des deux frameworks côte à côte dans la même cible3, ce qui signifie que la bonne approche pour une base de code non triviale n’est pas « tout réécrire » mais « écrivez les nouveaux tests en Swift Testing et migrez les anciens au fil des modifications ». Cet article parcourt la surface API, identifie la frontière de migration (ce qui reste dans XCTest), et couvre le vocabulaire des traits qui remplace la configuration par hiérarchie de classes de XCTest.
TL;DR
- Swift Testing remplace le modèle de XCTest, basé sur les classes et les chaînes typées, par des macros :
@Test,@Suite,#expect(...)et#require(...)4. - Les tests sont des fonctions libres ou des méthodes sur des types ; les suites sont n’importe quel type contenant des fonctions de test (
@Suiten’est requis que pour spécifier un nom d’affichage ou un trait). - La configuration passe des hiérarchies de classes et des conventions de nommage aux traits :
.enabled(if:),.disabled(_:),.serialized,.timeLimit(...),.tags(...),.bug(...)5. - Les tests paramétrés via
@Test(arguments:)produisent un résultat de test parent avec un enfant par argument ; parallèles par défaut6. - La migration se fait côte à côte, pas en mode big-bang. L’automatisation UI (
XCUIApplication) et les tests de performance (XCTMetric) restent dans XCTest car Swift Testing ne les couvre pas3. - La WWDC 2025 a ajouté les pièces jointes personnalisées (artefacts de test) et les exit tests (vérifier du code censé se terminer dans des conditions spécifiques)7.
Pourquoi Swift Testing existe
Les points douloureux de XCTest sont bien connus de quiconque a maintenu une grande suite de tests iOS :
Assertions à typage par chaînes. XCTAssertEqual(a, b, "message") ne capture pas l’expression Swift d’origine. Quand l’assertion échoue, le message d’échec vous donne les valeurs d’exécution mais pas l’expression qui les a produites. Le débogage exige de relire la source du test et de reconstruire le contexte.
Configuration basée sur les classes. Le setup, le teardown et l’état partagé exigent de sous-classer XCTestCase. Désactiver un test impose soit de le renommer (afin que le pattern de découverte le manque), soit de l’envelopper dans #if DEBUG. Sélectionner les tests à exécuter exige un nommage soigneux.
Décalage avec la concurrence. XCTest est antérieur à Swift Concurrency. XCTestExpectation plus wait(for:timeout:) est le pont historique vers le code asynchrone ; le pattern est verbeux et sujet aux erreurs. Le code Swift moderne utilise async/await ; les idiomes de XCTest paraissent anachroniques.
Paramétrage faible. XCTest n’a pas de support natif pour les tests paramétrés. Les développeurs le simulent avec des boucles for à l’intérieur des méthodes de test (ce qui produit un seul résultat de test pour de nombreux cas) ou écrivent des générateurs de code (ce qui produit N méthodes de test à partir d’un modèle).
Swift Testing répond à chaque point :
import Testing
@Test func userInitializesWithDefaults() {
let user = User()
#expect(user.name == "Anonymous")
#expect(user.preferences.isEmpty)
}
#expect capture l’expression complète. Quand l’assertion échoue, la sortie du test indique que user.name == "Anonymous" a échoué parce que user.name valait "Guest". Le framework fait le travail de lecture de l’expression parce que la macro voit l’arbre syntaxique. Pas de devinettes du type « attendu X, obtenu Y ».
Les macros
Quatre macros font le travail.
@Test et @Suite
@Test marque une fonction comme un test. La fonction peut résider à la portée du fichier, dans une struct, dans un actor, dans un enum, partout où une fonction peut résider en Swift. Le framework découvre les fonctions marquées @Test et les exécute.
@Test func loginSucceedsWithValidCredentials() async throws {
let result = try await api.login(username: "alice", password: "valid")
#expect(result.isAuthenticated)
}
struct AuthenticationTests {
@Test func loginRejectsEmptyPassword() async throws {
let result = try await api.login(username: "alice", password: "")
#expect(!result.isAuthenticated)
#expect(result.error == .emptyPassword)
}
}
@Suite marque explicitement un type comme suite de tests. Le marqueur est implicite quand un type contient des fonctions @Test ; un @Suite explicite n’est requis que pour ajouter un nom d’affichage personnalisé ou appliquer un trait au niveau de la suite4.
@Suite("Authentication", .tags(.security))
struct AuthenticationTests {
@Test func loginSucceeds() async throws { ... }
@Test func loginFails() async throws { ... }
}
#expect et #require
#expect est l’assertion standard. Le test continue si l’attente échoue (l’échec est enregistré ; les assertions suivantes s’exécutent quand même). #require est la variante « fail-fast » : si l’exigence échoue, le test s’arrête immédiatement. Utilisez #require pour les préconditions dont l’échec produirait des erreurs absurdes dans les assertions suivantes.
@Test func userPreferencesLoad() async throws {
let user = try #require(await store.fetchUser(id: 42))
// Si fetchUser renvoie nil, le test s'arrête ici. La ligne suivante
// ne s'exécute jamais sur un optionnel non déballé.
#expect(user.preferences["theme"] == "dark")
#expect(user.preferences.count >= 1)
}
Le try devant #require a son importance : #require renvoie la valeur déballée quand l’exigence est satisfaite (ici le User non optionnel) ou lance une erreur dans le cas contraire. La fonction de test doit être throws pour utiliser #require à des fins de déballage.
Tests paramétrés
@Test(arguments:) exécute la fonction de test une fois par argument. Le framework signale chaque argument comme un test enfant, avec le test parent en synthèse.
@Test(arguments: [
("alice", "password123", true),
("bob", "", false),
("", "password123", false),
("carol", "wrongpassword", false),
])
func loginScenario(username: String, password: String, expectedSuccess: Bool) async throws {
let result = try await api.login(username: username, password: password)
#expect(result.isAuthenticated == expectedSuccess)
}
Le framework exécute les quatre arguments en parallèle par défaut. Les messages d’échec de test identifient quel tuple d’arguments a échoué, donc déboguer un échec paramétré n’exige pas d’exécuter le test isolément.
Pour des arguments qui sont des collections indépendantes, @Test(arguments: arrayA, arrayB) exécute le produit cartésien (toutes les combinaisons). Pour des paires séquentielles, utilisez zip(arrayA, arrayB) et passez la séquence résultante.
Traits : la configuration comme valeur de première classe
XCTest utilise des hiérarchies de classes, des conventions de nommage et les test plans Xcode pour configurer les tests. Swift Testing utilise des traits : des valeurs appliquées aux déclarations @Test et @Suite5 :
@Test(.enabled(if: ProcessInfo.processInfo.isRunningOnCI))
func ciOnlyIntegrationTest() async throws { ... }
@Test(.disabled("Pending fix for FB12345"))
func knownFailingTest() { ... }
@Test(.timeLimit(.minutes(1)))
func potentiallySlowTest() async throws { ... }
@Test(.tags(.network, .integration), .bug("FB67890", "regression in retry logic"))
func networkRetryTest() async throws { ... }
@Suite(.serialized)
struct DatabaseTests {
// Tous les tests de cette suite s'exécutent séquentiellement.
// Utile quand des tests partagent une fixture de base de données.
@Test func insertUser() { ... }
@Test func updateUser() { ... }
@Test func deleteUser() { ... }
}
Le vocabulaire des traits couvre les cas pour lesquels XCTest exigeait une infrastructure personnalisée :
.enabled(if:)et.disabled(_:). Activation conditionnelle avec une expression d’exécution et une raison facultative. Remplace les blocs#if DEBUGet les astuces de renommage..serialized. Marque une suite ou un test paramétré comme séquentiel. Hérité : une suite sérialisée force tous les tests enfants à être sériels6..timeLimit(...). Limite de temps par test. Le test est interrompu et signalé comme échoué s’il dépasse la limite..tags(...). Tags pour le filtrage. Configurez les règles d’inclusion/exclusion de tags dans un test plan Xcode ; le plan s’exécute viaxcodebuild test -testPlan <name>. Notez que-only-testing:filtre par identifiant de test (cible, suite ou fonction), pas par tag..bug(...). Lie un test à un identifiant de bug avec une raison. Le lien apparaît dans le rapport de test et (pour les tests connus comme défaillants) empêche l’échec de bloquer la CI.
Les traits personnalisés sont également pris en charge. Une application peut définir ses propres traits pour des besoins spécifiques au projet (par exemple, .requiresKeychain, .skipIfOffline).
Le parallélisme est le comportement par défaut
Swift Testing exécute les tests en parallèle par défaut, y compris les arguments paramétrés. Le défaut a son importance car la plupart des suites de tests grossissent avec le temps, et le parallélisme fait la différence entre une exécution de tests de 30 secondes et une de 5 minutes. XCTest exigeait une activation explicite (bascule du parallélisme dans le test plan) ; Swift Testing inverse le défaut.
Conséquence : les tests doivent être indépendants. L’état partagé (une fixture de base de données, un système de fichiers mocké, un singleton au niveau de l’application) doit être soit isolé par test (chaque test obtient une fixture fraîche), soit marqué .serialized (la suite s’exécute séquentiellement). Les tests qui mutent un état global et supposent une exécution sérielle sont des bugs que Swift Testing fait remonter plus vite que XCTest.
Migration : côte à côte, pas big-bang
La position officielle d’Apple sur la migration est progressive3 :
- Les deux frameworks coexistent dans la même cible de tests.
- De nouveaux tests peuvent être écrits en Swift Testing immédiatement.
- Les anciens tests XCTest continuent de fonctionner sans changement.
- Migrez les tests XCTest vers Swift Testing quand c’est opportun (un fichier modifié, une nouvelle fonctionnalité, un refactor).
Deux cas restent indéfiniment dans XCTest :
Tests d’automatisation UI. XCUIApplication, XCUIElement, XCUIElementQuery, toute la surface des tests UI, sont exclusifs à XCTest3. Swift Testing ne fournit pas de remplacement. Les tests UI restent dans XCTest jusqu’à ce qu’Apple étende la couverture de Swift Testing.
Tests de performance. XCTMetric, measure(metrics:), les baselines de performance, également exclusifs à XCTest3. Swift Testing n’a pas encore d’équivalent.
Pour tout le reste (tests unitaires, tests d’intégration, tests asynchrones, tests paramétrés), Swift Testing est le défaut recommandé pour le nouveau code.
Ce que la WWDC 2025 a ajouté
Deux ajouts notables à Swift Testing à la WWDC 20257 :
Pièces jointes personnalisées. Un test peut attacher des données Attachable arbitraires à son résultat pour le triage des échecs. Captures d’écran d’échec, journaux de diagnostic, fichiers d’entrée générés. Les pièces jointes apparaissent dans le rapporteur de tests Xcode et dans les artefacts de CI. La surface API Attachment.record(_:named:sourceLocation:) dans Sources/Testing/Attachments/Attachment.swift définit le périmètre ; les valeurs conformes à Attachable (Data, String, types d’image via conformance opt-in) sont acceptées.
@Test func reportLayoutAtFailure() async throws {
let result = await renderer.run(LayoutTestCase.complex)
if !result.passes {
Attachment.record(result.diagnostics, named: "diagnostics.txt")
Attachment.record(result.screenshotData, named: "failure-screenshot.png")
}
#expect(result.passes)
}
Exit tests. Tests qui vérifient du code censé se terminer (appel à exit(), lancement d’une erreur fatale, déclenchement d’un échec de précondition). XCTest n’avait pas de moyen propre de tester cela ; les exit tests de Swift Testing exécutent le code dans un processus enfant et vérifient le comportement de terminaison.
@Test func parserExitsOnMalformedInput() async {
await #expect(processExitsWith: .failure) {
Parser.parse(malformedInput)
}
}
Les deux ajouts sont pragmatiques. Les pièces jointes personnalisées résolvent un vrai point douloureux (déboguer les échecs CI flaky) ; les exit tests couvrent un véritable manque dans la surface des tests unitaires.
Quand XCTest reste le bon choix
Trois cas où le nouveau code devrait toujours utiliser XCTest :
Le test est un test d’automatisation UI. XCUIApplication.launch, les interactions tap-and-swipe, les requêtes basées sur les identifiants d’accessibilité. Swift Testing ne couvre pas la surface d’automatisation UI ; les nouveaux tests UI sont en XCTest.
Le test est un benchmark de performance. measure(metrics: [XCTClockMetric()]), les baselines de performance, les mesures de mémoire. Swift Testing n’a pas d’équivalents ; les nouveaux tests de performance sont en XCTest.
Le test doit s’exécuter sur une plateforme qui ne prend pas en charge Swift Testing. Swift Testing exige Swift 6 / Xcode 16+. Les tests qui doivent compiler sur des toolchains antérieures (une matrice CI qui inclut des versions Xcode plus anciennes) ont besoin de XCTest jusqu’à ce que la toolchain rattrape son retard.
Pour les tests unitaires, les tests d’intégration, les tests asynchrones et les tests paramétrés sur des cibles Swift 6+, Swift Testing est le défaut moderne. Le coût de la migration est réel mais le coût de la non-migration s’accumule ; chaque nouveau XCTest écrit aujourd’hui est de la dette technique.
Le lien avec les workflows d’agent
Les tests sont le contrat sur lequel s’appuie la génération de code assistée par IA. Quand un agent écrit du code Swift, les tests sont la manière dont le développeur (ou l’agent lui-même) vérifie que la modification est correcte. Les propriétés de Swift Testing rendent le code généré par un agent plus vérifiable :
- Capture d’expression. L’échec de
#expect(user.score == calculator.compute(user))montre les deux côtés de la comparaison. Un agent qui lit l’échec peut corriger le bug sans relire le fichier de test. - Cas paramétrés. Un agent qui corrige une régression sait quelle entrée spécifique a échoué. La correction peut être ciblée.
- Filtrage par traits. Une annotation
.tags(.regression)permet à un agent de n’exécuter que les tests de régression après une correction ; boucle de feedback plus rapide. - Pièces jointes personnalisées. Un agent qui débogue un test flaky peut attacher l’artefact d’entrée défaillant et le lire à l’exécution suivante.
L’article sur les hooks pour le développement Apple couvre les hooks Stop qui exécutent les tests après chaque édition Claude Code. Les échecs plus rapides, plus parallèles et plus informatifs de Swift Testing rendent cette boucle suffisamment serrée pour que l’agent puisse itérer sans intervention manuelle.
Ce que ce pattern signifie pour les apps iOS 26+
Trois conclusions.
-
Choisissez Swift Testing par défaut pour les nouveaux tests. Le framework est plus expressif, s’exécute en parallèle par défaut, capture automatiquement le contexte d’échec, et prend en charge les tests paramétrés comme un concept de première classe. Le code XCTest existant continue de fonctionner ; le nouveau code utilise le défaut moderne.
-
Gardez l’automatisation UI et les tests de performance dans XCTest. Lacune de couverture chez Apple, pas un choix de migration. Tant que Swift Testing ne s’étend pas à ces domaines, ces tests restent indéfiniment dans XCTest.
-
Utilisez les traits comme vocabulaire de configuration.
.enabled(if:),.disabled(_:),.serialized,.timeLimit(...),.tags(...),.bug(...)couvrent les cas pour lesquels XCTest exigeait des hiérarchies de classes, des conventions de nommage et des blocs#if DEBUG. Les traits sont des valeurs de première classe qui se composent ; le vocabulaire des traits est ce qui rend le framework moderne.
Le cluster Apple Ecosystem complet : les App Intents typés ; les serveurs MCP ; la question du routage ; les Foundation Models ; la distinction LLM runtime vs outillage ; les trois surfaces ; le pattern source unique de vérité ; les Two MCP Servers ; les hooks pour le développement Apple ; les Live Activities ; le runtime watchOS ; les internals de SwiftUI ; le modèle mental spatial de RealityKit ; la discipline de schéma SwiftData ; les patterns Liquid Glass ; la livraison multi-plateforme ; la matrice des plateformes ; le framework Vision ; les Symbol Effects ; l’inférence Core ML ; l’API Writing Tools ; ce que je refuse d’écrire. Le hub se trouve à la série Apple Ecosystem. Pour un contexte plus large iOS-avec-agents-IA, consultez le guide de développement d’agents iOS.
FAQ
Swift Testing remplace-t-il entièrement XCTest ?
Pas encore. XCTest héberge encore les tests d’automatisation UI (XCUIApplication) et les tests de performance (XCTMetric) ; Swift Testing ne couvre pas ces domaines. Pour les tests unitaires, les tests d’intégration, les tests asynchrones et les tests paramétrés, Swift Testing est le défaut recommandé pour le nouveau code. Les deux frameworks coexistent dans la même cible sans conflit.
Quelle est la différence pratique entre #expect et XCTAssertEqual ?
#expect capture l’expression Swift complète et signale les deux côtés de la comparaison en cas d’échec (par exemple, user.score == calculator.compute(user) a échoué parce que user.score = 80 et calculator.compute(user) = 100). XCTAssertEqual ne connaît que les valeurs d’exécution et la chaîne de message facultative. La capture d’expression est ce qui rend les échecs Swift Testing auto-explicatifs sans qu’il faille relire la source du test.
Comment fonctionnent les tests paramétrés ?
@Test(arguments: [...]) exécute la fonction de test une fois par argument. Le framework signale chaque argument comme un résultat de test enfant, avec le parent en synthèse. Les tuples peuvent passer plusieurs valeurs. Les tests paramétrés s’exécutent en parallèle par défaut ; ajoutez .serialized pour forcer une exécution séquentielle.
À quoi sert #require ?
#require est la variante fail-fast de #expect. Utilisez-le pour les préconditions dont l’échec produirait des erreurs absurdes dans les assertions suivantes. try #require(value) renvoie la valeur déballée quand l’exigence est satisfaite ou lance une erreur dans le cas contraire, de sorte que le reste du test peut utiliser la valeur déballée directement. La fonction de test doit être throws pour utiliser #require à des fins de déballage.
Puis-je écrire des tests Swift Testing sous Linux ?
Oui. Swift Testing est open-source2 et s’exécute sur toutes les plateformes prises en charge par Swift, y compris Linux. Les projets Swift côté serveur peuvent adopter Swift Testing immédiatement. Le framework n’est pas spécifique aux plateformes Apple.
Comment migrer une suite XCTest existante ?
La recommandation d’Apple est progressive : écrivez les nouveaux tests en Swift Testing, migrez les anciens quand vous y touchez3. Il n’y a pas de convertisseur automatisé (les différences de modèle sont assez grandes pour qu’une automatisation soit fragile). La bonne cible de migration, ce sont les tests unitaires et les tests d’intégration ; les tests UI et les tests de performance restent indéfiniment dans XCTest.
Références
-
Apple Developer : Swift Testing. Vue d’ensemble par Apple de l’adoption de Swift Testing avec Xcode 16+ et Swift 6. ↩
-
Dépôt open-source : swiftlang/swift-testing sur GitHub. La source du framework, disponible sous la licence Apache License 2.0 avec la licence standard du Swift Project. ↩↩
-
Documentation Apple Developer : Migrating a test from XCTest. Le guide de migration officiel d’Apple couvrant la coexistence côte à côte et les exceptions XCUIApplication / XCTMetric. ↩↩↩↩↩↩
-
Documentation Apple Developer : Swift Testing. Référence du framework pour
@Test,@Suite,#expect,#requireet le vocabulaire des traits. ↩↩ -
Documentation Apple Developer : Trait. Le protocole qui définit les traits de test, avec les traits intégrés dont
.enabled(if:),.disabled(_:),.serialized,.timeLimit(...),.tags(...),.bug(...). ↩↩ -
Apple Developer : Meet Swift Testing (session 10179 de la WWDC 2024) et Go further with Swift Testing (session 10195 de la WWDC 2024). Les sessions d’introduction couvrant les tests paramétrés et le parallélisme. ↩↩
-
Preuves issues du dépôt open-source pour les ajouts de la WWDC 2025 :
Sources/Testing/Attachments/Attachment.swiftpour les pièces jointes personnalisées etSources/Testing/ExitTests/pour la macro d’exit test#expect(processExitsWith:). Tous deux sont apparus pour la première fois à la WWDC 2025 et sont livrés avec Xcode 26+. ↩↩