Lorsqu’une même suite de tests de dates réussit en local, mais présente occasionnellement un décalage d’un jour sur un Mac cloud, XCTest n’est généralement pas le premier suspect. Le problème vient plus souvent du processus de build, du simulateur et de l’application, qui n’utilisent pas nécessairement le même fuseau horaire, la même Locale ou le même Calendar. Ces dépendances implicites ne se révèlent souvent qu’au passage à l’heure d’été ou d’hiver, en fin de mois ou autour de minuit.
Identifier d’abord les quatre types de dépendances temporelles
La gestion du temps ne se résume pas à « l’heure de la machine ». Un projet comporte au moins quatre niveaux d’état :
| Niveau | Sources courantes | Effets typiques |
|---|---|---|
| Hôte | Fuseau horaire du système, synchronisation réseau | Horodatage des journaux, scripts non isolés |
| Processus de build | TZ, LANG, LC_ALL |
Outils Shell, scripts de génération, processus de test |
| Simulateur et application | Environnement de lancement, réglages régionaux | Affichage des dates, calculs calendaires |
| Code métier | Date(), Calendar.current |
Calcul des échéances, statistiques par jour, comptes à rebours |
L’« heure actuelle » n’est pas une donnée de test stable. Dès qu’une assertion dépend de l’instant précis de son exécution, elle n’est plus reproductible.
Commencez par classer le type d’échec. Un décalage constant de plusieurs heures indique en priorité un problème de fuseau horaire. Un échec limité à certaines langues appelle une vérification de la Locale. Un problème qui apparaît en fin de mois, lors d’un jour intercalaire ou près d’un changement d’heure impose d’examiner Calendar et les opérations sur les dates. Enfin, un échec uniquement autour de minuit provient généralement de plusieurs lectures successives de Date() ou d’une mauvaise utilisation des limites du jour local.
Capturer la configuration de référence du nœud plutôt que diagnostiquer à l’intuition
Enregistrez l’environnement au début de chaque tâche, sans toutefois afficher les identifiants d’accès ni l’ensemble des variables d’environnement. Les commandes suivantes suffisent à établir une référence temporelle :
sw_vers
xcodebuild -version
date '+%Y-%m-%dT%H:%M:%S%z'
date -u '+%Y-%m-%dT%H:%M:%SZ'
sudo systemsetup -gettimezone
sudo systemsetup -getusingnetworktime
defaults read -g AppleLocale
defaults read -g AppleLanguages
locale
Archivez ces résultats avec la révision du commit et l’identifiant de la tâche. Pour diagnostiquer un incident, comparez une tâche réussie à une tâche en échec, au lieu d’examiner uniquement l’environnement de l’échec. MiniBin fournit des nœuds physiques dédiés, mais plusieurs builds peuvent tout de même s’exécuter en parallèle sur un même nœud. Les réglages globaux restent donc un état partagé entre les tâches.
Évitez d’appeler systématiquement systemsetup -settimezone dans chaque tâche. Cette commande modifie la configuration de l’hôte, et des tâches parallèles risquent d’écraser mutuellement leurs réglages. À moins que le nœud entier ne soit réservé à une seule charge séquentielle exigeant explicitement un fuseau local, privilégiez l’isolation au niveau du processus.
Établir une référence déterministe dans le processus CI
Le script de build peut imposer UTC et une locale stable pour le traitement des caractères, tout en transmettant la date du commit courant aux outils compatibles avec les horodatages reproductibles :
#!/bin/zsh
set -euo pipefail
export TZ=UTC
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)"
date -u '+build_started=%Y-%m-%dT%H:%M:%SZ'
xcodebuild \
-workspace App.xcworkspace \
-scheme App \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-derivedDataPath "$PWD/.derived-data" \
test
SOURCE_DATE_EPOCH n’est pas un réglage universel reconnu par toutes les étapes de Xcode. Il doit uniquement servir d’entrée aux scripts et aux outils de packaging qui prennent cette convention en charge. Il ne remplace pas l’injection d’une horloge dans l’application.
Gardez également à l’esprit que TZ=UTC ne s’applique qu’aux processus qui héritent de cette variable. Un simulateur ou un service en arrière-plan déjà lancé ne recharge pas automatiquement son environnement. Chaque tâche doit donc démarrer elle-même les processus nécessaires, puis les nettoyer à la fin, plutôt que de réutiliser une session persistante dont l’origine est incertaine.
Injecter l’horloge, le fuseau horaire et le calendrier dans l’application
Lorsque les appels à Date() et Calendar.current sont dispersés dans le code métier, il devient difficile de contrôler les cas limites pendant les tests. Une approche plus robuste consiste à encapsuler la notion de « maintenant » dans une dépendance :
protocol Clock {
var now: Date { get }
}
struct SystemClock: Clock {
var now: Date { Date() }
}
struct FixedClock: Clock {
let now: Date
}
En production, injectez SystemClock ; dans les tests, utilisez FixedClock. Une opération métier ne doit lire now qu’une seule fois afin de ne pas franchir une seconde, une minute ou minuit en cours d’exécution.
Les règles de calcul des dates doivent elles aussi être explicites. Pour les dates échangées avec un serveur, utilisez un format fixe, en_US_POSIX, le calendrier grégorien et UTC. Pour l’interface utilisateur, utilisez la Locale courante. N’effectuez jamais de décision métier à partir d’une date formatée en chinois ou en anglais. De même, ne remplacez pas « le prochain jour civil local » par un nombre fixe de secondes correspondant à 24 heures : un changement d’heure peut modifier la durée réelle d’une journée.
Couvrir les cas limites
Prévoyez au minimum les échantillons fixes suivants :
- un instant où la date UTC diffère de la date locale ;
- les fins de mois, les fins d’année et les jours intercalaires ;
- les périodes proches du début et de la fin de l’heure d’été ;
- les formats sur 12 heures et sur 24 heures ;
- les régions où la semaine commence le lundi ou le dimanche ;
- les Locale utilisant des chiffres non latins ou un ordre de date différent.
Écrivez directement ces échantillons sous forme d’entrées ISO 8601 comprenant le fuseau horaire, puis convertissez-les en Date. Ils ne doivent jamais dépendre du jour où les tests sont exécutés.
Isoler le simulateur et les tests d’interface
Les tests d’interface doivent distinguer le « temps métier » de l’heure affichée dans la barre d’état. Modifier la barre d’état permet uniquement de stabiliser l’apparence des captures d’écran ; cela ne change pas la valeur lue par l’application avec Date(). Pour figer réellement le temps métier, transmettez la valeur de test à l’application par l’environnement de lancement :
SIMCTL_CHILD_UITEST_FIXED_NOW='2026-07-23T12:00:00Z' \
xcrun simctl launch --terminate-running booted com.example.App
L’application ne doit lire UITEST_FIXED_NOW que dans un build de test. Si l’analyse de la valeur échoue, le test doit s’arrêter immédiatement plutôt que de revenir silencieusement à l’heure système. Ce point d’entrée ne doit pas être accepté dans les builds de production.
Chaque lot de tests doit également définir explicitement l’appareil, la version du runtime système, la langue et les réglages régionaux. Un test ne doit pas modifier la Locale globale puis laisser cet état au lot suivant. Si plusieurs régions doivent être couvertes, répartissez-les entre des tâches distinctes ou recréez l’état du simulateur au début de chaque lot. Les résultats seront plus faciles à interpréter que si les réglages sont modifiés plusieurs fois au cours d’une même session.
Intégrer les conditions temporelles aux critères de validation du build
La validation finale ne doit pas se limiter à constater que « les tests passent ». Vérifiez aussi que les journaux contiennent à la fois l’heure UTC et le décalage de fuseau, que les rapports d’échec enregistrent la Locale, le Calendar et l’heure fixe utilisée par le test, et qu’aucune tâche n’a modifié le fuseau horaire global de l’hôte.
L’ordre des contrôles peut être standardisé : capturer la configuration de référence, définir l’environnement du processus, démarrer une nouvelle session de test, injecter une horloge fixe, exécuter les cas limites, puis archiver le contexte. Si une dérive de date réapparaît, l’équipe pourra ainsi déterminer si l’écart vient de l’hôte, du processus, du simulateur ou du code métier, au lieu de relancer les tests jusqu’à obtenir un résultat favorable.
Questions fréquentes
Définir TZ=UTC suffit-il à corriger tous les tests liés aux dates ?
Non. La variable ne concerne que les processus qui en héritent. Le simulateur, une application déjà lancée ou un Calendar configuré explicitement peuvent conserver un autre comportement. Il faut aussi injecter une horloge fixe.
Faut-il imposer en_US_POSIX à tous les formateurs de date ?
Cette locale convient aux protocoles et aux valeurs destinées aux machines. Pour l’interface, utilisez la locale de l’utilisateur et injectez la locale attendue dans les tests.
Pourquoi ne pas changer le fuseau global du Mac dans chaque tâche CI ?
Des tâches parallèles peuvent écraser le même réglage et rendre le résultat dépendant de leur ordre. Une variable TZ limitée au processus et des dépendances injectées offrent une meilleure isolation.
Mac physique exclusif dans le cloud
Déployez votre environnement de développement reproductible sur un nœud physique exclusif
Choisissez une configuration, une durée de location et l’un des cinq nœuds pour les builds Xcode, les tests automatisés, le développement à distance ou l’inférence de modèles.