Dès qu’un build distant dépend d’une véritable API de test, un même commit peut réussir le matin et échouer l’après-midi : les données de test ont été modifiées, le jeton a expiré, la passerelle limite le débit ou l’API est simplement plus lente pendant un moment. Plus problématique encore, l’échec ne laisse souvent qu’une erreur de décodage, sans permettre de savoir ce que le client a réellement reçu. La solution ne consiste pas à multiplier les nouvelles tentatives, mais à séparer la validation de la couche réseau en deux catégories : la majorité des tests de régression utilisent des réponses locales déterministes, tandis qu’un petit nombre de tests de contrat interrogent l’environnement réel.
Définir clairement le périmètre des tests hors ligne
URLProtocol s’intègre au chemin de traitement des requêtes de URLSession et peut renvoyer une réponse personnalisée avant que la requête ne soit réellement envoyée. Il permet de vérifier quatre points : la méthode et le chemin de la requête, l’intégrité des en-têtes et du corps, le décodage des modèles, ainsi que la conversion des codes d’état en erreurs explicites par la couche métier.
En revanche, il ne prouve pas que le service réel est actuellement accessible et ne détecte pas les changements de configuration de la passerelle. Il est donc recommandé de structurer les tests par niveaux :
| Niveau | Source des données | Vérification principale | Fréquence d’exécution |
|---|---|---|---|
| Régression unitaire | Fixtures JSON locales | Décodage et conversion des erreurs | À chaque commit |
| Régression de la couche réseau | URLProtocol | Cycle complet requête-réponse | À chaque commit |
| Tests de contrat | API de test contrôlée | Contrats des champs et de l’authentification | Périodiquement ou avant une release |
L’objectif des régressions hors ligne est d’éliminer les variations sans rapport avec le code, et non de simuler une requête qui réussit systématiquement. Les réponses 404, 429 et 500, les réponses vides et les documents JSON corrompus doivent tous devenir des cas de test déterministes.
Injecter une URLSession indépendante
N’enregistrez pas le protocole globalement dans les tests et ne laissez pas le code métier accéder directement à URLSession.shared. Injectez une session dans APIClient : utilisez la configuration par défaut en production et une configuration ephemeral dans les tests. Vous éviterez ainsi d’hériter du cache disque, des cookies ou de l’état d’anciennes connexions.
final class StubURLProtocol: URLProtocol {
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool {
request.url?.host == "api.test.invalid"
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
guard let handler = Self.handler else {
client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
return
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
func makeTestSession() -> URLSession {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [StubURLProtocol.self]
configuration.timeoutIntervalForRequest = 3
return URLSession(configuration: configuration)
}
canInit doit limiter l’interception à un hôte ou à un en-tête de requête personnalisé. S’il renvoie systématiquement true, d’autres URLSession du processus de test risquent également d’être interceptées, ce qui compliquera le diagnostic des erreurs.
Gérer les fixtures de réponse comme du code
Il est recommandé de placer les fixtures dans Fixtures/API/v1/ au sein de la Target de test, puis de les nommer selon la ressource et le scénario, par exemple projects-success.json, projects-empty.json et projects-malformed.json. N’enregistrez pas directement des captures complètes contenant des jetons dynamiques, des adresses e-mail ou des adresses internes.
Avant chaque commit, vous pouvez trier les clés de manière uniforme et valider la syntaxe :
find Tests/Fixtures -name '*.json' -print0 |
while IFS= read -r -d '' file; do
tmp="${file}.tmp"
jq -S . "$file" > "$tmp" && mv "$tmp" "$file"
done
Même les scénarios de réussite doivent vérifier la requête elle-même. Contrôler uniquement le modèle final peut masquer des erreurs telles qu’un GET remplacé par POST, l’oubli d’un en-tête de version ou le double encodage des paramètres de requête.
StubURLProtocol.handler = { request in
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/v1/projects")
XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json")
let data = try Data(contentsOf: fixtureURL("projects-success"))
let response = HTTPURLResponse(
url: try XCTUnwrap(request.url),
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"]
)!
return (response, data)
}
Valider séparément chaque catégorie d’erreur
Couvrez au minimum les erreurs de transport, les erreurs HTTP et les erreurs de contenu. Une erreur de transport peut être simulée en lançant URLError(.timedOut) ; une erreur HTTP doit renvoyer un véritable code d’état et un corps d’erreur structuré ; une erreur de contenu doit renvoyer 200, mais avec un document JSON auquel il manque des champs, dont les types sont incorrects ou qui est tronqué. Dans la couche métier, ces trois catégories ne doivent pas toutes devenir la même « erreur inconnue ».
Pour tester les délais d’attente, n’attendez pas réellement plusieurs dizaines de secondes. Faites lancer directement l’erreur correspondante par le handler, puis vérifiez que le modèle de l’interface passe dans un état permettant une nouvelle tentative. Pour une réponse 429, vérifiez également que le client lit Retry-After, sans toutefois effectuer de véritable pause dans le test. Extrayez le calcul du délai de backoff dans une fonction pure, puis testez séparément ses entrées et ses résultats.
À la fin de chaque test, réinitialisez le handler à nil. Si la suite exécute les tests en parallèle, un unique handler statique peut être remplacé par un autre cas de test. Le point de départ le plus fiable consiste à exécuter cette suite en série. Si l’exécution parallèle est indispensable, utilisez une table associant les identifiants de requête aux closures de réponse, puis protégez ce registre avec un verrou ou un actor.
Exécuter les tests de manière stable sur un Mac cloud
Sur un Mac cloud DplyMini, commencez par fixer la version de Xcode sélectionnée, puis utilisez toujours le même workspace, le même scheme et le même plan de test. Une machine physique dédiée n’est pas une machine virtuelle, mais les tests doivent tout de même isoler explicitement DerivedData afin d’éviter que différentes branches ne partagent des artefacts intermédiaires.
set -euo pipefail
sudo xcode-select -s /Applications/Xcode.app
rm -rf "$PWD/.derived-data"
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-testPlan NetworkRegression \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-derivedDataPath "$PWD/.derived-data" \
-resultBundlePath "$PWD/TestResults/NetworkRegression.xcresult"
Avant la première exécution, utilisez xcodebuild -showdestinations pour vérifier les noms des simulateurs disponibles dans l’environnement courant. Ne copiez pas directement dans le script le nom d’un appareil provenant de votre machine personnelle. En cas d’échec, conservez le fichier xcresult, les journaux de test et la version correspondante des fixtures ; ne vous contentez pas des dernières lignes de la console.
La validation finale doit satisfaire quatre critères : la suite hors ligne se termine même après la coupure du réseau externe ; les résultats restent identiques d’une exécution à l’autre ; les modifications apportées aux fixtures sont clairement visibles lors de la revue de code ; l’échec des véritables tests de contrat n’empêche pas les développeurs de déterminer si la logique du client a régressé. Lorsque ces quatre conditions sont réunies, les tests réseau cessent réellement de « dépendre de la chance côté API » et deviennent des ressources d’ingénierie maintenables.
Questions fréquentes
Les tests URLProtocol remplacent-ils les tests sur une API réelle ?
Non. Ils couvrent précisément la création des requêtes, le décodage et la traduction des erreurs, mais quelques tests de contrat réels restent nécessaires pour le proxy, l’authentification et le schéma serveur.
Pourquoi injecter une URLSession dédiée dans le code testé ?
Une session ephemeral limite l’interception au composant testé et évite que les cookies, le cache ou un protocole enregistré globalement contaminent les autres tests.
Comment empêcher les tests parallèles d’écraser un handler partagé ?
Exécutez la version simple en série. Pour le parallélisme, indexez les réponses par requête et protégez le registre avec un verrou ou un actor.
Choisir la durée de location d’un Mac dans le cloud selon la durée de la tâche
Deux configurations M4 sont disponibles à la location à la journée, à la semaine, au mois ou au trimestre. Les nœuds et leur disponibilité réelle sont indiqués en temps réel dans la console.