Une page de liste accède parfois à un NSManagedObject devenu invalide. Tout fonctionne après des dizaines d’exécutions locales, mais la CI plante de manière aléatoire lors d’un enregistrement, d’une fusion ou d’un rafraîchissement de l’interface. Ce type de problème vient généralement non pas d’un fichier de base de données endommagé, mais d’un objet utilisé en dehors de la file de son NSManagedObjectContext. Plutôt que d’ajouter des tentatives, activez le débogage de la concurrence Core Data sur un Mac dans le cloud afin que tout accès incorrect échoue immédiatement, au plus près de son origine.
Délimiter d’abord le problème
La règle de base de Core Data est claire : un context associé à une file privée ne doit être utilisé que dans ses propres blocs perform ou performAndWait, tandis qu’un context associé à la file principale ne doit être appelé que depuis celle-ci. Un NSManagedObject ne peut pas non plus être transmis directement à une autre file. Pour un passage entre files, transmettez un NSManagedObjectID stable, puis rechargez l’objet dans le context cible.
Commencez par examiner les points d’entrée à risque :
- Un rappel réseau transmet directement un objet géré au thread principal pour mettre à jour l’interface.
- Un
Task.detachedcapture un context ou un objet géré. - Après un enregistrement en arrière-plan, un tableau d’objets est placé dans une notification ou une closure.
- Plusieurs tests partagent le même conteneur persistant sans attendre la fin de l’écriture asynchrone précédente.
- Pendant la fusion d’un context, l’interface conserve des objets qui ont déjà été supprimés ou réinitialisés.
Ne considérez pas
performcomme un simple wrapper asynchrone. Il protège le context et son graphe d’objets, pas uniquement une instruction d’enregistrement.
Activer le débogage de la concurrence
Ajoutez les deux arguments suivants aux arguments de lancement d’un Scheme réservé aux tests :
-com.apple.CoreData.ConcurrencyDebug
1
Pour les tests d’interface, il est préférable de les fournir explicitement depuis le code de test afin d’éviter toute divergence entre le Scheme local des développeurs et celui de la CI :
let app = XCUIApplication()
app.launchArguments += [
"-com.apple.CoreData.ConcurrencyDebug",
"1"
]
app.launch()
Si les tests unitaires classiques dépendent de l’application hôte, configurez le même argument dans le Scheme partagé et ajoutez le fichier .xcscheme au dépôt. N’intégrez pas cet argument à la configuration de production : lorsqu’une violation est détectée, le mécanisme de débogage interrompt volontairement le processus, ce qui correspond précisément au comportement recherché dans un environnement de test.
Après l’activation, commencez par exécuter le plus petit ensemble de tests possible. Lancer immédiatement toute la suite multiplie les tâches concurrentes, masque la première pile d’appels exploitable et augmente le coût d’analyse des journaux.
Transmettre des ID plutôt que des objets
Une erreur classique consiste à créer un objet dans un context d’arrière-plan, puis à transmettre directement cet objet à la file principale. La bonne approche consiste à l’enregistrer, à obtenir un object ID permanent, puis à le récupérer dans le context cible :
func importItem(
payload: Payload,
backgroundContext: NSManagedObjectContext,
viewContext: NSManagedObjectContext
) async throws {
let objectID = try await backgroundContext.perform {
let item = Item(context: backgroundContext)
item.title = payload.title
try backgroundContext.obtainPermanentIDs(for: [item])
try backgroundContext.save()
return item.objectID
}
try await viewContext.perform {
let item = try viewContext.existingObject(with: objectID) as! Item
item.lastViewedAt = Date()
try viewContext.save()
}
}
Trois points doivent être vérifiés ici : un ID permanent a bien été obtenu avant le retour ; le context cible résout de nouveau l’objet avec existingObject(with:) ; toutes les lectures et écritures de propriétés se trouvent dans la closure du context correspondant. Si seul un ID temporaire est transmis, le context cible risque de ne pas pouvoir le résoudre. Lire une propriété en dehors de la closure, même s’il ne s’agit que d’une chaîne sur une seule ligne, enfreint également les contraintes de file.
Concevoir un test reproductible
L’objectif n’est pas d’empiler des opérations concurrentes aléatoires, mais de figer le chemin qui provoque le conflit. Le scénario peut être découpé en quatre étapes : insertion en arrière-plan, enregistrement en arrière-plan, fusion dans le context principal et lecture sur la file principale. Utilisez à chaque fois un stockage persistant en mémoire distinct et attendez la fin de toutes les tâches avant de terminer le test.
| Point de contrôle | Signal d’échec | Correction |
|---|---|---|
| Écriture en arrière-plan | Accès au context hors de la closure | Rapatrier l’accès dans perform |
| Passage d’objet | La closure capture un objet géré | Transmettre un object ID |
| Notification de fusion | context source non identifié | Limiter la source des notifications d’enregistrement |
| Nettoyage du test | Les tests suivants reçoivent encore des notifications | Retirer les observateurs et détruire le conteneur |
N’utilisez pas un sleep fixe pour attendre un enregistrement. Établissez une condition réelle d’achèvement avec await, une expectation ou un rappel du conteneur persistant. Une attente fixe ne fait que déplacer la fenêtre de concurrence.
Pour un chemin intermittent, vous pouvez répéter le même petit test 20 à 50 fois, mais en définissant un délai d’expiration global. Ces répétitions servent à élargir la couverture temporelle ; elles ne doivent pas remplacer une synchronisation déterministe des états.
Conserver les premiers éléments de diagnostic dans la CI
Le job exécuté sur le Mac dans le cloud doit fixer la version de Xcode, la cible du simulateur et le répertoire des résultats. Le script ci-dessous conserve le journal complet et le bundle de résultats en cas d’échec :
#!/bin/zsh
set -euo pipefail
mkdir -p artifacts logs
rm -rf artifacts/CoreDataTests.xcresult
if ! xcodebuild test \
-workspace App.xcworkspace \
-scheme AppConcurrencyTests \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-resultBundlePath artifacts/CoreDataTests.xcresult \
> logs/coredata-tests.log 2>&1; then
grep -nE 'CoreData|Concurrency|NSManagedObjectContext' \
logs/coredata-tests.log || true
exit 1
fi
Le nom de l’appareil cible doit provenir des simulateurs réellement disponibles dans la version courante de Xcode ; ne supposez pas qu’il est identique sur toutes les images. Avant de publier les journaux, supprimez également les données métier, les jetons d’accès et les fragments sensibles issus des répertoires utilisateur. Le bundle de résultats, le journal complet, le nom du test en échec, l’identifiant du commit et les arguments de lancement doivent être archivés comme un même ensemble d’éléments de diagnostic.
Si le job doit exécuter des tests en parallèle, commencez par lancer séparément et en série la suite avec le débogage de la concurrence. Son objectif est d’identifier le premier accès hors limites, et non d’atteindre le débit maximal. Une fois sa stabilité confirmée, vous pourrez déterminer s’il convient de l’intégrer aux partitions de tests habituelles.
Mettre en place un contrôle de fusion peu bruyant
Il est recommandé de répartir le contrôle en deux niveaux. Le premier exécute uniquement les tests unitaires de la couche de données et quelques parcours d’interface essentiels, avec le débogage de la concurrence toujours activé. Le second exécute toute la régression, sans compter sur elle pour détecter les erreurs élémentaires de file. En cas d’échec du premier niveau, interdisez les nouvelles tentatives automatiques : une tentative réussie masquerait un défaut réel en le faisant passer pour une instabilité de l’infrastructure.
Avant la fusion, vérifiez également les points suivants :
- Le Scheme est partagé et placé sous contrôle de version.
- Les tests utilisent des stockages distincts et ne réutilisent pas la base de données de développement.
- Aucun context, objet ni fetched results controller n’est transmis entre des files.
- Les observateurs de notifications sont correctement retirés à la fin des tests.
- Les journaux et le fichier xcresult sont toujours téléversés en cas d’échec de la CI.
- Le commit de correction contient un test de régression qui échoue avec l’ancien code.
Une fois tous les passages entre files convertis pour transmettre des object ID, les conditions de fin asynchrones clairement définies et les éléments de diagnostic traçables, ces plantages « uniquement observables en production » deviennent de simples problèmes détectables lors de la revue de code.
Questions fréquentes
Faut-il activer ce diagnostic dans l’application publiée ?
Non. Le mécanisme interrompt le processus lorsqu’il détecte une violation. Il doit rester limité à une configuration de développement ou à une tâche CI dédiée.
Pourquoi un test classique peut-il ignorer une violation de concurrence ?
Il ne provoque pas toujours le bon ordonnancement. Répétez dans un même scénario l’écriture en arrière-plan, la lecture sur la file principale, le passage par objectID et la fusion des contextes.
Quels éléments faut-il conserver après un échec CI ?
Conservez le journal xcodebuild complet, le paquet xcresult, le nom du test, le commit et les paramètres de lancement. Ajoutez le rapport de plantage après suppression des données sensibles.
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.