Après la mise à jour d’une app iOS, l’appareil peut encore contenir un cache JSON écrit par une ancienne version. Le fait que la dernière réponse du serveur se décode correctement ne garantit donc pas que ce cache reste lisible. Un incident classique survient après l’ajout d’un champ à un modèle Codable : les tests couvrent la nouvelle réponse, mais l’erreur de décodage n’apparaît que lorsqu’un utilisateur ouvre une page hors ligne. La solution n’est pas d’ignorer silencieusement toutes les erreurs. Il faut conserver de véritables données historiques comme échantillons et définir quelles évolutions doivent rester compatibles et lesquelles doivent provoquer une erreur.
Définir les limites de compatibilité
Prenons un résumé de compte : id et status sont indispensables au fonctionnement de l’app, tandis que nickname est un champ facultatif ajouté plus tard. Le comportement attendu est le suivant : une ancienne réponse sans nickname reste lisible ; une nouvelle réponse contenant une clé encore inconnue du client reste lisible ; une réponse sans id échoue. Ces trois entrées doivent faire l’objet de tests distincts. Une seule « réponse valide » ne suffit pas à démontrer la compatibilité.
Dans une implémentation Decodable synthétisée, un String? accepte l’absence de la clé et prend la valeur nil ; l’absence d’un String non facultatif provoque une erreur. L’implémentation synthétisée ignore généralement les clés inconnues. Mais ces comportements ne concernent que le décodage : si l’interface force ensuite le déballage de nickname, l’ancien cache peut toujours provoquer une panne. Une fois les tests du modèle validés, vérifiez aussi que le code qui utilise ce champ prévoit un affichage de repli.
Inscrivez à la fois « savoir lire les anciennes données » et « rejeter les données endommagées » dans les tests. Ne vérifier que le premier point risque de masquer une véritable erreur de données sous couvert de compatibilité.
Conserver les anciennes réponses sous forme de fixtures
Créez un répertoire Fixtures dans la cible de test et enregistrez-y account-v1.json, account-v2.json et account-missing-id.json. Les noms décrivent la forme des données, pas la date d’une exécution de CI. Ajoutez les trois fichiers aux Copy Bundle Resources de la cible de test : les créer dans le répertoire du projet ne suffit pas pour les rendre accessibles à l’exécution.
account-v1.json conserve l’ancienne réponse : elle contient id et status, mais pas nickname. account-v2.json ajoute nickname ainsi qu’une clé absente du modèle actuel. La troisième fixture supprime id et sert de contre-exemple censé échouer. Préparez ces échantillons à partir de la structure de données réelles anonymisées : remplacez les informations personnelles et les jetons, tout en conservant les types des champs, les valeurs manquantes et les niveaux d’imbrication. N’écrasez pas les anciens fichiers à chaque évolution de l’API : les tests perdraient leur capacité à détecter les régressions.
Si le serveur remplace une chaîne par un nombre pour un champ, conservez cette nouvelle forme dans un fichier distinct et déterminez d’abord si l’app doit prendre en charge les deux formats. Ne généralisez pas une conversion permissive en chaîne à tous les champs uniquement pour faire passer les tests. Si la compatibilité est nécessaire, ajoutez une branche de décodage personnalisée explicite et les fixtures correspondantes.
Vérifier les trois résultats avec XCTest
Le test ci-dessous suppose que les fixtures ont été copiées à la racine du bundle de test. AccountSnapshot est déclaré séparément pour l’exemple ; dans un projet réel, utilisez le modèle de production concerné. Sinon, le test pourrait ne valider qu’une structure temporaire.
import Foundation
import XCTest
private struct AccountSnapshot: Decodable {
let id: String
let status: String
let nickname: String?
}
final class CodableContractTests: XCTestCase {
private func decode(_ name: String) throws -> AccountSnapshot {
let url = try XCTUnwrap(
Bundle(for: Self.self).url(
forResource: name,
withExtension: "json"
)
)
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(AccountSnapshot.self, from: data)
}
func testOldResponseRemainsReadable() throws {
let account = try decode("account-v1")
XCTAssertEqual(account.id, "example-001")
XCTAssertNil(account.nickname)
}
func testUnknownFieldDoesNotBreakDecoding() throws {
let account = try decode("account-v2")
XCTAssertEqual(account.id, "example-001")
XCTAssertEqual(account.nickname, "Tester")
}
func testMissingRequiredFieldFails() {
XCTAssertThrowsError(try decode("account-missing-id"))
}
}
Utilisez le même exemple d’id dans les deux fixtures lisibles pour que les assertions aient un sens précis. Pour l’instant, le test d’échec exige seulement qu’une erreur soit levée. Si le type d’erreur fait lui-même partie du contrat de traitement des données, vérifiez aussi DecodingError.keyNotFound et le nom du champ. Vous distinguerez ainsi le rejet attendu d’un champ manquant d’une fixture introuvable.
Ne présumez pas du comportement par défaut pour les dates et les énumérations
Si les fixtures contiennent des dates, vérifiez d’abord la valeur de JSONDecoder.dateDecodingStrategy réellement utilisée en production ; le test doit reprendre la même configuration. Si une valeur de statut représentée par une chaîne fixe s’étend à un nouveau membre d’énumération, décidez également s’il faut conserver les statuts inconnus et afficher un libellé générique, ou refuser de les traiter. Inscrivez cette décision dans le modèle et les fixtures, au lieu d’inventer une stratégie de décodage propre au test.
Exécuter le test dans une tâche sur Mac cloud
Commencez par vérifier le projet et le scheme partagé avec xcodebuild -list, puis récupérez l’identifiant d’un appareil dans la liste des simulateurs disponibles. Les variables d’environnement de la commande suivante doivent être fournies par votre tâche CI ; renseignez le nom de la cible de test dans TEST_TARGET. L’identifiant de l’appareil évite toute ambiguïté entre des simulateurs portant le même nom :
: "${PROJECT:?Set PROJECT}"
: "${SCHEME:?Set SCHEME}"
: "${SIM_ID:?Set SIM_ID}"
: "${TEST_TARGET:?Set TEST_TARGET}"
xcodebuild \
-project "$PROJECT" \
-scheme "$SCHEME" \
-destination "platform=iOS Simulator,id=$SIM_ID" \
"-only-testing:$TEST_TARGET/CodableContractTests" \
test
Sur un Mac cloud CDNMac, vérifiez d’abord que la version de Xcode choisie peut ouvrir le projet, que le scheme est partagé et que les ressources de test sont présentes dans le bundle. Exécutez ensuite la tâche. Il s’agit de tests du contrat de données : ils ne nécessitent ni éléments de signature ni appareil physique connecté. La CI doit respecter le code de sortie de xcodebuild : fixture absente, échec de décodage ou assertion incorrecte doivent faire échouer la tâche. Ne déduisez pas la réussite de mots-clés trouvés dans les logs.
Points à vérifier lors de la maintenance des fixtures
Lorsqu’une modification du modèle passe en revue de code, posez-vous ces questions une par une : l’ancien cache peut-il encore être lu ? Comment afficher un champ ajouté lorsqu’il manque dans les anciennes données ? Les clés inconnues doivent-elles être ignorées ? Où aboutit l’erreur si un champ obligatoire est endommagé ? Vérifiez ensuite que les tests utilisent bien le modèle et la configuration de décodage de production, et non une implémentation d’exemple qui leur ressemble seulement.
En cas d’échec, identifiez d’abord son origine. Si le fichier est introuvable, vérifiez son appartenance à la cible et sa présence dans le bundle. Pour keyNotFound, déterminez si le champ est réellement indispensable ou s’il s’agit d’un ajout facultatif. Pour typeMismatch, vérifiez si le type des données réelles a changé. Si une assertion échoue, examinez le sens métier des données décodées. Ne remplacez pas tous ces cas par try? et un objet vide. Les fixtures historiques conservées constituent une trace exécutable de ce que l’équipe s’engage encore à savoir lire.
Questions fréquentes
Faut-il conserver les anciens JSON après l’ajout d’un champ facultatif ?
Oui. Les caches existants ne contiennent pas ce champ ; la fixture vérifie que son absence donne nil sans faire échouer le décodage.
Un champ JSON inconnu doit-il faire échouer Codable ?
En général, non. Le décodage Decodable synthétisé ignore les clés non déclarées. Testez séparément l’échec dû à l’absence d’un champ obligatoire.
Lancez votre prochain build iOS sur un Mac dédié dans le cloud
Choisissez une configuration fixe avec M4, 16 Go de RAM et un SSD de 256 Go, parmi six nœuds et quatre durées de location, selon vos besoins.