# Swift Testing Framework en entretien 2026 : macros #expect et #require vs XCTest > Maîtrisez le nouveau Swift Testing Framework pour vos entretiens iOS : macros #expect et #require, migration depuis XCTest, patterns avancés et pièges à éviter. - Published: 2026-03-05 - Updated: 2026-03-28 - Author: SharpSkill - Tags: swift, ios, testing, xctest, entretien - Reading time: 14 min --- Introduit à la WWDC 2024 et livré avec Swift 6 et Xcode 16, Swift Testing représente une refonte complète de la façon dont les tests fonctionnent en Swift. Ce framework remplace les 40+ assertions XCTest par seulement deux macros : `#expect` et `#require`. Les recruteurs testent désormais régulièrement cette connaissance lors des entretiens iOS. > **Format de ce guide** > > Chaque section reproduit une question d'entretien technique avec sa réponse détaillée et du code fonctionnel. La progression va des concepts fondamentaux vers les patterns avancés. ## Fondamentaux de Swift Testing ### Question 1 : Quelles différences majeures entre Swift Testing et XCTest ? Swift Testing apporte cinq changements fondamentaux par rapport à XCTest : 1. **Syntaxe déclarative** : attribut `@Test` au lieu de préfixes `test` 2. **Deux macros universelles** : `#expect` et `#require` remplacent 40+ assertions 3. **Parallélisme par défaut** : tous les tests tournent en parallèle 4. **Support async natif** : intégration complète avec Swift Concurrency 5. **Cross-platform** : fonctionne sur Apple, Linux et Windows ```swift // TestComparison.swift import XCTest import Testing // ❌ Ancien pattern XCTest class UserServiceXCTests: XCTestCase { // Doit commencer par "test" func testUserCreation() { let user = User(name: "Alice", age: 25) // Assertions multiples et verbeuses XCTAssertNotNil(user) XCTAssertEqual(user.name, "Alice") XCTAssertGreaterThan(user.age, 18) XCTAssertTrue(user.isValid) } } // ✅ Nouveau pattern Swift Testing @Test("User creation with valid data") func userCreation() { let user = User(name: "Alice", age: 25) // Une seule macro pour toutes les vérifications #expect(user.name == "Alice") #expect(user.age > 18) #expect(user.isValid) } ``` La différence clé réside dans l'expressivité : Swift Testing utilise des expressions Swift standard au lieu d'assertions spécialisées, rendant les tests plus lisibles et les messages d'erreur plus informatifs. ### Question 2 : Comment fonctionne la macro #expect ? La macro `#expect` valide qu'une expression booléenne est vraie. Elle capture automatiquement les valeurs évaluées pour fournir des messages d'erreur détaillés. Contrairement à `XCTAssert`, elle utilise la syntaxe Swift native. ```swift // ExpectMacroBasics.swift import Testing @Test func basicExpectations() { let numbers = [1, 2, 3, 4, 5] let user = User(name: "Bob", email: "bob@example.com") // Comparaisons simples - expression Swift standard #expect(numbers.count == 5) #expect(user.name == "Bob") // Comparaisons avec opérateurs #expect(numbers.first! < numbers.last!) #expect(user.email.contains("@")) // Vérification de nil #expect(numbers.first != nil) // Vérification de collections #expect(numbers.contains(3)) #expect(!numbers.isEmpty) } @Test func expectWithCustomMessage() { let balance = 150.0 let withdrawAmount = 200.0 // Message personnalisé pour clarifier l'intention #expect( balance >= withdrawAmount, "Insufficient funds: balance \(balance) < withdrawal \(withdrawAmount)" ) } ``` Quand un `#expect` échoue, le test continue son exécution. Cette caractéristique permet de collecter plusieurs échecs dans un seul test run, facilitant le diagnostic. ### Question 3 : Quelle différence entre #expect et #require ? La différence fondamentale concerne le comportement après échec : - `#expect` : enregistre l'échec et **continue** l'exécution - `#require` : enregistre l'échec et **stoppe** immédiatement le test `#require` doit toujours être utilisé avec `try` car il peut lancer une erreur. ```swift // ExpectVsRequire.swift import Testing struct ApiResponse { let data: Data? let items: [Item]? } @Test func demonstrateExpectContinues() { let values = [1, 2, 3] // Premier #expect échoue mais le test continue #expect(values.count == 10) // ❌ Échec enregistré // Ces vérifications s'exécutent quand même #expect(values.first == 1) // ✅ Succès #expect(values.last == 3) // ✅ Succès // Résultat : 1 échec, 2 succès dans le même test } @Test func demonstrateRequireStops() throws { let response = ApiResponse(data: nil, items: nil) // #require stoppe immédiatement si la condition échoue let data = try #require(response.data) // ❌ Échec et STOP // Ce code ne s'exécute JAMAIS si data est nil let json = try JSONDecoder().decode(User.self, from: data) #expect(json.name == "Alice") } ``` En pratique, `#require` remplace parfaitement `XCTUnwrap` pour le unwrapping d'optionnels sécurisé. > **Règle d'or** > > Utiliser `#require` quand les étapes suivantes dépendent du résultat (unwrapping, préconditions). Utiliser `#expect` pour des vérifications indépendantes qui peuvent échouer sans bloquer le reste du test. ## Patterns avancés avec #require ### Question 4 : Comment utiliser #require pour unwrapper des optionnels ? `#require` excelle dans le unwrapping d'optionnels. Il retourne la valeur non-optionnelle si elle existe, ou échoue le test immédiatement si `nil`. ```swift // RequireUnwrapping.swift import Testing struct UserProfile { let id: Int let name: String let settings: Settings? } struct Settings { let theme: String let notifications: Bool } @Test func unwrapOptionalChain() throws { let profile = UserProfile( id: 1, name: "Alice", settings: Settings(theme: "dark", notifications: true) ) // Unwrap sécurisé - stoppe si nil let settings = try #require(profile.settings) // Maintenant settings n'est plus optionnel #expect(settings.theme == "dark") #expect(settings.notifications == true) } @Test func unwrapArrayElement() throws { let users = ["Alice", "Bob", "Charlie"] // Unwrap du premier élément let firstUser = try #require(users.first) #expect(firstUser == "Alice") // Unwrap avec index sécurisé let secondUser = try #require(users[safe: 1]) #expect(secondUser == "Bob") } @Test func unwrapDictionaryValue() throws { let config: [String: Any] = [ "apiUrl": "https://api.example.com", "timeout": 30, "retryCount": 3 ] // Unwrap et cast en une seule opération let apiUrl = try #require(config["apiUrl"] as? String) let timeout = try #require(config["timeout"] as? Int) #expect(apiUrl.contains("https")) #expect(timeout > 0) } ``` Cette approche élimine les pyramides de `guard let` et rend le code de test linéaire et lisible. ### Question 5 : Comment tester qu'une fonction lance une erreur ? Swift Testing propose `#expect(throws:)` pour vérifier qu'une fonction lance bien une erreur spécifique. ```swift // ErrorTesting.swift import Testing enum ValidationError: Error, Equatable { case emptyName case invalidEmail case underAge(minimum: Int) } struct Validator { static func validateUser(name: String, email: String, age: Int) throws { guard !name.isEmpty else { throw ValidationError.emptyName } guard email.contains("@") else { throw ValidationError.invalidEmail } guard age >= 18 else { throw ValidationError.underAge(minimum: 18) } } } @Test func testThrowsSpecificError() { // Vérifie qu'une erreur spécifique est lancée #expect(throws: ValidationError.emptyName) { try Validator.validateUser(name: "", email: "test@mail.com", age: 25) } #expect(throws: ValidationError.invalidEmail) { try Validator.validateUser(name: "Alice", email: "invalid", age: 25) } } @Test func testThrowsErrorType() { // Vérifie le type d'erreur sans valeur spécifique #expect(throws: ValidationError.self) { try Validator.validateUser(name: "Bob", email: "bob@mail.com", age: 15) } } @Test func testThrowsWithInspection() throws { // Capture l'erreur pour inspection détaillée let error = try #require( throws: ValidationError.self ) { try Validator.validateUser(name: "Charlie", email: "charlie@mail.com", age: 16) } // Vérifie les détails de l'erreur if case .underAge(let minimum) = error { #expect(minimum == 18) } } ``` Cette syntaxe remplace `XCTAssertThrowsError` avec une API plus claire et type-safe. ## Organisation des tests avec @Test et @Suite ### Question 6 : Comment organiser les tests avec @Suite ? `@Suite` groupe logiquement des tests liés. Contrairement à `XCTestCase`, il ne nécessite pas d'héritage de classe. ```swift // TestSuiteOrganization.swift import Testing // Suite pour les tests d'authentification @Suite("Authentication Tests") struct AuthenticationTests { // Propriété partagée pour tous les tests de la suite let authService = AuthService() @Test("Login with valid credentials succeeds") func loginWithValidCredentials() async throws { let result = try await authService.login( email: "user@example.com", password: "validPass123" ) #expect(result.isSuccess) let token = try #require(result.token) #expect(!token.isEmpty) } @Test("Login with invalid password fails") func loginWithInvalidPassword() async { let result = await authService.login( email: "user@example.com", password: "wrong" ) #expect(!result.isSuccess) #expect(result.error == .invalidCredentials) } } // Suites imbriquées pour une organisation hiérarchique @Suite("User Management") struct UserManagementTests { @Suite("Creation") struct CreationTests { @Test func createUserWithValidData() { // Test de création } @Test func createUserWithDuplicateEmail() { // Test d'erreur doublon } } @Suite("Deletion") struct DeletionTests { @Test func deleteExistingUser() { // Test de suppression } @Test func deleteNonExistentUser() { // Test d'erreur } } } ``` Les suites permettent d'exécuter des sous-ensembles de tests et d'organiser les rapports de façon lisible. ### Question 7 : Comment utiliser les traits pour configurer les tests ? Les traits modifient le comportement des tests : conditions d'exécution, tags, timeouts, etc. ```swift // TestTraits.swift import Testing @Suite("API Integration Tests") struct APITests { // Test désactivé temporairement @Test(.disabled("Backend en maintenance")) func fetchUserProfile() async { // Ne s'exécute pas } // Test conditionnel selon la plateforme @Test @available(iOS 17, *) func useNewAPIFeature() { // S'exécute uniquement sur iOS 17+ } // Test avec tags pour filtrage @Test(.tags(.critical, .network)) func criticalNetworkOperation() async throws { // Test tagué pour filtrage } // Test avec timeout personnalisé @Test(.timeLimit(.minutes(2))) func longRunningOperation() async { // Doit terminer en 2 minutes max } // Combinaison de traits @Test( "Complex data sync", .tags(.slow), .timeLimit(.minutes(5)), .bug("JIRA-1234", "Flaky on CI") ) func complexDataSync() async throws { // Test documenté avec bug connu } } // Définition de tags personnalisés extension Tag { @Tag static var critical: Self @Tag static var network: Self @Tag static var slow: Self } ``` Les traits rendent les tests auto-documentés et permettent une exécution sélective via la ligne de commande. ## Tests paramétrés ### Question 8 : Comment créer des tests paramétrés ? Swift Testing permet d'exécuter le même test avec différentes entrées via les paramètres. ```swift // ParameterizedTests.swift import Testing struct EmailValidator { static func isValid(_ email: String) -> Bool { let pattern = #"^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$"# return email.range(of: pattern, options: .regularExpression) != nil } } // Test paramétré avec une collection @Test(arguments: [ "user@example.com", "test.name@domain.org", "contact@company.co.uk" ]) func validEmailsAreAccepted(email: String) { #expect(EmailValidator.isValid(email)) } @Test(arguments: [ "invalid", "@nodomain.com", "no@tld", "spaces in@email.com" ]) func invalidEmailsAreRejected(email: String) { #expect(!EmailValidator.isValid(email)) } // Test avec tuples pour cas input/output @Test(arguments: [ ("hello", "HELLO"), ("World", "WORLD"), ("Swift", "SWIFT") ]) func uppercaseConversion(input: String, expected: String) { #expect(input.uppercased() == expected) } // Test avec produit cartésien de deux collections @Test(arguments: [1, 2, 3], ["a", "b"]) func combinationTest(number: Int, letter: String) { // S'exécute pour (1,"a"), (1,"b"), (2,"a"), (2,"b"), (3,"a"), (3,"b") let combined = "\(number)\(letter)" #expect(combined.count == 2) } ``` Chaque combinaison de paramètres génère un test indépendant, facilitant l'identification des cas en échec. ### Question 9 : Comment tester du code asynchrone ? Swift Testing intègre nativement async/await, simplifiant drastiquement les tests asynchrones. ```swift // AsyncTesting.swift import Testing actor DataStore { private var items: [String] = [] func add(_ item: String) { items.append(item) } func getAll() -> [String] { items } func clear() { items.removeAll() } } @Suite("Async Operations") struct AsyncTests { @Test func basicAsyncOperation() async { // Pas besoin d'expectation ou de wait let result = await fetchData() #expect(!result.isEmpty) } @Test func asyncWithTimeout() async throws { // Utilise Task.sleep pour simuler un délai try await Task.sleep(for: .milliseconds(100)) let data = await loadConfiguration() let config = try #require(data) #expect(config.isValid) } @Test func testActorIsolation() async { let store = DataStore() // Opérations sur actor de manière séquentielle await store.add("Item 1") await store.add("Item 2") let items = await store.getAll() #expect(items.count == 2) #expect(items.contains("Item 1")) } @Test func testConcurrentOperations() async { let store = DataStore() // Exécution concurrente avec TaskGroup await withTaskGroup(of: Void.self) { group in for i in 1...10 { group.addTask { await store.add("Item \(i)") } } } let items = await store.getAll() #expect(items.count == 10) } } // Helpers asynchrones pour les tests func fetchData() async -> [String] { try? await Task.sleep(for: .milliseconds(50)) return ["data1", "data2"] } func loadConfiguration() async -> Configuration? { Configuration(isValid: true) } struct Configuration { let isValid: Bool } ``` > **Parallélisme par défaut** > > Swift Testing exécute les tests en parallèle par défaut. Pour les tests qui modifient un état partagé, utiliser `.serialized` sur la suite ou isoler l'état avec des actors. ## Migration XCTest vers Swift Testing ### Question 10 : Comment migrer progressivement de XCTest ? Les deux frameworks coexistent dans le même projet. Une migration progressive est recommandée. ```swift // MigrationStrategy.swift import XCTest import Testing // ⚠️ RÈGLE CRITIQUE : Ne jamais mélanger les frameworks dans un même test // ❌ INCORRECT - Mélange interdit class BadMixedTest: XCTestCase { func testMixed() { #expect(true) // Ne fonctionne pas dans XCTestCase } } // ✅ CORRECT - XCTest pur pour tests existants class LegacyUserTests: XCTestCase { func testUserCreation() { let user = User(name: "Test") XCTAssertNotNil(user) XCTAssertEqual(user.name, "Test") } } // ✅ CORRECT - Swift Testing pour nouveaux tests @Suite("User Tests - Modern") struct ModernUserTests { @Test func userCreation() { let user = User(name: "Test") #expect(user.name == "Test") } } // Stratégie de migration par fichier // 1. Identifier les tests à migrer (commencer par les plus simples) // 2. Créer un nouveau fichier avec @Suite // 3. Réécrire les tests un par un // 4. Supprimer l'ancien fichier XCTest une fois validé ``` | XCTest | Swift Testing | |--------|---------------| | `XCTAssertTrue(x)` | `#expect(x)` | | `XCTAssertFalse(x)` | `#expect(!x)` | | `XCTAssertEqual(a, b)` | `#expect(a == b)` | | `XCTAssertNil(x)` | `#expect(x == nil)` | | `XCTAssertNotNil(x)` | `#expect(x != nil)` | | `XCTUnwrap(x)` | `try #require(x)` | | `XCTAssertThrowsError` | `#expect(throws:)` | ### Question 11 : Quelles fonctionnalités XCTest ne sont pas encore dans Swift Testing ? Swift Testing (Swift 6) ne couvre pas encore tous les cas d'usage de XCTest. ```swift // MissingFeatures.swift // ❌ NON SUPPORTÉ : Tests de performance // Rester sur XCTest pour mesurer les performances class PerformanceTests: XCTestCase { func testPerformance() { measure { // Code à mesurer _ = (0..<1000).map { $0 * 2 } } } } // ❌ NON SUPPORTÉ : Tests UI (XCUITest) // Continuer d'utiliser XCUITest pour les tests d'interface class UITests: XCTestCase { func testLoginFlow() { let app = XCUIApplication() app.launch() // Tests UI... } } // ✅ SUPPORTÉ : Tests d'intégration async @Test func integrationTest() async throws { let api = APIClient() let response = try await api.fetchUsers() #expect(!response.isEmpty) } // ✅ SUPPORTÉ : Mocking avec protocols @Test func mockingWithProtocols() async { let mockService = MockUserService() let viewModel = UserViewModel(service: mockService) await viewModel.loadUser(id: 1) #expect(viewModel.user?.name == "Mock User") } ``` Pour les projets avec tests UI ou de performance, maintenir XCTest pour ces cas spécifiques. ## Questions pièges d'entretien ### Question 12 : Pourquoi #require nécessite try mais pas #expect ? `#require` peut lancer une erreur si la condition échoue, car il doit interrompre le test. `#expect` ne fait qu'enregistrer l'échec et continue, donc ne lance rien. ```swift // RequireTryExplanation.swift import Testing @Test func explainTryRequirement() throws { let optionalValue: String? = nil // #expect retourne Void - pas d'erreur lancée // Le test continue même si ça échoue #expect(optionalValue != nil) // Pas de try // #require peut lancer ExpectationFailedError // Le test s'arrête si ça échoue // Doit être marqué avec try let value = try #require(optionalValue) // Requiert try // Si on arrive ici, optionalValue n'était pas nil #expect(!value.isEmpty) } // La signature interne ressemble à : // func #expect(_ condition: Bool) -> Void // func #require(_ value: T?) throws -> T ``` Cette distinction architecturale permet au compilateur de garantir la gestion correcte des échecs. ### Question 13 : Comment Swift Testing gère-t-il le parallélisme ? Par défaut, tous les tests s'exécutent en parallèle, ce qui accélère les suites de tests mais requiert une isolation correcte de l'état. ```swift // ParallelismHandling.swift import Testing // ❌ PROBLÈME : État partagé mutable var sharedCounter = 0 // Danger en parallèle ! @Suite("Problematic Parallel Tests") struct ProblematicTests { @Test func incrementCounter1() { sharedCounter += 1 // Race condition ! } @Test func incrementCounter2() { sharedCounter += 1 // Race condition ! } } // ✅ SOLUTION 1 : Forcer l'exécution séquentielle @Suite("Sequential Tests", .serialized) struct SequentialTests { static var counter = 0 @Test func first() { Self.counter += 1 #expect(Self.counter == 1) } @Test func second() { Self.counter += 1 #expect(Self.counter == 2) } } // ✅ SOLUTION 2 : Isoler l'état par test @Suite("Isolated Tests") struct IsolatedTests { @Test func independentTest1() { var localCounter = 0 localCounter += 1 #expect(localCounter == 1) } @Test func independentTest2() { var localCounter = 0 localCounter += 1 #expect(localCounter == 1) } } // ✅ SOLUTION 3 : Utiliser un actor pour état partagé actor TestState { var value = 0 func increment() -> Int { value += 1 return value } } @Suite("Actor-based Tests") struct ActorTests { let state = TestState() @Test func safeIncrement() async { let result = await state.increment() #expect(result > 0) } } ``` Le trait `.serialized` garantit l'exécution séquentielle d'une suite entière. ### Question 14 : Comment utiliser les confirmations pour les callbacks ? Pour les API avec callbacks (non-async), Swift Testing propose `confirmation`. ```swift // ConfirmationPattern.swift import Testing // Service legacy avec callback class LegacyService { func fetchData(completion: @escaping (Result) -> Void) { DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { completion(.success("Data loaded")) } } } @Test func testCallbackWithConfirmation() async { let service = LegacyService() // confirmation attend qu'elle soit appelée await confirmation("Data callback received") { confirm in service.fetchData { result in if case .success(let data) = result { #expect(data == "Data loaded") confirm() // Signale que le callback a été exécuté } } } } // Pour les callbacks appelés plusieurs fois @Test func testMultipleCallbacks() async { let publisher = EventPublisher() // expectedCount spécifie le nombre d'appels attendus await confirmation("Events received", expectedCount: 3) { confirm in publisher.onEvent = { event in #expect(!event.isEmpty) confirm() // Appelé 3 fois } publisher.emit("Event 1") publisher.emit("Event 2") publisher.emit("Event 3") } } class EventPublisher { var onEvent: ((String) -> Void)? func emit(_ event: String) { DispatchQueue.global().async { self.onEvent?(event) } } } ``` `confirmation` remplace élégamment `XCTestExpectation` et `wait(for:timeout:)`. ## Conclusion Swift Testing représente l'avenir des tests sur les plateformes Apple. Les deux macros `#expect` et `#require` simplifient radicalement l'écriture de tests tout en améliorant la qualité des messages d'erreur. **Points clés à retenir pour les entretiens :** - ✅ `#expect` continue après échec, `#require` stoppe immédiatement - ✅ `#require` nécessite `try` car il peut lancer une erreur - ✅ Swift Testing tourne en parallèle par défaut - ✅ Les deux frameworks coexistent mais ne doivent pas être mélangés dans un même test - ✅ XCTest reste nécessaire pour les tests UI et de performance - ✅ Les traits permettent de configurer finement le comportement des tests - ✅ Les tests paramétrés évitent la duplication de code La migration vers Swift Testing peut se faire progressivement, fichier par fichier, en commençant par les tests les plus simples. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/ios/swift-testing-framework-macros-expect-require-xctest