Comment tester que les objets sont correctement désalloués sans introduire de surcharge dans votre code de production
Les tests sont l’un de mes sujets de prédilection, ce n’est pas une nouvelle. Et j’aime rechercher et trouver de nouvelles façons d’améliorer la façon dont nous pouvons tester nos applications et nos bibliothèques.
L’une des choses les plus difficiles à tester dans iOS sont les cycles de référence. J’ai déjà a écrit sur le test des cycles de référence il y a un an et demi, mais cette solution n’était pas complètement satisfaisante : elle obligeait à polluer le code de production avec des fermetures et des observateurs.
Récemment, Michel Long a ressuscité ce poste avec d’autres suggestions sur la façon d’obtenir un résultat similaire. Je suis très reconnaissant pour ces suggestions car j’aime discuter de ces sujets avec la communauté iOS, mais ils ont tous deux souffert du même problème : ils nécessitent l’ajout de code supplémentaire pour les tests. Oui, vous pouvez les compiler en utilisant des pragmas de précompilateur comme #if DEBUG
mais ils rendront toujours le code moins lisible.
L’année dernière, merci aussi à Développeur Essentielj’ai appris une façon différente et raffinée de tester les cycles de référence et aujourd’hui je veux la partager avec tout le monde.
Un cycle de référence se produit lorsque deux objets, A et B, contiennent des références l’un à l’autre :
class A {
var b: B?
}class B {
var a: A?
}
// app
let a = A()
let b = B()
a.b = b
b.a = a
C’est un problème car, lorsque l’objet A doit être désalloué pour libérer de la mémoire, il ne peut pas le faire tant que B n’est pas également désalloué. Mais B ne peut pas être désalloué tant que A n’est pas désalloué. Par conséquent, un objet empêche l’autre d’être désalloué, et vice versa.
Le compilateur gère cette situation en ne libérant pas du tout la mémoire. Cette mémoire reste occupée pendant que l’application est en cours d’exécution, et c’est un fuite de mémoire. Si votre application alloue trop d’objets affectés par cette faille, elle peut occuper toute la mémoire disponible et le système la tuera.
Il est important de trouver un moyen de détecter les cycles de référence et de les résoudre dès que possible. Le test est le moyen le plus rapide de le faire.
Si l’exemple ci-dessus vous semble trop théorique, de sorte que vous pensez que cela ne peut jamais arriver dans le monde réel, je dois vous dire qu’il existe un modèle que nous utilisons quotidiennement qui souffre de ce problème, si nous ne l’implémentons pas correctement : le Déléguer motif.
Avec le pattern Delegate, on a généralement un objet A qui implémente un protocole DelegateA
et crée et conserve une référence à un objet B, en se définissant comme délégué.
UIKit utilise beaucoup ce modèle et nous utilisons les modèles délégués avec ViewControllers. En règle générale, lorsque le parent ViewController A présente l’enfant ViewController B, il se définit comme son délégué.
import UIKitprotocol ADelegate {}
class B: UIViewController {
// Wrong implementation. This creates a reference cycle!
var delegate: ADelegate?
// Right implementation. The weak var breaks the cycle
// weak var delegate: ADelegate?
}
class A: UIViewController, ADelegate {
func presentB() {
let b = UIViewControllerB()
b.delegate = self
self.present(b, animated: false)
}
}
Après avoir appelé le presentB
fonction, A contient une référence à B (stockée dans la UIViewController
classe) et B contient une référence à A, sous la forme de la ADelegate
protocole.
Pour les besoins des tests, nous utiliserons un exemple simplifié :
protocol ADelegate: AnyObject {}class B: UIViewController {
var delegate: ADelegate?
}
class A: UIViewController, ADelegate {
var b: B?
func presentB() {
let b = UIViewControllerB()
b.delegate = self
self.b = b
}
}
Cela simule le modèle de délégué, sans appeler le UIViewController.present
les fonctions. Cela nécessite un ensemble de travail supplémentaire pour le tester correctement, ce qui pourrait détourner l’attention du concept de base.
Tout d’abord, écrivons un test pour nous assurer que notre code se construit et se comporte correctement, malgré la fuite de mémoire. Nous voulons tester cela, quand presentB
est invoquée, une instance de B
est créé et il a le bon jeu de délégués.
Le test ressemble à ceci (notez que j’utiliserai SUT pour faire référence au Système sous test — la classe que nous voulons tester):
import XCTest
@testable import RefCycleAppfinal class RefCycleAppTests: XCTestCase {
func testExample() throws {
let sut = A()
sut.presentB()
let b = sut.b
XCTAssertIdentical(b?.delegate, sut) // => compares references
}
}
Si nous exécutons ce test, il réussit. La question est : les objets sont-ils correctement désalloués ?
Vous pouvez ajouter quelques print
déclarations dans le deinit
pour vérifier qu’ils sont appelés :
class B {
var delegate: DelegateA?+ deinit {
+ print("B deallocated!")
+ }
}
class A: DelegateA {
var b: B?
+ deinit {
+ print("A deallocated!")
+ }
func presentB() {
let b = B()
b.delegate = self
self.b = b
}
}
Relancez les tests et constatez que ça passe toujours mais qu’il n’y a pas de messages dans la console : les objets ne sont pas détruits. Vous pouvez vérifier cela en définissant des points d’arrêt dans le deinit
en exécutant les tests et en observant que l’exécution ne s’arrête pas dans les points d’arrêt.
Ajouter print
instructions est une technique de débogage efficace (et ancienne), mais vous ne pouvez pas inspecter manuellement le journal des tests, surtout si vous avez des centaines de tests dans votre application ou votre framework.
Vous voulez écrire des tests qui vous crient dessus quand quelque chose ne va pas. Comment pouvez-vous écrire un test qui vous indique quand un objet n’a pas été désalloué ?
L’idée est simple :
- attrape un
weak
référence à l’objet que vous testez. - vérifier que la référence est
nil
après l’exécution des tests (dans la méthode de démontage).
C’est ça. weak
référence sont automatiquement définis sur nil
lorsque l’objet vers lequel ils pointent est désalloué, et ils ne comptent pas comme des références lorsqu’il s’agit de compter les références pour la gestion de la mémoire.
Mettons à jour nos tests avec cette idée :
final class RefCycleAppTests: XCTestCase {
// 1. Define a weakSUT variable to track the SUT
weak var weakSUT: A?// 2. Define an helper method that creates the SUT and sets the weak reference
func prepareSUT() -> A {
let a = A()
self.weakSUT = a
return a
}
// 3. Write the tearDown method to assert that the weakSUT is nil
override func tearDownWithError() throws {
XCTAssertNil(self.weakSUT)
}
// 4. Update the test to use the helper method defined at 2
func testExample() throws {
let sut = prepareSUT()
sut.presentB()
let b = sut.b
XCTAssertNotNil(b?.delegate)
}
}
C’est ça. Récapituler:
- Dans la classe Test, nous définissons un
weak var
pour conserver la référence au SUT. - Nous définissons une méthode d’assistance qui initialise le SUT et le
weakSUT
a été. - Dans le
tearDown
méthode, nous affirmons que laweakSUT
var doit être nul. - Dans nos méthodes de test, nous initialisons le SUT à l’aide de la méthode d’assistance définie à l’étape 2.
Si nous exécutons le test maintenant, la méthode tearDown nous criera dessus :
Regardez que l’assertion nous indique également quelle méthode de test échoue et quel objet devait être nil
.
Nous pouvons maintenant corriger notre code de production et voir que tout passe :
class B {
- var delegate: DelegateA?
+ weak var delegate: DelegateA?deinit {
print("B deallocated!")
}
}
Si nous exécutons à nouveau le test, il réussit et les instructions d’impression sont affichées dans la console !
Maintenant, vous pouvez supprimer en toute confiance le deinit
avec les instructions d’impression du code, en le gardant propre et lisible.
Note: cette technique ne fonctionne que si vous créez le SUT dans toutes les méthodes de test avec une méthode qui initialise également la var faible (étape 4. ci-dessus).
Vous ne pouvez pas créer le SUT dans lesetUpWithError
méthode et affectez-la à une variable membre : si vous le faites, votre classe de test contiendra une référence à l’objet et elle ne sera jamais désallouée.
Aujourd’hui, j’ai partagé une technique différente pour tester les cycles de référence. Cette technique a pour principal avantage de ne pas polluer le code de production avec des surcoûts supplémentaires.
Cela pousse également le code de test à être plus isolé : le SUT doit être initialisé dans chaque méthode de test, ce qui garantit qu’il ne reporte pas les états sales des exécutions précédentes.
J’ai écrit le premier article sur les cycles de référence il y a un an et demi et depuis j’ai beaucoup appris et j’apprends continuellement de nouvelles choses. C’est une vérité importante de notre métier : nous avons toujours quelque chose à apprendre, indépendamment de notre titre et de notre ancienneté.
J’aime cet aspect de mon travail et j’espère que vous l’aimez aussi !