Bonjour et bienvenue !
Si c’est la première fois que vous lisez un de mes articles, je me présente : je m’appelle Skander et je suis passionné par le développement iOS. Mon objectif est de partager mes découvertes et réflexions sur Swift et l’écosystème Apple, en espérant que vous y trouviez quelque chose d’intéressant et utile.

J’écris souvent mes articles lors de mes voyages en train à travers les magnifiques paysages de la France. Aujourd’hui, alors que je voyage d’Abbeville à Paris Nord pour assister au Swift Connection, je vous invite à m’accompagner dans ce nouvel article.

Les pièges des tests asynchrones en Swift : Éviter les échecs aléatoires dus aux conditions de concurrence

Récemment, l’utilisation des fonctions asynchrones en Swift est devenue de plus en plus courante, particulièrement avec l’arrivée de Swift6.

Il est toujours excitant de suivre les nouveautés technologiques et de rester à jour. Cependant, cela peut parfois être un vrai défi, surtout dans le contexte d’un projet de grande envergure.

Prenons cet exemple simple :

func toto() async {
    Task {
        let soso = try await manager.getFofo().value
        self.soso = soso
    }
}

Dès que j’ai écrit ce code, une question s’est imposée : comment tester efficacement le contenu du bloc Task ? En explorant les solutions disponibles sur internet, j’ai rapidement trouvé des exemples qui ressemblent à ceci :

func testToto() async throws {
    let task = sut.toto() // ici le sut devrais déjà configuré avec un manager mock
    try await task.value
    XCTAssertNotNil(sut.soso)
}

Cependant, il y a un problème sous-jacent avec cette approche. Si vous n’avez pas encore lu mon article sur les Data Races en Swift 6, je vous recommande vivement de le faire avant de continuer.

Pour ceux qui l’ont déjà lu, voici la conclusion que j’en tire :

L’échec du test se produit de manière aléatoire en raison d’une condition de concurrence potentielle. Cela est fréquent lorsque l’on travaille avec du code asynchrone, comme avec les Task de Swift. Le problème ici est que l’assertion XCTAssertNotNil peut être exécutée avant la fin de la tâche asynchrone, entraînant des échecs intermittents.

Ce problème est encore plus amplifié lorsque plusieurs tests s’exécutent en parallèle dans votre projet, augmentant les risques d’échec liés aux conditions de course.

La solution :

Pour éviter les échecs aléatoires lors des tests, nous devons nous assurer que le bloc asynchrone a bien terminé sa tâche avant d’exécuter les assertions. Apple recommande une approche classique avec XCTestExpectation, qui permet d’attendre la fin d’un bloc asynchrone avant de poursuivre.

func testToto() async throws {
    // GIVEN
    let expectation = XCTestExpectation(description: "Tâche Toto terminée")

    // Simuler l'accomplissement de la tâche asynchrone dans le stub
    sut.manager.totoStub = { _ in
        expectation.fulfill()
        return true
    }

    // WHEN
    sut.toto()

    // THEN
    wait(for: [expectation], timeout: 1.0)
    XCTAssertNotNil(sut.soso)
}

Alors, à votre avis, qu’est-ce qui devrait être modifié pour que notre code fonctionne sans erreur sous Swift6 ?

Bravo 🎉 ! La réponse est bien le wait(for:).

Avec Swift6, wait(for:) est désormais déprécié, car il n’est plus adapté aux nouvelles APIs asynchrones introduites par async/await. Heureusement, il existe une alternative plus moderne : await fulfillment(of:timeout:enforceOrder:).

Cette méthode permet d’attendre qu’une ou plusieurs attentes XCTestExpectation soient remplies, tout en intégrant le support natif de l’asynchrone de Swift. Voici comment adapter notre test :

func testToto() async throws {
    // GIVEN
    let expectation = XCTestExpectation(description: "Tâche Toto terminée")

    // Simuler l'accomplissement de la tâche asynchrone dans le stub
    sut.manager.totoStub = { _ in
        expectation.fulfill()
        return true
    }

    // WHEN
    sut.toto()

    // THEN
    try await fulfillment(of: [expectation], timeout: 1.0)
    XCTAssertNotNil(sut.soso)
}

Voilà, c’est tout pour ce blog ! J’espère que vous avez trouvé ces explications utiles pour mieux comprendre les tests asynchrones en Swift6. Merci de m’avoir accompagné durant ce voyage à travers les défis de async/await. Je vous donne rendez-vous très bientôt pour un autre article, et qui sait, peut-être sur un autre trajet en train à travers la France. 🚆

À bientôt pour de nouvelles aventures Swiftiennes !

Categorized in:

Swift,