Une application iOS enregistre des brouillons hors ligne dans SQLite. Après avoir vu « Enregistrement réussi », l’utilisateur constate pourtant que l’application plante lors de l’écriture suivante. Au redémarrage, le brouillon est-il toujours là ? Sur un simulateur isolé hébergé sur un Mac dans le cloud, on peut en faire un test reproductible : valider d’abord une ligne de référence, provoquer ensuite un crash pendant qu’une seconde transaction est encore ouverte, puis examiner directement la base de données. Ce test porte sur les limites des transactions, pas sur la vitesse de compilation. Le simple fait que l’application redémarre ne remplace pas la vérification des données.
Définir le résultat attendu
Préparez une table de test comportant uniquement les colonnes id et phase. La ligne 1|committed doit être validée avant le crash ; la ligne 2|pending doit être écrite dans une nouvelle transaction, sans que celle-ci soit validée. Après la reprise, le contrôle d’intégrité doit renvoyer ok et la requête ne doit retourner que la première ligne. Si la seconde apparaît aussi, vérifiez d’abord que le crash n’a pas été déclenché après la validation. Si la première a disparu, vérifiez qu’elle a bien été écrite dans le même fichier de base de données.
Le mode WAL ne garantit pas qu’« aucune écriture ne sera jamais perdue ». Ce test vérifie seulement qu’une transaction validée reste lisible après ce crash du processus et qu’une transaction non validée ne devient pas une donnée enregistrée. Il ne remplace pas les tests de coupure de courant, de défaillance du stockage ou de synchronisation métier.
Utilisez un simulateur jetable et un identifiant de bundle de test. Fixez d’abord la version de Xcode et le runtime du simulateur, puis consignez la sortie de xcodebuild -version et l’identifiant du simulateur. Réutilisez les mêmes conditions à chaque exécution. N’injectez pas de crash sur votre simulateur de développement habituel ni dans une base contenant de vraies données utilisateur.
Provoquer le crash à la frontière de transaction
Réservez ce point d’injection aux builds de débogage et exigez un argument de lancement explicite pour éviter son déclenchement lors d’une exécution ordinaire. La fonction ci-dessous suppose que l’appelant a déjà ouvert Documents/Recovery.sqlite et que le résultat de chaque appel SQL est vérifié. Dans cet exemple, precondition fait échouer immédiatement la préparation si elle rencontre une erreur, au lieu de laisser le test produire des résultats trompeurs.
#if DEBUG
import Darwin
import SQLite3
func runRecoveryProbe(_ db: OpaquePointer) {
func execute(_ sql: String) {
precondition(sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK)
}
execute("PRAGMA journal_mode=WAL")
execute("CREATE TABLE IF NOT EXISTS probe (id INTEGER PRIMARY KEY, phase TEXT NOT NULL)")
execute("INSERT OR REPLACE INTO probe VALUES (1, 'committed')")
execute("BEGIN IMMEDIATE")
execute("INSERT OR REPLACE INTO probe VALUES (2, 'pending')")
abort()
}
#endif
N’appelez cette fonction que si les arguments de lancement contiennent le marqueur dédié au test. Le point d’entrée du test doit d’abord confirmer qu’il utilise la base expérimentale et qu’aucune transaction antérieure n’est restée ouverte. PRAGMA journal_mode=WAL doit être exécuté hors transaction. abort() termine anormalement le processus : il n’y a donc volontairement aucun COMMIT après cet appel. Si le projet utilise une couche d’accès à la base de données, établissez les mêmes limites de transaction à travers cette couche. Ne la contournez pas en prétendant ensuite avoir validé le chemin d’écriture utilisé en production.
Éviter la contamination par d’anciennes données
Avant chaque exécution, réinitialisez les données de l’application de test sur le simulateur jetable, ou installez-la sur un nouveau simulateur dédié. Ne vous contentez pas d’exécuter DELETE FROM probe en laissant les autres tables et fichiers métier en place. Avant le premier lancement, confirmez que la base se trouve bien dans le conteneur de données de l’application. Consignez son chemin, l’identifiant du build et celui du simulateur avant et après l’injection, sans copier de données réelles du projet dans le conteneur expérimental.
Examiner la base après le crash
Après avoir lancé l’application, confirmez qu’elle s’est terminée anormalement à cause du crash prévu avant d’interroger la base. Le retour de la commande de lancement ne suffit pas à prouver que le crash a eu lieu : l’application n’a peut-être pas encore atteint le point d’entrée du test. Recoupez l’état du processus dans le simulateur avec le rapport de crash. Sans preuve de crash, classez cette exécution comme « test non terminé », et non comme réussie.
Une fois la sortie du processus confirmée, lisez le conteneur de données de l’application depuis le terminal du Mac dans le cloud. Remplacez les variables par l’identifiant du simulateur utilisé pour cette exécution et par l’identifiant du bundle de test :
UDID="your-simulator-udid"
BUNDLE_ID="com.example.RecoveryProbe"
DATA="$(xcrun simctl get_app_container "$UDID" "$BUNDLE_ID" data)"
sqlite3 "$DATA/Documents/Recovery.sqlite" \
'PRAGMA integrity_check; SELECT id, phase FROM probe ORDER BY id;'
La sortie attendue est, dans cet ordre, ok puis 1|committed. Avant le contrôle, ne supprimez pas les fichiers -wal et -shm situés à côté de la base et ne les déplacez pas séparément : laissez SQLite ouvrir la base telle quelle. Ne lancez pas non plus, pendant le contrôle, un processus de l’application qui écrirait à nouveau. Si le projet possède plusieurs bases de données, vérifiez d’abord le nom du fichier et le chemin du conteneur pour ne pas confondre une requête sur le mauvais fichier avec un échec de reprise.
Classer les échecs pour en trouver la cause
Si la sortie contient 2|pending, cherchez en priorité une validation automatique, une transaction terminée trop tôt par la couche d’accès ou un marqueur de crash qui a emprunté un autre chemin de code. Si 1|committed manque, confirmez que la première écriture a réussi et que le test s’est exécuté sur la même connexion et dans le même fichier. Si la requête indique que la table n’existe pas, vérifiez d’abord que le build installé contient le point d’entrée du test et que le chemin du conteneur ne renvoie pas au simulateur de l’exécution précédente.
integrity_check n’est pas une assertion métier : même s’il renvoie ok, il faut vérifier les lignes une par une. À l’inverse, un rapport de crash prouve seulement que le processus s’est terminé anormalement, pas que la reprise des transactions est correcte. Conservez ces deux types de preuves avec la version de Xcode, l’identifiant du simulateur et celui du build de test : vous disposerez ainsi de conditions comparables pour reproduire un échec.
Préserver les limites du test dans la CI
Réservez un simulateur à ce test. Une fois l’injection terminée et la sortie du processus confirmée, lancez l’étape de vérification en lecture seule. Ne partagez pas le même conteneur de données avec les tests UI ordinaires : des écritures parallèles modifieraient les résultats observés. Le point d’injection ne doit exister que dans les builds de débogage, pas dans les builds de publication ; la CI doit le vérifier explicitement.
Effectuez enfin une exécution de contrôle sans injection de crash : validez la seconde ligne par le chemin normal et confirmez qu’elle est lisible. Vous vérifierez ainsi à la fois que l’écriture non validée est annulée et que le test ne bloque pas toutes les écritures. Ces deux séries de résultats transforment un simple redémarrage observé une fois en test de persistance vérifiable.
Questions fréquentes
Une fermeture normale de l’application suffit-elle ?
Non. Elle peut valider la transaction ou déclencher un nettoyage. Il faut interrompre le processus avant la validation et confirmer le crash.
Faut-il supprimer les fichiers -wal et -shm avant le contrôle ?
Non. Conservez tous les fichiers, puis ouvrez la base avec SQLite pour contrôler son intégrité et ses lignes.
Lancez votre prochaine compilation Xcode sur un Mac physique dédié.
Choisissez votre modèle Apple Silicon, votre région et la durée de location, puis exécutez votre propre processus de compilation à distance. Les disponibilités sont celles indiquées dans la console au moment de la commande.