NOTE D’INGÉNIERIE

Fragmenter les tests Xcode sur un Mac cloud et réutiliser la compilation

Fragmenter les tests Xcode sur un Mac cloud et réutiliser la compilation

Lorsqu’un projet iOS compte plusieurs milliers de tests unitaires et de tests d’interface utilisateur, une simple exécution de xcodebuild test enchaîne généralement la compilation, le démarrage du simulateur, l’installation de l’application et l’exécution de tous les tests. Si un échec survient dans les dernières minutes, relancer la commande recommence pourtant par la compilation. Sur un Mac cloud, il est plus efficace de scinder ce processus en deux étapes : produire d’abord une build testable, puis la réutiliser dans plusieurs fragments de test aux périmètres clairement définis.

Séparer d’abord la compilation de l’exécution

La commande build-for-testing de Xcode compile l’application, l’hôte de test et les bundles de test, puis génère un fichier .xctestrun décrivant l’environnement d’exécution. L’utilisation ultérieure de test-without-building évite à chaque fragment de résoudre à nouveau les dépendances et de recompiler le code source.

Commencez par fixer l’espace de travail, le Scheme, l’appareil cible et le chemin de DerivedData :

set -euo pipefail

DERIVED_DATA="$PWD/.ci/DerivedData"
RESULTS="$PWD/.ci/Results"
DESTINATION="platform=iOS Simulator,name=iPhone 16,OS=latest"

rm -rf "$DERIVED_DATA" "$RESULTS"
mkdir -p "$RESULTS"

xcodebuild build-for-testing \
  -workspace App.xcworkspace \
  -scheme App \
  -configuration Debug \
  -destination "$DESTINATION" \
  -derivedDataPath "$DERIVED_DATA" \
  CODE_SIGNING_ALLOWED=NO

find "$DERIVED_DATA/Build/Products" -name "*.xctestrun" -print

CODE_SIGNING_ALLOWED=NO ne convient qu’aux tests sur simulateur qui ne dépendent pas d’une signature pour appareil physique. Si la cible de test contient des composants qui doivent être signés, supprimez ce paramètre et laissez la configuration de signature du projet s’appliquer. Une fois la compilation réussie, ne déplacez pas DerivedData : le fichier .xctestrun peut contenir des chemins absolus qui y font référence.

La fragmentation résout un problème d’ordonnancement des tests, pas celui des états partagés entre eux. Si un test dépend de l’exécution préalable d’un autre, cette dépendance réelle apparaîtra après la fragmentation, même si les tests semblaient stables lors d’une exécution séquentielle sur une seule machine.

Définir des fragments stables avec des listes de tests

Évitez un découpage manuel en groupes d’« environ cent méthodes ». Les noms de méthodes changent fréquemment, ce qui entraîne une maintenance importante. Il est plus fiable de regrouper les tests par Target de test, classe de test ou domaine fonctionnel, puis de versionner les listes dans le dépôt.

Fragment Périmètre conseillé Tests adaptés
unit-core Couche de logique pure Transformation de données, validation, machines à états
unit-storage Couche de persistance Base de données, cache, migrations
ui-account Parcours liés au compte Connexion, réglages, écrans d’autorisations
ui-checkout Parcours de transaction Sélection des produits, confirmation et scénarios d’erreur

Les listes peuvent rester de simples fichiers texte :

AppTests/ParserTests
AppTests/SessionReducerTests
AppTests/ValidationTests

Lors de l’exécution, convertissez chaque ligne en un paramètre -only-testing:. Un identifiant de test prend généralement la forme Target/Class ou Target/Class/testMethod. Commencez par exécuter xcodebuild -list afin de vérifier le Scheme, puis validez les identifiants avec une commande ciblée. Vous éviterez ainsi qu’une faute de frappe conduise un fragment à n’exécuter aucun test.

Exécuter un seul fragment

XCTESTRUN="$(find "$DERIVED_DATA/Build/Products" -name '*.xctestrun' -print -quit)"

xcodebuild test-without-building \
  -xctestrun "$XCTESTRUN" \
  -destination "$DESTINATION" \
  -parallel-testing-enabled NO \
  -only-testing:AppTests/ParserTests \
  -only-testing:AppTests/SessionReducerTests \
  -resultBundlePath "$RESULTS/unit-core.xcresult"

Chaque fragment doit utiliser un resultBundlePath distinct. Réutiliser le même chemin écraserait les éléments de diagnostic et pourrait également amener plusieurs processus concurrents à se disputer le même répertoire.

Attribuer un simulateur indépendant à chaque tâche concurrente

Si deux processus xcodebuild ciblent simultanément le même simulateur, l’installation, le démarrage, l’état des autorisations et les données du presse-papiers peuvent interférer. Prévoyez un appareil dédié pour chaque emplacement d’exécution concurrente et ciblez-le par son UDID.

Commencez par afficher les environnements d’exécution disponibles :

xcrun simctl list runtimes
xcrun simctl list devicetypes

Après avoir récupéré l’identifiant de l’environnement d’exécution actuel, créez un appareil dédié :

xcrun simctl create \
  ci-shard-1 \
  com.apple.CoreSimulator.SimDeviceType.iPhone-16 \
  "$RUNTIME_ID"

Enregistrez l’UDID renvoyé dans une configuration d’exécution contrôlée. Avant chaque exécution, arrêtez l’appareil et réinitialisez son état :

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

Remplacez ensuite la destination par platform=iOS Simulator,id=$SIMULATOR_UDID. Pour les tâches qui nécessitent une langue, des autorisations ou des données de test prédéfinies, appliquez systématiquement cette configuration après erase au lieu de dépendre de l’état laissé par l’exécution précédente.

Ajuster le niveau de concurrence à la pression sur les ressources

Si vous lancez explicitement deux fragments tout en activant le parallélisme intégré de Xcode dans chacun d’eux, vous créez une concurrence imbriquée. Le nombre de simulateurs, de processus de test et de processus auxiliaires de compilation augmente alors simultanément, au point de pouvoir rendre l’exécution plus lente qu’en mode séquentiel. Pour une première version, utilisez -parallel-testing-enabled NO et n’ajoutez qu’un seul emplacement de fragmentation à la fois.

Surveillez au minimum trois catégories de signaux :

L’objectif ne doit pas être uniquement d’obtenir des fragments contenant le même nombre de tests. Une classe de tests d’interface qui redémarre souvent l’application peut être plus lente que plusieurs centaines de tests de fonctions pures. Vous pouvez ajuster les listes en fonction des durées observées sur les dernières exécutions du pipeline, mais évitez toute répartition aléatoire à l’exécution : elle compliquerait la reproduction des échecs et la comparaison des tendances.

Permettre la relance et la collecte de preuves pour chaque échec

Chaque fragment doit conserver son code de sortie, ses journaux de console et son propre fichier .xcresult. L’étape d’agrégation du pipeline peut échouer, mais l’échec du premier fragment ne doit pas entraîner la suppression des éléments de diagnostic des autres fragments. Pour relancer un groupe en échec, continuez à utiliser le fichier .xctestrun d’origine tout en choisissant un autre chemin de résultat :

xcodebuild test-without-building \
  -xctestrun "$XCTESTRUN" \
  -destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \
  -parallel-testing-enabled NO \
  -only-testing:AppUITests/CheckoutTests \
  -resultBundlePath "$RESULTS/ui-checkout-retry-1.xcresult"

Si le code source, les paramètres de compilation, la version de Xcode ou l’environnement d’exécution du simulateur changent, vous devez relancer build-for-testing. Les anciens produits ne peuvent pas servir de preuve pour un nouveau commit. En conservant les listes de tests, les journaux de compilation, les résultats de chaque fragment et les versions de l’environnement, vous pourrez déterminer si l’échec provient du code, de l’état des tests ou de l’environnement d’exécution.

L’essentiel de cette architecture n’est pas de dupliquer la commande de test, mais d’établir clairement trois responsabilités : les produits de compilation ne sont générés qu’une seule fois, chaque simulateur est réservé à un seul fragment et les bundles de résultats ne s’écrasent jamais. Une fois ces trois règles respectées, vous pouvez augmenter ou réduire le nombre de fragments et effectuer des relances ciblées sans sacrifier la reproductibilité.

Questions fréquentes

Pourquoi ne pas utiliser uniquement le parallélisme intégré de Xcode ?

Le parallélisme intégré répartit automatiquement les tests dans une invocation. Des fragments explicites facilitent la traçabilité, les résultats séparés et la relance ciblée.

Peut-on déplacer le fichier xctestrun vers un autre Mac ?

Pas sans validation. Les produits peuvent dépendre des chemins, de la version de Xcode et du runtime de simulateur ; ces éléments doivent rester compatibles.

VMDebug Mac physiques dans le cloud

Besoin d’un Mac mini physique dédié à une seule commande ?

Comparez les configurations M4 et M4 Pro, les quatre durées de location et les cinq nœuds disponibles, puis choisissez l’appareil adapté à votre workflow.

Choisir une configuration et commander