Isoler les états partagés de Swift Testing sur un Mac cloud

Isoler les états partagés de Swift Testing sur un Mac cloud

Une même suite de tests Swift peut réussir lorsqu’elle est exécutée seule, puis échouer de manière intermittente dans des tâches parallèles sur un Mac cloud. Le problème vient généralement moins de l’instabilité de la machine que du partage de répertoires, de préférences, de bases de données, de ports ou d’états aléatoires entre les tests. Ajouter des tentatives ne fait que masquer cette pollution. Une approche plus fiable consiste à attribuer à chaque test une frontière d’exécution identifiable, nettoyable et reproductible.

Vérifier d’abord si l’échec provient d’un état partagé

Conservez d’abord le parallélisme au lieu de passer immédiatement à une exécution séquentielle. Exécutez la même cible avec un seul processus de travail, puis avec plusieurs, en produisant un bundle de résultats distinct pour chaque exécution :

set -o pipefail

xcodebuild test \
  -scheme AppTests \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -parallel-testing-enabled NO \
  -resultBundlePath Artifacts/serial.xcresult

xcodebuild test \
  -scheme AppTests \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -parallel-testing-enabled YES \
  -maximum-parallel-testing-workers 4 \
  -resultBundlePath Artifacts/parallel.xcresult

Si l’exécution avec un seul processus est stable alors que l’exécution parallèle échoue, examinez en priorité les ressources suivantes plutôt que de remettre d’abord les assertions en cause :

Ressource partagée Symptômes courants Méthode d’isolation
Répertoire temporaire Fichiers écrasés ou absents lors du nettoyage Créer un sous-répertoire unique pour chaque test
UserDefaults Lecture d’une valeur écrite par un autre cas de test Utiliser un suiteName distinct
Fichier SQLite Conflits de verrouillage ou variation du nombre de données Utiliser une base de données distincte pour chaque test
Port fixe Address already in use Lier le service au port 0
Générateur aléatoire global Résultat différent après un changement d’ordre Enregistrer explicitement la graine aléatoire

La réussite en mode séquentiel prouve seulement que l’état partagé n’a pas été utilisé simultanément, pas que les tests sont correctement isolés.

Attribuer un bac à sable distinct à chaque test

Ne laissez pas tous les cas de test écrire dans /tmp/app-tests. Au démarrage d’un test, générez un identifiant unique, placez les fichiers, la base de données et les résultats exportés dans le répertoire correspondant, puis nettoyez-le à la fin.

import Foundation
import Testing

struct TestSandbox {
    let id: String
    let root: URL

    init(name: String) throws {
        id = "\(name)-\(UUID().uuidString)"
        root = FileManager.default.temporaryDirectory
            .appending(path: "ZoneMiniTests")
            .appending(path: id)
        try FileManager.default.createDirectory(
            at: root,
            withIntermediateDirectories: true
        )
    }

    func preferences() throws -> UserDefaults {
        guard let defaults = UserDefaults(suiteName: "tests.\(id)") else {
            throw CocoaError(.fileWriteUnknown)
        }
        return defaults
    }

    func remove() throws {
        try FileManager.default.removeItem(at: root)
        UserDefaults.standard.removePersistentDomain(
            forName: "tests.\(id)"
        )
    }
}

Transmettez root au code testé par injection de dépendances afin d’éviter que le code de production lise lui-même un chemin temporaire global. Placez le nettoyage dans un bloc defer pour qu’il s’exécute même en cas d’échec d’une assertion. Si vous devez conserver l’état d’un test défaillant, vous pouvez ignorer la suppression selon une variable d’environnement et joindre le chemin du bac à sable aux pièces jointes du test.

Ne pas utiliser le nom du test comme clé unique

Un test paramétré peut exécuter en parallèle plusieurs jeux d’entrées sous le même nom de fonction. Le seul nom de la fonction provoquera donc encore des collisions. Combinez le nom du test, un résumé des paramètres et un UUID. Si les paramètres contiennent des jetons ou des données utilisateur, ne les insérez pas directement dans le nom du répertoire : générez d’abord une empreinte irréversible.

Isoler la configuration, la base de données et les ports d’écoute

Le domaine standard de UserDefaults constitue un état partagé à l’échelle du processus. Les tests doivent recevoir une instance dédiée et supprimer le domaine persistant correspondant à la fin. Le même principe s’applique aux bases de données : chaque test crée son propre fichier, et les tests de migration ne doivent pas réutiliser la même copie que les tests ordinaires de lecture et d’écriture.

Les tests réseau sont particulièrement exposés aux faux échecs causés par des ports fixes. Le serveur de test doit être lié au port 0 afin que le système choisisse un port disponible, puis transmettre le port effectivement attribué au client. N’utilisez pas une méthode consistant à rechercher d’abord un port libre, à fermer la connexion de détection, puis à effectuer une nouvelle liaison : une fenêtre de concurrence existe entre la détection et la liaison.

Les tests qui dépendent réellement d’un singleton global et ne peuvent pas encore être remaniés peuvent être sérialisés localement :

import Testing

@Suite(.serialized)
struct LegacyDatabaseTests {
    @Test
    func migratesExistingStore() async throws {
        // Implémentation du test
    }
}

Limitez .serialized au périmètre du code hérité. Sérialiser toute la cible de test fait disparaître la pollution parallèle, mais également les gains de temps d’exécution et les indices permettant d’identifier le problème.

Fixer la graine aléatoire plutôt que multiplier les tentatives

Lorsqu’un test implique un ordre aléatoire, un délai exponentiel entre les tentatives ou la génération de données, lisez la graine depuis une variable d’environnement. Générez-la et enregistrez-la une seule fois au démarrage de la tâche, puis faites dériver une sous-graine propre à chaque processus de test à partir de cette valeur.

let environment = ProcessInfo.processInfo.environment
let seed = UInt64(environment["TEST_SEED"] ?? "") ?? 20260806

Chaque sous-graine peut être calculée de manière stable à partir de la graine de base et de la clé unique du test. Le rapport d’échec doit au minimum conserver la graine de base, le nombre de processus de travail, la cible de test et la commande d’exécution. Vous pourrez ainsi reproduire la combinaison d’une entrée précise et d’un degré de parallélisme donné, au lieu de relancer aveuglément les tests dix fois.

Sur les nœuds d’exécution continue de ZoneMini, il est recommandé d’écrire le bundle de résultats dans un répertoire associé à l’identifiant de la tâche afin d’éviter qu’une exécution ultérieure n’écrase la précédente :

RUN_ID="${CI_RUN_ID:-local-$(date +%s)}"
mkdir -p "Artifacts/$RUN_ID"

TEST_SEED=20260806 xcodebuild test \
  -scheme AppTests \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -parallel-testing-enabled YES \
  -maximum-parallel-testing-workers 4 \
  -resultBundlePath "Artifacts/$RUN_ID/tests.xcresult"

Valider la correction par des répétitions sous charge

Après la correction, ne vous contentez pas d’une seule exécution. Commencez par enchaîner plusieurs exécutions avec la même graine et le même degré de parallélisme, puis changez de graine pour couvrir d’autres entrées. Le bundle de résultats de chaque cycle doit utiliser un chemin distinct, sinon xcodebuild s’arrêtera parce que la destination existe déjà.

La validation peut reposer sur quatre critères explicites :

  1. L’exécution avec un seul processus et celle avec quatre processus produisent les mêmes résultats d’assertion.
  2. Chaque cas de test écrit uniquement dans son propre répertoire, son propre domaine de préférences et sa propre base de données.
  3. Les services en écoute n’utilisent aucun port codé en dur.
  4. Les journaux d’échec permettent de retrouver la graine, le bac à sable et le bundle de résultats.

Vérifiez enfin la stratégie de nettoyage. Les tâches réussies peuvent supprimer les bacs à sable temporaires. Les tâches en échec doivent conserver les journaux et bundles de résultats nécessaires, sans stocker durablement d’identifiants, de jetons de dépôt ni de contenu de requête non anonymisé. La stabilité des tests parallèles ne repose pas sur davantage de tentatives, mais sur l’attribution d’un état propre à chaque cas de test et sur la conservation, à chaque échec, d’informations suffisantes pour le reproduire.

Questions fréquentes

Faut-il désactiver immédiatement l’exécution parallèle en cas d’échec ?

Non. Il faut d’abord isoler les répertoires, UserDefaults, bases de données et ports de chaque test. Le trait .serialized reste un recours local pour les anciens scénarios non parallélisables.

Comment reproduire un échec aléatoire visible uniquement dans la CI ?

Conservez la graine, le nombre de workers, la destination de test et le chemin du bundle xcresult. Relancez ensuite avec la même graine et le même niveau de parallélisme.

Pourquoi éviter un port fixe dans les tests parallèles ?

Plusieurs processus peuvent réserver le même port ou contacter le serveur d’un autre test. Le serveur doit demander le port 0 puis transmettre au client le port réellement attribué.

CRÉNEAU DE BUILD DÉDIÉ

Choisissez un Mac dans le cloud dédié à vos tâches de build continues

Vérifiez le modèle M4, la mémoire, le stockage, le nœud et la période de facturation, puis intégrez cet environnement de build fixe à votre pipeline existant.

Choisir une configuration et commander