Bonnes pratiques

Reproduire les échecs de tests aléatoires sur un Mac cloud

Reproduire les échecs de tests aléatoires sur un Mac cloud

Une tâche CI nocturne signale parfois un échec XCTest impossible à reproduire localement : les entrées sont générées par une fabrique aléatoire, les tests s’exécutent dans un ordre aléatoire et le pipeline relance automatiquement la tâche après un échec. La deuxième exécution réussit, mais il devient impossible de savoir quelles données ont provoqué le premier échec ou quels tests l’ont précédé. Pour résoudre ce type de problème, il ne faut pas multiplier les tentatives, mais transformer chaque exécution en une expérience entièrement reproductible.

Définir d’abord un contrat de rejeu

Les tests aléatoires dépendent d’au moins trois catégories de variables : les valeurs aléatoires des données métier, l’ordre d’exécution des tests, ainsi que l’état laissé par le simulateur et le processus. Enregistrer uniquement SEED=42 ne suffit pas, car une modification du commit, de la liste des tests ou des paramètres régionaux peut conduire la même graine vers un autre chemin d’exécution.

Au début de chaque tâche, générez un manifeste d’exécution que toutes les étapes suivantes devront uniquement lire. Il est recommandé d’y conserver au minimum les champs suivants :

Champ Exemple Utilité
commit Hachage du commit Git Figer le code testé
seed Entier non signé Rejouer les données aléatoires
tests Liste ordonnée d’identifiants de tests Figer l’ordre d’exécution
destination UDID du simulateur Figer la cible d’exécution
runtime Versions du système et de Xcode Repérer les différences de chaîne d’outils
locale zh_CN ou en_US Éviter les différences de formatage
result bundle Chemin vers .xcresult Conserver les journaux et les pièces jointes

« Le même test a fini par réussir » ne constitue pas une reproduction. Il faut retrouver les mêmes entrées, le même ordre et le même environnement pour déterminer si le défaut est toujours présent.

Le manifeste d’exécution doit être archivé avec les artefacts de test, et pas seulement affiché dans des journaux de console susceptibles d’être tronqués.

Utiliser une graine stable pour les données aléatoires

La source aléatoire du système Swift convient au code de production, mais elle ne permet pas de rejouer une séquence à partir d’une graine donnée. Injectez le générateur de nombres aléatoires dans la fabrique de données : les tests utiliseront une implémentation initialisable avec une graine, tandis que le code de production continuera d’utiliser l’implémentation système.

struct SplitMix64: RandomNumberGenerator {
    private var state: UInt64

    init(seed: UInt64) {
        state = seed
    }

    mutating func next() -> UInt64 {
        state &+= 0x9E3779B97F4A7C15
        var value = state
        value = (value ^ (value >> 30)) &* 0xBF58476D1CE4E5B9
        value = (value ^ (value >> 27)) &* 0x94D049BB133111EB
        return value ^ (value >> 31)
    }
}

func makeQuantity<R: RandomNumberGenerator>(using rng: inout R) -> Int {
    Int.random(in: 1...20, using: &rng)
}

Le point d’entrée des tests doit lire la graine depuis une variable d’environnement et échouer immédiatement si elle est absente, au lieu de revenir silencieusement à la source aléatoire du système :

let rawSeed = ProcessInfo.processInfo.environment["TEST_SEED"]
let seed = rawSeed.flatMap(UInt64.init)
XCTAssertNotNil(seed, "TEST_SEED must be provided")

Toutes les fabriques aléatoires d’une même tâche doivent partager une règle de dérivation explicite. Vous pouvez, par exemple, calculer une sous-graine à partir de la graine principale et d’un identifiant de test stable. Ainsi, un test qui demande une valeur aléatoire supplémentaire ne modifiera pas la séquence de données de toute la suite.

Figer la liste et l’ordre d’exécution des tests

L’ordre des tests ne doit pas dépendre de l’ordre de parcours du système de fichiers. Commencez par maintenir une liste textuelle contenant un identifiant complet par ligne, par exemple AppTests/LoginTests/testExpiredSession. Triez ensuite cette liste de manière stable à partir d’un condensat de la graine et de chaque identifiant :

import hashlib
import os

seed = os.environ["TEST_SEED"]
with open("ci/tests.txt", encoding="utf-8") as source:
    tests = [line.strip() for line in source if line.strip()]

def key(name):
    value = f"{seed}:{name}".encode()
    return hashlib.sha256(value).hexdigest()

for test in sorted(tests, key=key):
    print(test)

Cette méthode permet à la fois de couvrir différents ordres et de reconstruire la même liste sur n’importe quel nœud. Avant l’exécution, enregistrez le résultat du tri dans ordered-tests.txt, puis construisez les arguments de xcodebuild test par test :

set -euo pipefail

mkdir -p artifacts
python3 ci/order_tests.py > artifacts/ordered-tests.txt

args=()
while IFS= read -r test_id; do
  args+=("-only-testing:${test_id}")
done < artifacts/ordered-tests.txt

xcodebuild test \
  -workspace App.xcworkspace \
  -scheme App \
  -destination "id=${SIMULATOR_UDID}" \
  -resultBundlePath "artifacts/test-${TEST_SEED}.xcresult" \
  "${args[@]}"

N’activez pas en parallèle un autre mécanisme d’ordre aléatoire qui ne serait pas enregistré. Sinon, l’ordre du manifeste et l’ordre réellement exécuté divergeront de nouveau.

Isoler l’état du simulateur sans effacer les preuves

Avant un rejeu, utilisez un simulateur dédié afin que le débogage manuel et la CI ne partagent pas le même appareil. Vous pouvez arrêter et effacer l’appareil indiqué avant le début des tests, mais vous devez vérifier que son UDID appartient bien à la tâche en cours. N’effectuez jamais de nettoyage indifférencié sur un appareil partagé.

xcrun simctl shutdown "$SIMULATOR_UDID" 2>/dev/null || true
xcrun simctl erase "$SIMULATOR_UDID"
xcrun simctl boot "$SIMULATOR_UDID"
xcrun simctl bootstatus "$SIMULATOR_UDID" -b

Les paramètres régionaux, la langue, le fuseau horaire et les arguments de lancement doivent eux aussi être définis explicitement par le point d’entrée des tests. Si les réponses réseau, l’heure actuelle ou les UUID influencent les assertions, injectez des implémentations fixes au moyen de protocoles au lieu d’espérer que le simulateur se trouve fortuitement dans le même état.

Si l’échec semble provenir d’une contamination par des tests antérieurs, ne commencez pas par exécuter chaque test dans un simulateur neuf. Rejouez d’abord les tests dans l’ordre d’origine pour confirmer l’échec. Réduisez ensuite la liste des tests préalables par dichotomie afin d’identifier la plus petite chaîne de contamination. Vous pourrez ainsi distinguer un échec déclenché par les données aléatoires d’une fuite d’état provenant d’un test précédent.

Conserver l’état complet dès le premier échec

Une tâche en échec doit conserver au minimum le fichier xcresult, la liste ordonnée des tests, le manifeste d’exécution, la sortie standard et le commit du code. Enregistrez également xcodebuild -version, sw_vers et le runtime du simulateur. Pour les variables d’environnement sensibles, ne conservez que leur nom et l’indication de leur présence, jamais leur valeur réelle.

Les relances automatiques peuvent servir à classer le problème, mais elles doivent uniquement intervenir après l’archivage du premier échec. Il est recommandé de répartir les résultats en trois catégories :

Échec stable avec le même contrat

Si l’échec persiste avec le même commit, la même graine, le même ordre et le même état du simulateur, examinez en priorité la logique métier déterministe et les assertions.

Échec après modification de l’ordre

Si les données restent identiques mais qu’une modification des tests précédents change le résultat, concentrez-vous sur les singletons, les observateurs de notifications, les bases de données, les caches et les configurations globales qui n’ont pas été restaurées.

Résultat toujours instable avec le même contrat

Vérifiez de nouveau que les tâches asynchrones sont réellement terminées, que le code ne lit pas l’heure système, que les réponses réseau sont bien figées et que les tests n’appellent aucune source aléatoire non injectée.

L’objectif final n’est pas de faire disparaître un échec grâce aux relances, mais d’associer à chaque échec une commande de rejeu exécutable. Une fois ce mécanisme en place, un problème intermittent cesse d’être un « événement probabiliste » pour devenir un ensemble d’entrées que l’on peut réduire, comparer et corriger.

Questions fréquentes

La graine aléatoire suffit-elle pour reproduire un échec XCTest ?

Non, généralement. Il faut aussi fixer le commit, la liste et l’ordre des tests, le runtime du simulateur, la locale et les entrées externes afin de retrouver le même chemin d’exécution.

Faut-il relancer automatiquement un test dès son premier échec ?

Il faut d’abord conserver le xcresult initial, la graine et l’ordre exact. La relance vient ensuite avec le même contrat de rejeu, sans considérer une réussite ponctuelle comme une correction.

Pourquoi injecter un générateur aléatoire dans le code Swift ?

Le test peut utiliser un générateur initialisé avec une graine et reproduire la même suite de valeurs, tandis que le code de production conserve le générateur système.

ArmCLI Mac cloud

Préparez votre prochain build sur un Mac physique dédié

Choisissez le modèle, le centre de données et la durée selon votre tâche. Les nœuds restent opérationnels toute l’année ; leur disponibilité réelle est indiquée en temps réel dans la console.