Pour que les tests XCTest réussissent individuellement et en suite, attribuez à chaque test sa propre fixture mutable et injectez-la dans le code testé. Lorsqu’un singleton doit rester partagé, réinitialisez-le avant chaque test et empêchez tout accès simultané pendant toute la durée du test.

Ce guide suit une tâche de développement sur Mac : corriger des tests de calcul de mise en page dans Xcode qui réussissent seuls, mais échouent ensemble. L’exemple utilise uniquement des largeurs entières synthétiques et des calculs locaux. Il ne nécessite aucune fenêtre d’application, aucun mock de service, aucun stockage de fichiers ni aucune connexion réseau.

Pourquoi un test peut-il réussir seul et échouer dans la suite ?

Un test exécuté de façon isolée peut rencontrer l’état initial d’un singleton. Dans une suite, un test précédent a peut-être déjà modifié cet état. Une nouvelle référence à ce singleton pointe toujours vers le même objet mutable.

Swift distingue les propriétés d’instance des propriétés de type : chaque instance possède ses propres propriétés d’instance, tandis qu’une propriété de type n’existe qu’en un seul exemplaire partagé. Les propriétés de type stockées sont initialisées lors de leur premier accès. Par conséquent, affecter SpacingCalculator.shared à une propriété de test ne crée pas un nouveau calculateur. Consultez la documentation sur les propriétés du projet Swift.

Considérons une rangée synthétique contenant des éléments de largeurs [10, 20, 30]. Sa largeur totale correspond à la somme des largeurs des éléments, à laquelle s’ajoutent les espaces qui les séparent. Avec un espacement par défaut de 2, le résultat est 64 ; avec un espacement de 8, il est de 76.

Supposons qu’un test vérifie la largeur par défaut sans configurer le calculateur, tandis qu’un autre modifie l’espacement partagé pour le fixer à 8. Le tableau suivant décrit une exécution séquentielle, chaque ligne commençant avec un nouveau calculateur dont l’espacement est 2. Il s’agit de calculs illustratifs, et non de résultats de tests Xcode enregistrés.

Exécution Calculateur partagé sans réinitialisation Nouveau calculateur pour chaque test
Test de la valeur par défaut exécuté seul 64 : réussite 64 : réussite
Test du grand espacement exécuté seul 76 : réussite 76 : réussite
Valeur par défaut, puis grand espacement Les deux réussissent Les deux réussissent
Grand espacement, puis valeur par défaut Le test de la valeur par défaut reçoit 76 : échec Les deux réussissent

Vous disposez ainsi d’un diagnostic utile : cherchez quel test modifie l’état utilisé par l’assertion qui échoue. Renommer les tests pour obtenir un ordre favorable ne supprime pas cette dépendance.

Quelle méthode du cycle de vie XCTest doit gérer la fixture ?

Utilisez une méthode d’initialisation d’instance pour l’état mutable dont chaque test a besoin. Apple précise que l’initialisation d’instance s’exécute avant chaque méthode de test, tandis que l’initialisation au niveau de la classe ne s’exécute qu’une fois avant la classe de test. Une fixture mutable créée lors de l’initialisation au niveau de la classe ne repart donc pas d’un état initial pour chaque méthode. Consultez Configurer et nettoyer l’état dans vos tests.

Choisissez la méthode d’initialisation en fonction du travail à effectuer : la construction synchrone et non susceptible de lever une erreur convient à setUp() ; la construction susceptible de lever une erreur convient à setUpWithError() ; une préparation asynchrone convient à setUp() async throws. Utilisez le nettoyage pour terminer celui du test en cours. Apple déconseille explicitement de compter sur le nettoyage pour préparer les tests suivants, car un plantage peut empêcher son exécution. La même documentation sur le cycle de vie décrit ces limites.

Le choix de la fixture dépend à la fois de sa propriété et de son périmètre d’exécution :

Situation Approche appropriée Condition à vérifier
Les tests peuvent construire la dépendance Créer et injecter une instance par test Le consommateur doit utiliser cette instance injectée
Les tests doivent exercer le véritable singleton Établir un état initial complet avant chaque test Aucun test exécuté simultanément ne doit modifier ce singleton
La configuration partagée est immuable Réutiliser la valeur immuable Les objets référencés doivent également rester inchangés
Un test lance un travail asynchrone Attendre la fin du travail avant de terminer le test Aucun travail en suspens ne doit ensuite modifier la fixture

La première option est la plus simple pour ce calculateur. Le code de production peut conserver une instance partagée par défaut, tandis que les tests choisissent explicitement des instances indépendantes.

Créer une fixture XCTest détenue indépendamment

Utilisez un Mac avec Xcode et une cible de tests unitaires macOS existante. Ajoutez l’exemple complet suivant dans un fichier Swift de cette cible de test. Il définit son propre calculateur, consommateur et tests ; il n’a donc pas besoin d’importer le module de l’application. Utilisez une chaîne d’outils Swift 6 avec l’isolation d’acteur par défaut de la cible définie sur nonisolated.

Le calculateur est un acteur afin de rendre explicite la limite de concurrence de son état mutable. Les acteurs Swift restreignent l’accès à leur état mutable, et les appels depuis l’extérieur de l’acteur utilisent await. Ces règles sont décrites dans le chapitre sur la concurrence du projet Swift.

import XCTest

actor SpacingCalculator {
    static let shared = SpacingCalculator()

    private var gap = 2

    func setGap(_ value: Int) {
        precondition(value >= 0)
        gap = value
    }

    func width(of itemWidths: [Int]) -> Int {
        precondition(itemWidths.allSatisfy { $0 >= 0 })
        let gapCount = max(itemWidths.count - 1, 0)
        return itemWidths.reduce(0, +) + gapCount * gap
    }
}

struct RowLayout {
    let calculator: SpacingCalculator

    init(calculator: SpacingCalculator = .shared) {
        self.calculator = calculator
    }

    func width(of itemWidths: [Int]) async -> Int {
        await calculator.width(of: itemWidths)
    }
}

final class RowLayoutTests: XCTestCase {
    private var calculator: SpacingCalculator?

    override func setUp() {
        super.setUp()
        calculator = SpacingCalculator()
    }

    override func tearDown() {
        calculator = nil
        super.tearDown()
    }

    func testDefaultGap() async throws {
        let fixture = try XCTUnwrap(calculator)
        let layout = RowLayout(calculator: fixture)

        let actual = await layout.width(of: [10, 20, 30])

        XCTAssertEqual(actual, 64)
    }

    func testWideGap() async throws {
        let fixture = try XCTUnwrap(calculator)
        await fixture.setGap(8)
        let layout = RowLayout(calculator: fixture)

        let actual = await layout.width(of: [10, 20, 30])

        XCTAssertEqual(actual, 76)
    }

    func testEmptyRow() async throws {
        let fixture = try XCTUnwrap(calculator)
        let layout = RowLayout(calculator: fixture)

        let actual = await layout.width(of: [])

        XCTAssertEqual(actual, 0)
    }

    func testSingleItem() async throws {
        let fixture = try XCTUnwrap(calculator)
        let layout = RowLayout(calculator: fixture)

        let actual = await layout.width(of: [10])

        XCTAssertEqual(actual, 10)
    }
}

La ligne déterminante est calculator = SpacingCalculator(). Chaque initialisation crée un acteur indépendant dont l’espacement est 2. Chaque test transmet ensuite cet acteur à RowLayout. Appeler RowLayout() dans ces tests sélectionnerait .shared et contournerait la fixture.

L’exemple autorise délibérément la construction directe. Si votre singleton existant possède un initialiseur privé, ajoutez un chemin de construction interne ou extrayez son état de calcul mutable dans une dépendance constructible séparément. Un initialiseur interne peut être exposé à une cible de test via la configuration d’importation testable déjà établie pour le module de l’application ; il n’est pas nécessaire de rendre la référence au singleton remplaçable partout.

Le calcul définit également deux cas limites utiles : une rangée vide a une largeur de 0, et un élément seul ne comporte aucun espacement intermédiaire. L’exemple accepte les entiers non négatifs suffisamment petits pour éviter un dépassement d’entier ; les valeurs négatives enfreignent ses préconditions et interrompent l’exécution au lieu de provoquer un échec d’assertion.

Les calculs attendus et les deux ordres d’exécution des tests ont été vérifiés localement à l’aide de calculs Python déterministes. Les exemples Swift ont été examinés, mais pas compilés ni exécutés, car cet environnement de recherche ne dispose d’aucune chaîne d’outils Swift. Exécutez-les dans Xcode pour vérifier leur compilation et leur exécution avec votre cible.

Quand la réinitialisation du singleton suffit-elle ?

La réinitialisation est une solution transitoire viable lorsque les tests ont réellement besoin de l’instance partagée et que l’intégralité de son état visible par les tests peut être restaurée. Établissez l’état initial avant chaque test, y compris pour les valeurs considérées comme des valeurs par défaut. Réinitialiser uniquement la propriété modifiée par le test qui échoue peut laisser d’autres dépendances non détectées.

Pour ce calculateur, une initialisation asynchrone pourrait sélectionner SpacingCalculator.shared et attendre setGap(2) avant chaque méthode. Cela rétablit l’état initial du calcul uniquement si aucun appel simultané ne modifie l’espacement avant l’assertion. Si la fixture reçoit ensuite des résultats mis en cache ou une configuration supplémentaire, son opération de réinitialisation doit également couvrir ces valeurs.

Un acteur ne confère pas à un test la propriété exclusive de l’état au cours de plusieurs appels avec attente. Par exemple, le test A peut définir l’espacement partagé sur 2, le test B peut le définir sur 8, puis le test A peut calculer une largeur en utilisant 8. Chaque opération sur l’acteur peut s’exécuter correctement, tandis que l’hypothèse globale du test échoue. Cela découle des règles d’exécution et de suspension des acteurs documentées par Swift.

Avant de choisir une isolation fondée sur la réinitialisation, repérez chaque test et tâche en arrière-plan susceptible d’accéder à l’objet partagé. Si vous ne pouvez pas garantir son utilisation exclusive pendant la préparation, l’exécution et le nettoyage, injectez plutôt un état indépendant. Restaurer un instantané après coup n’empêche pas un autre appelant d’observer les modifications temporaires pendant le test.

Comment utiliser cette fixture avec Swift Testing ?

Swift Testing initialise une instance de suite distincte pour chaque fonction de test d’instance. Une propriété d’instance qui construit un calculateur fournit donc le même modèle de propriété, sans remplacer les méthodes d’initialisation XCTest. Une propriété initialisée à partir de .shared référencerait toujours le singleton. Apple décrit le cycle de vie des instances dans Organiser les fonctions de test avec des types de suite.

Avec les définitions précédentes de SpacingCalculator et RowLayout présentes une seule fois dans la même cible de test, voici une suite Swift Testing supplémentaire complète. Elle nécessite une chaîne d’outils comprenant le module Testing, comme Xcode 16 ou une version ultérieure. Son isolation d’acteur par défaut doit également être définie sur nonisolated.

import Testing

struct RowLayoutSwiftTests {
    let calculator = SpacingCalculator()

    @Test func defaultGap() async {
        let layout = RowLayout(calculator: calculator)
        let actual = await layout.width(of: [10, 20, 30])
        #expect(actual == 64)
    }

    @Test func wideGap() async {
        await calculator.setGap(8)
        let layout = RowLayout(calculator: calculator)
        let actual = await layout.width(of: [10, 20, 30])
        #expect(actual == 76)
    }
}

Pour une préparation plus élaborée, Swift Testing prend en charge les initialiseurs de suite, y compris ceux qui sont asynchrones ou susceptibles de lever une erreur. Le guide Apple Migrer un test depuis XCTest explique ce remplacement des méthodes d’initialisation XCTest. Cette fixture ne nécessite aucun nettoyage explicite, car elle ne possède qu’un état de calcul local et ne lance aucune tâche en arrière-plan.

Swift Testing exécute les tests en parallèle par défaut. L’application de .serialized à une suite exécute ses tests et suites imbriquées en série, mais n’empêche pas les tests sans lien avec elle de s’exécuter en parallèle. L’application de ce trait à une fonction de test ordinaire non paramétrée n’a aucun effet. Ces limites sont explicitées dans Exécuter les tests en série ou en parallèle.

Par conséquent, placer deux suites dépendant d’un singleton sous des annotations .serialized distinctes n’établit pas d’exclusion mutuelle entre elles. Une suite parente commune peut coordonner ses suites descendantes, à condition que tous les éléments susceptibles de les concurrencer appartiennent à ce périmètre. Des fixtures indépendantes évitent ce besoin de coordination pour ces tests de calcul.

Vérifier les résultats des tests individuels et de la suite dans Xcode

Utilisez le même schéma, le même plan de test et la même destination Mac pendant toute la vérification. Apple décrit l’exécution de méthodes individuelles, de suites et du plan de test actif dans Exécuter les tests et interpréter les résultats.

  1. Exécutez individuellement chacune des quatre méthodes XCTest à l’aide du bouton de lecture dans la gouttière de l’éditeur. Attendez-vous respectivement à 64, 76, 0 et 10.
  2. Exécutez la classe RowLayoutTests à l’aide du bouton de lecture situé à côté de sa déclaration. Les quatre méthodes devraient réussir ensemble.
  3. Choisissez Product > Test pour exécuter le plan de test actif. Vérifiez que ces tests ont bien été exécutés, et non ignorés ou exclus.
  4. Si vous avez ajouté l’exemple Swift Testing, exécutez aussi ses deux fonctions individuellement et ensemble.

Si les tests individuels réussissent, mais que le plan complet échoue, repérez l’objet utilisé par le consommateur qui échoue. Cherchez une injection oubliée, un utilitaire qui appelle .shared ou un travail asynchrone qui continue après le test qui l’a lancé. Si un test échoue seul, vérifiez ses entrées et le calcul attendu avant d’attribuer l’échec à l’ordre d’exécution de la suite.

La construction de la fixture doit rendre visible son état initial dans le chemin de construction du test en cours. Pour ces calculs de mise en page, chaque test possède un calculateur, le transmet explicitement au consommateur et attend la fin de son travail. Le résultat attendu est ainsi indépendant de l’ordre d’exécution des tests voisins.

Sources