Un test d’interface peut réussir sur le Mac d’un développeur, puis rester occasionnellement bloqué sur l’écran d’accueil après plusieurs dizaines d’exécutions sur un Mac cloud. La cause la plus fréquente n’est pas la puissance du simulateur, mais l’absence de contrat définissant l’état au lancement. Un scénario transmet -skipOnboarding, un autre utilise une variable d’environnement et un troisième oublie d’effacer les données persistantes. Chacun fonctionne correctement de manière isolée, mais les états commencent à se contaminer dès que les tests sont parallélisés ou exécutés dans un ordre différent. La solution consiste à traiter les noms des paramètres, le format des valeurs, la réinitialisation de l’état et les conditions d’exclusion de Release comme une seule interface testable.
Définir le contrat de lancement plutôt que disperser des chaînes
Les arguments de lancement conviennent aux indicateurs booléens, tandis que les variables d’environnement sont adaptées aux réglages associés à une valeur. Les deux doivent être déclarés de manière centralisée afin d’éviter que la cible de test et celle de l’application utilisent des orthographes différentes. L’analyseur suivant n’est actif que dans les builds Debug et applique un comportement par défaut explicite aux valeurs absentes ou vides.
#if DEBUG
enum UITestLaunchContract {
static let arguments = ProcessInfo.processInfo.arguments
static let environment = ProcessInfo.processInfo.environment
static var enabled: Bool {
arguments.contains("-ui-testing")
}
static var resetState: Bool {
arguments.contains("-reset-state")
}
static var fixture: String? {
guard enabled else { return nil }
return environment["UITEST_FIXTURE"]?.trimmingCharacters(
in: .whitespacesAndNewlines
).nonEmpty
}
}
private extension String {
var nonEmpty: String? {
isEmpty ? nil : self
}
}
#endif
Le contrat doit répondre à au moins quatre questions : quel est le nom de l’indicateur, d’où vient sa valeur, comment les valeurs non valides sont-elles traitées et que se passe-t-il lors d’un lancement hors test ? L’absence de valeur ne doit pas correspondre implicitement à un état métier. Ne placez pas non plus d’identifiants d’accès, de matériel de signature ou de jetons dans les variables d’environnement : ils peuvent apparaître dans les informations du processus, les rapports de test ou les journaux d’échec.
Le point d’entrée réservé aux tests doit produire un état reproductible, et non contourner les validations métier. Les assertions réalisables via l’interface publique doivent continuer à passer par celle-ci. Seules l’initialisation des jeux de données et la suppression des résidus ont leur place dans le contrat de lancement.
Isoler l’état dès le début du lancement de l’application
La réinitialisation de l’état doit intervenir avant la lecture des préférences utilisateur, la création du conteneur de base de données et le choix du premier écran. Si l’interface est construite avant un nettoyage asynchrone, le test observe simultanément l’ancien et le nouvel état, ce qui crée une condition de concurrence difficile à reproduire.
N’effacer que les données appartenant aux tests
Utilisez une suite distincte pour les tests d’interface au lieu de supprimer des répertoires sans limites précises. La base de données doit également résider dans un conteneur réservé aux tests et être supprimée avant la création de la pile de persistance.
#if DEBUG
func prepareUITestState() throws {
guard UITestLaunchContract.enabled else { return }
if UITestLaunchContract.resetState {
let defaults = UserDefaults(suiteName: "com.example.app.uitests")
defaults?.removePersistentDomain(
forName: "com.example.app.uitests"
)
let storeURL = FileManager.default.temporaryDirectory
.appendingPathComponent("UITestStore.sqlite")
if FileManager.default.fileExists(atPath: storeURL.path) {
try FileManager.default.removeItem(at: storeURL)
}
}
}
#endif
La fonction de nettoyage doit pouvoir être exécutée plusieurs fois : l’absence de la cible ne doit pas provoquer d’erreur, tandis qu’un échec de suppression doit interrompre immédiatement le lancement du test au lieu de poursuivre avec un état partiellement nettoyé. Les noms des jeux de données doivent également être associés à une liste blanche, par exemple empty-project et three-items. Une variable d’environnement ne doit jamais pouvoir devenir directement un chemin de fichier arbitraire.
Assembler tous les paramètres de lancement dans XCTest
Côté test, un seul lanceur doit être utilisé. Il se charge d’arrêter l’ancien processus, de définir les paramètres fixes et de valider le jeu de données. Les méthodes de test ne modifient plus elles-mêmes launchArguments.
final class AppLauncher {
static func launch(fixture: String) -> XCUIApplication {
let allowed = ["empty-project", "three-items"]
precondition(allowed.contains(fixture))
let app = XCUIApplication()
if app.state != .notRunning {
app.terminate()
}
app.launchArguments = [
"-ui-testing",
"-reset-state",
"-disable-animations"
]
app.launchEnvironment = [
"UITEST_FIXTURE": fixture,
"LC_ALL": "en_US_POSIX"
]
app.launch()
return app
}
}
Affectez launchArguments dans son ensemble au lieu d’enchaîner les appels à append sur une instance partagée de l’application. Une affectation complète garantit qu’une nouvelle exécution ne récupère aucun indicateur laissé par le scénario précédent. Si le test nécessite deux états différents, arrêtez le processus puis rappelez le lanceur au lieu de modifier les variables d’environnement pendant l’exécution. Une fois le processus démarré, ProcessInfo n’est pas réinitialisé avec les nouvelles valeurs du script de test.
Transformer les erreurs de contrat en échecs du pipeline
La revue de code ne suffit pas à bloquer les anciens paramètres. Un petit test unitaire peut vérifier que les noms des paramètres sont uniques et que tous les jeux de données peuvent être interprétés. Les tests d’interface rapides doivent en outre couvrir trois parcours : un lancement normal sans paramètre, l’ouverture de l’écran attendu avec un jeu de données valide et un échec explicite avec un jeu de données non valide.
Avant l’exécution sur un Mac cloud, fixez le scheme, le plan de test et l’identifiant du simulateur :
set -euo pipefail
: "${SIMULATOR_ID:?SIMULATOR_ID is required}"
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-testPlan CI \
-destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \
-resultBundlePath artifacts/LaunchContract.xcresult
Le pipeline ne doit pas masquer le premier échec en « relançant une fois ». Le bundle de résultats du premier échec doit être conservé tel quel. Une nouvelle tentative ne doit servir qu’au diagnostic et doit utiliser un autre chemin de sortie. Sinon, la réussite de la deuxième exécution écrase les informations les plus précieuses sur l’état initial.
Vérifier les conditions de compilation de Release
Après avoir protégé le point d’entrée des tests avec #if DEBUG, vérifiez également que Release n’inclut pas DEBUG ni une condition de test personnalisée par erreur :
set -euo pipefail
settings="$(
xcodebuild \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-showBuildSettings
)"
if printf '%s\n' "$settings" |
grep -E 'SWIFT_ACTIVE_COMPILATION_CONDITIONS.*(DEBUG|UI_TESTING)'; then
echo "Unexpected test compilation condition in Release" >&2
exit 1
fi
Ce contrôle porte sur les réglages de build après leur résolution finale, ce qui le rend plus fiable qu’une simple inspection du fichier de projet. Si l’équipe utilise plusieurs fichiers xcconfig, exécutez-le séparément pour chaque configuration pouvant être publiée.
Gérer l’exécution parallèle et les preuves d’échec
Lors d’une exécution parallèle, chaque worker doit disposer de son propre identifiant de données. Le pipeline peut générer un UITEST_RUN_ID non sensible, puis l’utiliser pour construire le répertoire temporaire et le nom de la suite. Plusieurs workers ne doivent pas partager un fichier de base de données fixe. Une fois les tests terminés, ne supprimez que le répertoire correspondant à l’identifiant de l’exécution en cours afin qu’une tâche n’efface pas les éléments de diagnostic d’une autre.
Les preuves d’échec doivent au minimum conserver la liste blanche des arguments de lancement, le nom du jeu de données, la méthode de test, l’identifiant du simulateur et le chemin du bundle de résultats. N’écrivez pas le dictionnaire complet des variables d’environnement dans les journaux, car il peut contenir des valeurs sensibles injectées par le pipeline. Il est préférable de ne consigner que les clés autorisées et de masquer les valeurs selon leur type.
L’ordre des vérifications peut être standardisé comme suit :
- Confirmer que l’application a réellement été arrêtée avant son lancement.
- Vérifier que les paramètres sont affectés dans leur ensemble et qu’aucune clé n’est dupliquée.
- Confirmer que le nettoyage de l’état précède l’initialisation de la base de données et de l’interface racine.
- Comparer les répertoires de données utilisés par le scénario en échec et par le scénario précédent.
- Utiliser le même bundle de résultats pour examiner la chronologie des plantages, des assertions et des captures d’écran.
- Effectuer un démarrage à froid sans aucun paramètre de test afin de confirmer que le parcours normal n’est pas affecté.
Liste de contrôle minimale avant publication
Avant de fusionner les changements, toutes les conditions suivantes doivent être remplies : les noms des paramètres sont définis de manière centralisée ; les réglages associés à une valeur utilisent une liste blanche ; les données de test résident dans un conteneur distinct ; le nettoyage peut être répété ; chaque lancement remplace l’ensemble des paramètres ; les workers parallèles ne partagent aucun répertoire ; les nouvelles tentatives n’écrasent pas les bundles de résultats en échec ; les conditions de compilation de Release ne contiennent aucun indicateur de test ; un démarrage à froid sans paramètre accède au parcours normal.
L’intérêt de ces contraintes ne réside pas simplement dans l’ajout d’un lanceur. Elles transforment la convention implicite définissant « l’état dans lequel l’application démarre » au sein des scripts de test en une interface vérifiable à la fois par l’application et par le pipeline. Ce n’est qu’une fois l’état descriptible, refusable et nettoyable que les changements d’ordre, l’exécution parallèle et les répétitions prolongées sur Mac cloud produisent des résultats comparables.
Questions fréquentes
Pourquoi éviter d’ajouter librement des arguments dans chaque test d’interface ?
Des chaînes dispersées créent des fautes de nommage, des dépendances à l’ordre et des paramètres obsolètes. Un type partagé doit définir les noms, les valeurs acceptées et le comportement par défaut.
Comment vérifier qu’un crochet de test n’est pas inclus dans Release ?
Placez son implémentation derrière la compilation conditionnelle DEBUG, inspectez les réglages Release dans la CI, puis exécutez un démarrage à froid sans argument de test.
Déployez des workflows validés sur des nœuds physiques dédiés
Choisissez M4 ou M4 Pro, le nœud cible et la durée de facturation selon votre tâche, puis vérifiez la configuration et les options supplémentaires avant de commander.