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.

Swift Testing Framework avec macros #expect et #require pour entretiens iOS

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
TestComparison.swiftswift
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.

ExpectMacroBasics.swiftswift
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.

ExpectVsRequire.swiftswift
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.

RequireUnwrapping.swiftswift
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.

ErrorTesting.swiftswift
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.

Prêt à réussir tes entretiens iOS ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

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.

TestSuiteOrganization.swiftswift
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.

TestTraits.swiftswift
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.

ParameterizedTests.swiftswift
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.

AsyncTesting.swiftswift
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.

MigrationStrategy.swiftswift
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.

MissingFeatures.swiftswift
// ❌ 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.

Prêt à réussir tes entretiens iOS ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

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.

RequireTryExplanation.swiftswift
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<T>(_ 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.

ParallelismHandling.swiftswift
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.

ConfirmationPattern.swiftswift
import Testing

// Service legacy avec callback
class LegacyService {
    func fetchData(completion: @escaping (Result<String, Error>) -> 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.

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

#swift
#ios
#testing
#xctest
#entretien

Partager

Articles similaires