Si les résultats d’une recherche modifient encore l’état après avoir quitté l’écran, ou si les requêtes se multiplient à chaque retour sur la page, le problème ne vient souvent pas de la lenteur du réseau, mais d’abonnements Combine qui restent actifs trop longtemps. Pour exécuter des tests de régression sur un Mac dans le cloud, commencez par remplacer le réseau réel par une source d’événements contrôlable. Déterminez précisément quand l’abonnement commence, quand il est annulé et quel objet doit être libéré, puis reconnectez le service réel. Vous pourrez ainsi reproduire les échecs de façon fiable, sans attendre de réponse distante.
Décomposer le contrat d’annulation
Une annulation recouvre au moins trois résultats distincts : l’aval ne reçoit plus de valeurs, l’amont reçoit un signal d’annulation et la tâche sous-jacente s’arrête. Les deux premiers se vérifient en testant le pipeline de publishers. Le troisième exige d’examiner l’implémentation concrète de la tâche : il ne peut pas se déduire de l’état de l’abonnement.
Définissez quatre critères d’acceptation pour l’écran : la valeur ne change plus après l’annulation ; des annulations répétées ne déclenchent le nettoyage qu’une seule fois ; le modèle de l’écran est libéré ; une ancienne requête ne peut pas écraser le résultat d’une nouvelle. Prévoyez un test distinct pour la terminaison normale, car une notification de fin n’est pas une notification d’annulation.
cancel n’est pas un retour arrière. Les modifications d’état déjà effectuées, les fichiers déjà écrits sur disque et les opérations déjà soumises au serveur ne sont pas automatiquement annulés.
Commencez par figer la chaîne d’outils Xcode et ajoutez les tests à la cible de tests unitaires XCTest existante. Le code ci-dessous ne dépend que de Combine et de XCTest : il ne nécessite ni le lancement de l’interface du simulateur ni d’autorisation d’accès au réseau.
Vérifier la propagation de l’annulation avec une source synchrone
Ajoutez le code suivant à la cible de tests. Le compteur utilisé ici est réservé aux tests : en production, n’utilisez pas de compteur global pour représenter l’état d’annulation. Le test envoie une première valeur, annule deux fois l’abonnement, puis envoie une autre valeur. Les trois assertions vérifient respectivement la réception, la propagation de l’annulation et l’absence de transmission après celle-ci.
import Combine
import XCTest
final class SubscriptionLifetimeTests: XCTestCase {
func testCancelStopsDeliveryAndPropagatesOnce() {
let source = PassthroughSubject<Int, Never>()
var received: [Int] = []
var cancellations = 0
let token = source
.handleEvents(receiveCancel: {
cancellations += 1
})
.sink { value in
received.append(value)
}
source.send(1)
XCTAssertEqual(received, [1])
token.cancel()
token.cancel()
source.send(2)
XCTAssertEqual(cancellations, 1)
XCTAssertEqual(received, [1])
}
}
Dans ce pipeline sans changement de scheduler, PassthroughSubject transmet les valeurs de manière synchrone. Les assertions peuvent donc suivre immédiatement les opérations. N’ajoutez pas de sleep pour « laisser le système traiter les événements » : cela ne ferait que masquer un ordre d’exécution mal défini.
Ce test valide le pipeline de l’exemple. Pour l’appliquer à votre propre service, transmettez la source d’événements au modèle métier par injection de dépendances, puis effectuez les mêmes assertions sur l’état exposé par ce modèle. Tester uniquement un pipeline sans lien avec le produit ne suffit pas.
Vérifier les relations de rétention, pas seulement cancel
Un cycle classique apparaît lorsque le modèle conserve un AnyCancellable et que la closure passée à sink conserve à son tour une référence forte au modèle. Compter alors sur la destruction du modèle pour libérer l’abonnement crée une attente circulaire. L’exemple suivant rompt ce cycle avec une capture faible et utilise une référence faible pour vérifier que l’objet est réellement libéré.
private final class ScreenModel {
var value = 0
private var token: AnyCancellable?
init(source: PassthroughSubject<Int, Never>) {
token = source.sink { [weak self] value in
self?.value = value
}
}
}
extension SubscriptionLifetimeTests {
func testModelReleaseCancelsSubscription() {
let source = PassthroughSubject<Int, Never>()
var cancellations = 0
let publisher = source.handleEvents(receiveCancel: {
cancellations += 1
})
var token: AnyCancellable?
weak var weakModel: ScreenModel?
do {
let model = ScreenModel(source: source)
weakModel = model
token = publisher.sink { [weak model] value in
model?.value = value
}
}
XCTAssertNil(weakModel)
token?.cancel()
XCTAssertEqual(cancellations, 1)
}
}
Ce deuxième test observe séparément la libération du modèle et l’annulation de l’abonnement externe, sans les confondre en une seule opération. L’abonnement interne est annulé lorsque le modèle est libéré. En revanche, le token externe reste détenu par le test et doit être annulé explicitement. Lors de l’intégration dans un projet réel, ajoutez un point d’observation de l’annulation de l’abonnement interne, puis vérifiez les deux abonnements séparément.
La capture faible n’est pas non plus une solution universelle. Si une tâche doit continuer après avoir quitté l’écran, confiez-la à un service dont la durée de vie est plus longue et laissez l’écran s’abonner uniquement à sa progression. Vous éviterez ainsi d’interrompre la tâche par erreur lors de la libération de l’écran.
Vérifier l’identité des requêtes avant les mises à jour asynchrones
Après l’annulation de la recherche A et le lancement de la recherche B, des traitements déjà placés dans une autre file d’exécution peuvent encore tenter de modifier l’état. Le simple fait d’avoir appelé l’annulation ne constitue pas une barrière garantissant la sûreté de cet état.
Attribuez à chaque requête un identifiant incrémental et vérifiez sa concordance avant d’appliquer le résultat. La modification de l’identifiant et l’écriture de l’état doivent se faire dans le même actor ou dans le même contexte d’exécution série. Fixez l’ordre du test : lancer A, lancer B, recevoir la réponse de B, puis celle de A. L’état final doit toujours correspondre à B.
Ajoutez aussi un scénario de sortie : lancer une requête, quitter l’écran, puis déclencher une réponse tardive et vérifier qu’aucun état visible n’est mis à jour. Si la fonctionnalité autorise la poursuite en arrière-plan, vérifiez séparément que l’état du service est mis à jour et que les callbacks destinés à l’écran ont cessé.
Lorsque receive(on:) intervient, validez d’abord le cœur synchrone, puis la frontière d’ordonnancement. Utilisez un scheduler contrôlable pour faire avancer la file. Si vous ne disposez pas de cette infrastructure, attendez un signal explicite de fin d’exécution : avoir attendu un certain temps ne permet pas d’affirmer qu’aucun événement tardif ne surviendra.
Construire des tests de régression dont les échecs sont traçables
Pour chaque test en échec, consignez l’identifiant de la requête, l’ordre des événements, le nombre d’annulations et l’état final, sans enregistrer de jetons de requête ni de données réelles d’utilisateurs. Ne modifiez qu’une condition à la fois : validez d’abord un abonnement unique, puis couvrez le réabonnement rapide, la terminaison normale, la terminaison en échec et la libération de l’écran.
Lors de la connexion à une tâche réseau réelle, vérifiez séparément que le callback d’annulation appelle bien le cancel de la tâche sous-jacente, ainsi que l’état final de cette tâche. L’absence de résultat renvoyé par le serveur ne suffit pas à prouver que l’annulation a réussi. Une course entre annulation et terminaison doit autoriser les ordres de terminaison conformes au contrat, mais jamais une mise à jour de l’état métier après la terminaison.
Enfin, effectuez une vérification en introduisant volontairement des défauts : retirer la capture faible doit révéler le cycle de rétention ; supprimer le contrôle de l’identifiant de requête doit faire échouer le test des réponses reçues dans le désordre. Ce n’est qu’en détectant ces erreurs injectées que les tests prouvent qu’ils protègent les véritables frontières du système, plutôt que de simplement faire passer le pipeline au vert.
Questions fréquentes
cancel arrête-t-il forcément la requête réseau ?
Non. Cela dépend de l’implémentation de l’éditeur. Vérifiez séparément que le chemin d’annulation annule la tâche sous-jacente et contrôlez son état.
Comment éviter les délais fixes dans les tests ?
Utilisez PassthroughSubject pour les événements synchrones. Pour les traitements asynchrones, préférez un ordonnanceur contrôlable ou un signal explicite de fin.
Louez un Mac cloud pour votre prochain build
Choisissez un modèle, une ville et une durée de location, puis utilisez un nœud physique dédié avec vos outils de développement. La disponibilité est indiquée en temps réel dans la console.