L’actualisation en arrière-plan fonctionne parfois sur une machine de développement, mais semble souvent « ne pas s’exécuter » une fois intégrée à la CI. Le problème vient généralement non pas du code de la tâche, mais des tests qui considèrent l’heure de planification du système comme une condition déterministe. C’est iOS qui décide quand réveiller le processus, et un Mac cloud ne peut pas transformer ce mécanisme en minuteur fixe. L’approche fiable consiste à séparer trois éléments : la configuration statique de l’enregistrement, la couche d’adaptation au planificateur et la logique métier qui effectue réellement la synchronisation ou le nettoyage.
Définir d’abord ce qui peut être vérifié
Une chaîne de traitement en arrière-plan comprend au minimum l’enregistrement, la soumission de la requête, le rappel système, l’exécution du travail, l’annulation à l’expiration et la remontée du résultat. L’intégration continue doit vérifier de manière stable les deux extrémités de cette chaîne, sans attendre que le système se déclenche « par hasard ».
| Couche | Vérifications automatisées | Ne doit pas servir d’assertion |
|---|---|---|
| Configuration | Identifiants, déclarations de capacités, configuration de la cible | Moment où le système réveille l’application |
| Adaptation | Réussite de l’enregistrement, transmission du rappel, état d’achèvement | Priorité de planification |
| Tâche | Sorties, erreurs, annulation, exécutions répétées | Niveau réel de batterie et habitudes d’utilisation |
L’objectif des tests de régression n’est pas de prouver que la tâche démarrera à une minute précise, mais de vérifier que, dès que le système la transmet, l’application peut l’achever correctement, l’annuler ou la relancer sans risque.
Commencez par fixer l’identifiant de la tâche, par exemple com.example.app.refresh. Il doit figurer à la fois dans le code d’enregistrement et dans BGTaskSchedulerPermittedIdentifiers. Si les différentes configurations de build génèrent des fichiers Info.plist distincts, inspectez le produit compilé plutôt que de lire uniquement le fichier source du dépôt.
Réduire le planificateur à une fine couche d’adaptation
N’écrivez pas directement la logique réseau, de base de données ou de cache dans le rappel de BGAppRefreshTask. Définissez d’abord une unité d’exécution remplaçable :
protocol RefreshJob {
func run() async throws
func cancel()
}
final class BackgroundRefreshAdapter {
private let job: RefreshJob
init(job: RefreshJob) {
self.job = job
}
func handle(task: BGAppRefreshTask) {
task.expirationHandler = { [job] in
job.cancel()
}
Task {
do {
try await job.run()
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
}
}
Injectez ensuite dans la tâche réelle le client réseau, l’interface de stockage et l’horloge. Les tests unitaires n’ont ainsi pas besoin de simuler BGAppRefreshTask : il suffit de vérifier les sorties produites par RefreshJob pour des entrées données. La couche d’adaptation reste concise et se limite à transmettre l’exécution, gérer l’expiration et signaler l’état d’achèvement.
Propager réellement l’annulation jusqu’à la couche la plus basse
Définir une simple variable booléenne ne suffit généralement pas. L’état d’annulation doit être vérifié entre le téléchargement, l’analyse et les écritures par lots. Avec la concurrence Swift, vous pouvez appeler Task.checkCancellation() à chaque limite d’étape. Pour les écritures en base de données, privilégiez les transactions courtes afin d’éviter qu’une tâche expirée conserve une transaction longue ou laisse des données partiellement enregistrées.
Mettre d’abord en place un contrôle de configuration statique
Les échecs les plus fréquents des tâches en arrière-plan proviennent d’un identifiant mal orthographié, d’une capacité absente de la configuration de la cible ou d’un test qui lit le mauvais plist. Inspectez directement le paquet de l’application après le build :
set -euo pipefail
APP_PATH="$BUILT_PRODUCTS_DIR/$WRAPPER_NAME"
PLIST="$APP_PATH/Info.plist"
TASK_ID="com.example.app.refresh"
plutil -extract BGTaskSchedulerPermittedIdentifiers raw "$PLIST" |
grep -Fx "$TASK_ID"
plutil -extract UIBackgroundModes raw "$PLIST" |
grep -F "fetch"
Le script doit s’exécuter dans une phase de build distincte et déclarer explicitement ses fichiers d’entrée afin d’éviter une exécution systématique à chaque build. Si vous utilisez des tâches de traitement, vérifiez également le mode d’arrière-plan correspondant. Le fait que deux tâches soient enregistrées par le même framework ne signifie pas que leurs déclarations sont identiques.
Le code d’enregistrement doit aussi produire un résultat observable. Au démarrage, consignez le résultat d’enregistrement de chaque identifiant dans les données de diagnostic internes de l’application, puis faites-les lire par les tests afin de vérifier que tous les enregistrements ont réussi. Les journaux doivent uniquement contenir l’identifiant de la tâche, l’étape et le type d’erreur, jamais de jeton d’accès ni le contenu complet d’une requête.
Couvrir la réussite, l’expiration et les exécutions répétées
La tâche métier nécessite au moins quatre groupes de tests. Le premier utilise une réponse fixe pour vérifier qu’après une exécution réussie, le curseur, le cache et l’heure de mise à jour sont validés de façon atomique. Le deuxième injecte une erreur pendant le téléchargement ou l’écriture et confirme que les anciennes données restent lisibles. Le troisième déclenche une annulation et vérifie que les fichiers temporaires sont supprimés sans mettre à jour l’indicateur de réussite. Le quatrième exécute deux fois de suite la même entrée et vérifie qu’aucun enregistrement en double n’est créé.
func testRepeatedRunIsIdempotent() async throws {
let store = InMemoryStore()
let client = StubClient(items: [.init(id: "42")])
let job = SyncRefreshJob(client: client, store: store)
try await job.run()
try await job.run()
XCTAssertEqual(store.items.map(\.id), ["42"])
XCTAssertEqual(store.commitCount, 2)
}
L’idempotence ne signifie pas que la deuxième exécution ne fait rien. Elle impose un état final identique et l’absence d’objets en double après des validations répétées. Si la tâche envoie des fichiers, utilisez une clé métier stable pour enregistrer l’état de soumission. Si elle repose sur un curseur de pagination, testez le point d’interruption où les données ont été écrites, mais où le curseur n’a pas encore été mis à jour.
Ne pas introduire d’attente par veille dans les tests
Évitez d’utiliser un sleep fixe pour estimer le délai d’achèvement d’une opération asynchrone. Extrayez l’horloge et la stratégie de temporisation dans des protocoles, puis utilisez pendant les tests une horloge que vous pouvez avancer manuellement. Toute interrogation périodique doit disposer d’une condition d’arrêt explicite et inclure l’étape en cours dans le message d’échec, au lieu de signaler uniquement un dépassement de délai.
Archiver des preuves vérifiables sur un Mac cloud
Commencez par répertorier les simulateurs disponibles sur le nœud actuel, puis sélectionnez un runtime déjà installé pour le projet :
xcrun simctl list devices available
xcodebuild test \
-scheme BackgroundTasks \
-destination 'platform=iOS Simulator,OS=latest,name=iPhone 16' \
-resultBundlePath artifacts/BackgroundTasks.xcresult
Le nom de l’appareil doit provenir d’une variable du pipeline afin d’éviter qu’une mise à jour de Xcode ne lie le script à un runtime inexistant. En cas d’échec, conservez le fichier xcresult, les journaux de test, les données de diagnostic de l’application et l’identifiant du commit utilisé. Ne téléversez pas uniquement les dernières dizaines de lignes de la console.
Pendant le débogage, les outils de débogage des tâches en arrière-plan de Xcode permettent de déclencher manuellement le rappel système. Cette possibilité sert uniquement à vérifier le câblage de la couche d’adaptation et ne doit pas devenir une dépendance du build de publication. La validation finale comporte deux niveaux : la CI vérifie de manière déterministe la configuration et la logique des tâches, tandis qu’un appareil contrôlé valide le cycle de vie réel après la remise de la tâche par le système. Consignez séparément les résultats de ces deux niveaux afin de pouvoir déterminer, en cas d’échec, s’il s’agit d’une régression de configuration, d’une erreur métier ou d’une attente incorrecte concernant le moment choisi par le système pour planifier la tâche.
Questions fréquentes
Le simulateur iOS peut-il garantir l’heure de lancement d’une tâche en arrière-plan ?
Non. Il permet de tester l’enregistrement, la logique, l’annulation et l’idempotence, mais le réveil décidé par le système ne constitue pas une assertion CI déterministe.
Quelle structure rend les tests BGTaskScheduler fiables ?
Un adaptateur BGTaskScheduler très mince délègue à un composant asynchrone. Les tests injectent directement l’horloge, les entrées, les erreurs et l’annulation.
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.