Fiche 14.06 — Tester l'async réel : spies et URLProtocol mock
Objectif
Vérifier l'interaction (qui a appelé quoi, avec quels arguments, combien de fois) avec des spies, tester la séquence d'états idle → loading → loaded/error, et intercepter le vrai réseau avec un URLProtocol custom pour tester ton APIClient sans serveur. Le tout en async, avec Swift Testing (confirmation) et XCTest (expectation).
1. Stub vs Mock vs Spy : la distinction qui compte en entretien
En UIKit comme en SwiftUI, un mock qui renvoie juste une valeur (un stub) teste le résultat. Un spy enregistre les appels pour tester l'interaction : "est-ce que le ViewModel a bien appelé logout() une seule fois ?".
Tu testes l'interaction quand le résultat seul ne prouve rien : envoi d'analytics, invalidation d'un cache, déconnexion, appel d'un endpoint précis.
2. Un spy qui enregistre les appels
Le protocole d'abord (tu fais déjà ça en UIKit avec tes protocols de service) :
Le spy enregistre callCount, lastArguments et la liste receivedMessages. Le suffixe convention : …Spy.
Réflexe UIKit : avant, tu mettais un var loginWasCalled = false à la main dans chaque mock. Ici on garde la même idée, mais structurée (callCount, lastArguments, receivedMessages) pour des assertions précises.
3. Tester l'interaction (pas seulement le résultat)
receivedMessages capture aussi l'ordre et l'absence d'appels parasites : si le ViewModel appelait logout() par erreur après un login, le tableau ne matcherait plus.
En XCTest, c'est la même logique :
XCTAssertEqual(spy.loginCallCount, 1).
4. Tester la séquence d'états idle → loading → loaded/error
Un piège classique : tu assertes l'état final (.loaded) mais tu ne vérifies jamais que .loading est bien passé. Avec un service à délai contrôlé, on capture chaque transition.
Le ViewModel moderne en @Observable (pas ObservableObject) :
Pour observer la transition, on enregistre les états dans une Task parallèle via withObservationTracking, ou plus simplement on snapshot l'état pendant que le service est suspendu. Le plus lisible : un service qui se met en pause sur un signal.
Pour le chemin erreur, même structure mais le service throw au lieu de renvoyer : tu assertes .loading puis .error(...).
5. URLProtocol mock : intercepter le vrai réseau sans serveur
C'est LA technique pro pour tester un APIClient basé sur URLSession. Tu enregistres un URLProtocol custom dans une URLSessionConfiguration éphémère : toutes les requêtes de cette session passent par ton code, qui renvoie data + statusCode + erreur à volonté. Aucun appel réseau réel.
La session de test, montée sur une config éphémère qui ne touche ni le cache ni les cookies :
6. Tester l'APIClient via URLProtocol
L'APIClient réel reçoit sa URLSession par injection (jamais URLSession.shared en dur — sinon intestable) :
Test du cas succès :
Test du cas erreur HTTP, sans changer une ligne de l'APIClient :
Réflexe UIKit : avant tu injectais peut-être une URLSession sous-classée ou un protocole NetworkSession maison. URLProtocol est plus propre car tu testes le vrai URLSession.data(from:) (donc le vrai décodage, le vrai mapping de statut), pas une couche que tu as réécrite.
7. Async en test : confirmation() et XCTestExpectation
Quand le code ne s'await pas directement (callback, notification, @Observable qui mute via une Task interne), il faut attendre un événement.
Swift Testing — confirmation (compte les occurrences attendues) :
XCTest — l'équivalent que tu connais, XCTestExpectation :
confirmation vérifie aussi le nombre d'appels (expectedCount:), ce qui en fait un mini-spy intégré : confirmation(expectedCount: 0) prouve qu'un callback n'est jamais appelé.
8. Quoi tester en priorité sur une app client
Tu n'es pas QA d'Apple. Ne teste pas que List affiche des lignes ou que NavigationStack pousse un écran — c'est le boulot d'Apple, et ces tests sont fragiles et sans valeur.
Points à connaître
URLProtocolMock.requestHandlerest statique et partagé : remets-le ànilentre les tests (ou utilise une session dédiée par test) pour éviter qu'un test pollue le suivant. Avec XCTest, fais-le entearDown.- Config
.ephemeralobligatoire : une config par défaut peut servir du cache et te donner un faux succès..ephemeralne persiste rien. - N'injecte jamais
URLSession.shareden dur dans tonAPIClient: sans injection,URLProtocolne peut pas s'insérer et le code est intestable. await Task.yield()≠ délai : il rend la main une fois pour laisser une autre tâche démarrer. Pour capturer un état.loading, c'est plus fiable qu'unTask.sleepqui introduit du flaky.- Spy ≠ stub : si ton test n'asserte que le résultat, un stub suffit. Sors le spy quand l'interaction est le comportement à prouver.
Exercice
Dans un projet de test, fais les deux :
- Écris un
AuthServiceSpyconforme àAuthServicing(login + logout) qui enregistreloginCallCount,lastCredentialsetreceivedMessages. Écris un@Testqui appelle unLoginViewModel, vérifie queloginest appelé une seule fois avec les bons identifiants, et qu'aucun logoutn'a eu lieu. - Écris un
URLProtocolMock+URLSession.mocked(), puis un@Testqui fait renvoyer un JSON valide en 200 àAPIClient.fetchUsers()et asserte le décodage, plus un second test qui renvoie un 404 et asserteAPIError.badStatus(404)viaawait #expect(throws:).
Faisable en 15-20 min en repartant des blocs des sections 2, 5 et 6.
Question d'entretien
Q : Comment testes-tu du code réseau sans appeler le vrai serveur ?
R : J'injecte une URLSession configurée avec un URLProtocol custom sur une URLSessionConfiguration.ephemeral. Le protocol intercepte chaque requête et renvoie data, statusCode ou erreur à la demande. Ça teste le vrai URLSession.data(from:), donc le vrai décodage et le vrai mapping d'erreurs, sans réseau, sans flaky, et en quelques millisecondes. Je remets le handler à nil entre les tests pour éviter les fuites d'état.
Q : Différence entre un stub et un spy ?
R : Un stub renvoie une valeur figée pour tester le résultat. Un spy enregistre les appels (callCount, arguments, ordre) pour tester l'interaction — par exemple prouver que le ViewModel a appelé logout() exactement une fois et rien d'autre. Un mock combine les deux.
Q : Comment vérifies-tu qu'un ViewModel passe bien par l'état .loading ?
R : Je rends le service contrôlable (il se suspend sur une continuation ou un signal). Je lance load() dans une Task, je fais await Task.yield() pour la laisser démarrer, j'asserte .loading pendant que le service est suspendu, puis je le débloque et j'asserte l'état final. Asserter seulement l'état final raterait une transition cassée.
Résumé
- Un spy enregistre
callCount/lastArguments/receivedMessagespour tester l'interaction, pas seulement le résultat. - Teste la séquence d'états (
loadinginclus) avec un service à délai/résultat contrôlé, pas juste l'état final. URLProtocolMock+ config.ephemeralintercepte le réseau : tu testes l'APIClientréel (data, statusCode, erreur) sans serveur.- Async sans
awaitdirect :confirmation(Swift Testing) ouXCTestExpectation(XCTest) ;confirmationvérifie aussi le nombre d'appels. - Priorise logique métier, mapping d'erreurs et ViewModels ; ne teste pas l'UI d'Apple.
- Toujours injecter la
URLSessionet réinitialiser le handler statique entre les tests.