Ou comment j’ai atteint une couverture de code de 100 % dans Factory.
J’ai écrit un article précédent sur l’importance des tests unitaires dans une bibliothèque et je crois toujours que c’est une bonne pratique. Et encore plus lorsque cette bibliothèque est un morceau de code critique dont dépend toute une application.
La bibliothèque en question est Usineun système d’injection de dépendances sécurisé au moment de la compilation et un successeur de Resolver.
J’ai progressivement ajouté de plus en plus de tests à la bibliothèque depuis sa sortie plus tôt en 2022. Certains des tests ajoutés visaient à couvrir les défauts occasionnels trouvés ici et là. Et d’autres ont été ajoutés simplement pour augmenter la couverture du code.
Mais récemment, j’ai commencé l’effort final pour atteindre l’objectif légendaire d’une couverture de code à 100 % dans mes tests unitaires. Et cet objectif semblait à portée de main.
Jusqu’au point où j’ai atteint 99,2%… et un mur de briques.
La bibliothèque, voyez-vous, contient une branche de code qui se termine par une erreur fatale.
Chaînes de dépendance circulaires
Factory est, dans l’ensemble, sûr au moment de la compilation. Pour utiliser une usine, vous devez fournir à cette usine une fermeture qui produit le service ou la dépendance en question. Et pour résoudre une injection au moment de l’exécution, vous devez pointer vers cette usine pour obtenir ce dont vous avez besoin.
Ainsi, le code de résolution nécessite une usine, sinon il ne se compilera pas. La fermeture doit renvoyer le type correct. Ou il ne compilera pas.
extension Container {
static let myService = Factory { MyService() as MyServiceType }
}class ContentViewModel: ObservableObject {
@Injected(Container.myService) private var myService
...
}
Ce qui le rend sûr au moment de la compilation dans presque toutes les circonstances. Sauf un.
Récemment, j’ai ajouté une vérification pour détecter les chaînes de dépendance circulaires. Que se passe-t-il, par exemple, si vous écrivez le code suivant, puis essayez de créer une instance de CircularA
?
class CircularA {
@Injected(Container.circularB) var circularB
}class CircularB {
@Injected(Container.circularC) var circularC
}
class CircularC {
@Injected(Container.circularA) var circularA
}
Tentative de création d’une instance de CircularA
va aboutir à une boucle infinie. Pourquoi? Eh bien, le wrapper de propriété injecté de A a besoin d’un B pour construire un A. D’accord, très bien. Faisons-en un. Mais l’emballage de B a besoin d’un C, qui ne peut être fait sans injecter un A, qui encore une fois a besoin d’un B… et ainsi de suite. À l’infini.
Il s’agit d’une chaîne de dépendance circulaire.
Bien qu’il soit facile d’examiner le code ci-dessus et de voir ce qui ne va pas, dans les grands systèmes avec des centaines voire des milliers de dépendances, trouver la chaîne de dépendance problématique peut être intimidant.
Pire encore, au moment où ce code a été compilé et exécuté, il est trop tard pour faire quoi que ce soit à ce sujet. Swift nous demande de fournir une instance d’un objet que nous pouvait créer une fois que nous avons toutes ses dépendances… mais les initialiseurs de propriété de cet objet exigent que nous fournissions d’abord une instance de une autre objet, qui nous emmène dans la chaîne. Laver rinser. Répéter.
Tout ce que l’application peut faire à ce stade, c’est mourir.
Mais avec Factory, nous pouvons mourir gracieusement et, ce faisant, vider la chaîne de dépendances qui montre où se situe le problème.
2022-12-23 14:57:23.512032-0600 FactoryDemo[47546:6946786] Factory/Factory.swift:393:
Fatal error: circular dependency chain - CircularA > CircularB > CircularC > CircularA
Nous n’avons pas besoin de comprendre ce qui ne va pas en examinant la pile d’appels. L’usine nous le dira et tout ce que nous devons faire est de résoudre le problème.
Mais nous avons maintenant atteint le nœud du problème de la couverture du code. Parce qu’au fond de Factory, il y a un bloc de code qui ressemble à ça.
#if DEBUG
let typeComponents = String(describing: T.self).components(separatedBy: CharacterSet(charactersIn: "<>"))
let typeName = typeComponents.count > 1 ? typeComponents[1] : typeComponents[0]
let typeIndex = dependencyChain.firstIndex(where: { $0 == typeName })
dependencyChain.append(typeName)
if let index = typeIndex {
let message = "circular dependency chain - \(dependencyChain[index...].joined(separator: " > "))"
fatalError(message)
}
#endif
Si la vérification de dépendance circulaire est déclenchée pendant le test, la routine de résolution échoue avec un fatalError
informant le développeur que son code contient un problème et où le trouver.
Mais nous ne pouvons pas test ce code, car le fait de déclencher une erreur fatale plantera également et mettra fin aux tests unitaires !
Et donc il nous reste deux problèmes.
- Je suis presque sûr que le code est correct, mais je ne peux pas test que c’est correct.
- Et si je ne peux pas tester ce dernier morceau de code, je ne peux pas atteindre mon objectif maintenant trop obsessionnel de couverture de code à 100 % !
Donc, y a-t-il une solution?
Code X
On pourrait penser qu’une telle chose serait relativement courante et qu’Apple aurait fourni quelques petits sournois XCTSomethingOrOther
qui pourraient détecter des erreurs fatales lors de l’exécution de tests.
Hélas, rien ne semblait apparent. Et donc c’était parti pour…
Poser la question sur Reddit n’a pas été très utile. Plusieurs personnes ont suggéré d’utiliser Matt Gallagher’s CwlPreconditionTesting bibliothèque, tandis que quelques autres ont suggéré d’utiliser Agile.
Les deux semblent pouvoir potentiellement résoudre le problème, mais uniquement au prix de l’ajout d’une dépendance lourde à la bibliothèque… tout cela pour tester une seule ligne de code.
Il y avait sûrement un meilleur moyen.
J’ai donc décidé de demander…
ChatGPT a commencé à bavarder sur XCTAssertThrows
et XCTAssertNoThrow
qui, comme vous le savez sûrement, consiste à attraper et à tester des lancers des exceptions. Pas d’erreurs fatales.
Une fois corrigé à ce sujet, il m’a ensuite informé que je devrais essayer quelque chose comme fatalError = { didTriggerFatalError = true }
qui ne compile pas car vous ne pouvez pas affecter de fermeture à une fonction.
Et donc j’ai fait ce que j’aurais dû faire en premier, et j’ai vérifié…
Il y a plusieurs réponses au problème sur StackOverflow. L’idée de base derrière chacun d’eux est de fournir essentiellement votre propre local fatalError
routine qui masque les fatalError
une fonction.
Mais la plupart des solutions présentées souffrent de plusieurs problèmes.
La première est que la réponse la plus couramment recommandée nécessite que vous ajoutiez ce qui suit à votre code d’application.
// overrides Swift global `fatalError`
public func fatalError(_ message: @autoclosure () -> String = "", file: StaticString = #file, line: UInt = #line) -> Never {
FatalErrorUtil.fatalErrorClosure(message(), file, line)
unreachable()
}/// This is a `noreturn` function that pauses forever
public func unreachable() -> Never {
repeat {
RunLoop.current.run()
} while (true)
}
/// Utility functions that can replace and restore the `fatalError` global function.
public struct FatalErrorUtil {
// Called by the custom implementation of `fatalError`.
static var fatalErrorClosure: (String, StaticString, UInt) -> Never = defaultFatalErrorClosure
// backup of the original Swift `fatalError`
private static let defaultFatalErrorClosure = { Swift.fatalError($0, file: $1, line: $2) }
/// Replace the `fatalError` global function with something else.
public static func replaceFatalError(closure: @escaping (String, StaticString, UInt) -> Never) {
fatalErrorClosure = closure
}
/// Restore the `fatalError` global function back to the original Swift implementation
public static func restoreFatalError() {
fatalErrorClosure = defaultFatalErrorClosure
}
}
C’est beaucoup de code à ajouter à la bibliothèque Factory juste pour tester un seul flux d’exécution, en particulier lorsque Factory lui-même ne contient que 500 lignes de code. Mais un plus gros problème, de mon point de vue, est le unreachable
bloc de code qui tourne lorsqu’une erreur se produit lors des tests.
Ce code ne se termine jamais, ce qui signifie que j’ajoute une fois de plus du code qui ne sera jamais complètement exécuté, ce qui signifie à son tour que ma tentative d’atteindre une couverture de 100% échouerait. (Il laisse également des fils pendants, mais plus à ce sujet ci-dessous.)
Une autre solution éliminait le bloc inaccessible, mais nécessitait toujours l’ajout d’un gros morceau de code.
// overrides Swift global `fatalError`
func fatalError(_ message: @autoclosure () -> String = String(), file: StaticString = #file, line: UInt = #line) -> Never {
FatalErrorUtil.fatalErrorClosure(message(), file, line)
}/// Utility functions that can replace and restore the `fatalError` global function.
enum FatalErrorUtil {
typealias FatalErrorClosureType = (String, StaticString, UInt) -> Never
// Called by the custom implementation of `fatalError`.
static var fatalErrorClosure: FatalErrorClosureType = defaultFatalErrorClosure
// backup of the original Swift `fatalError`
private static let defaultFatalErrorClosure: FatalErrorClosureType = { Swift.fatalError($0, file: $1, line: $2) }
/// Replace the `fatalError` global function with something else.
static func replaceFatalError(closure: @escaping FatalErrorClosureType) {
fatalErrorClosure = closure
}
/// Restore the `fatalError` global function back to the original Swift implementation
static func restoreFatalError() {
fatalErrorClosure = defaultFatalErrorClosure
}
}
Ouais.
Mais après examen, j’ai déterminé que la quasi-totalité de ce code existe pour gérer une seule fermeture qui, par défaut, contient un appel à Swift fatalError
une fonction. La plupart de ce code pourrait être éliminé si je manipulais simplement la fermeture elle-même et supprimais les fonctions d’assistance (qui devraient également être exercées pour la couverture du code).
Un autre inconvénient de l’utilisation du mécanisme de fermeture signifiait que lors de l’exécution réelle, l’erreur fatale se produisait dans une partie du code éloignée de l’endroit où la vérification avait réellement eu lieu.
Je pourrais vivre avec ça, mais peut-être plus pertinent pour la discussion actuelle est que pendant les tests que la fermeture serait remplacée par une autre fermeture contenant le code de test… ce qui signifiait que la fermeture d’origine contenant le fatalError
ne serait jamais testé !
Ce qui aurait signifié, encore une fois, pas de couverture de code à 100 % ! Tout ce que nous avons fait, c’est jeter la canette sur la route.
Soupir. Mais… il y avait là le germe d’une solution. Comme je n’avais qu’un seul cas d’erreur fatale à tester, j’ai pu en fait éliminer le masquage fatalError
fonction et appeler la fermeture directement.
Et pour autant que que va, les fermetures et les fonctions sont des citoyens de première classe à Swift. On n’a pas vraiment besoin de la fermeture du tout !
C’était mon moment eurêka. Je pourrais réduire tout le code requis dans la bibliothèque à une seule ligne.
internal var triggerFatalError = Swift.fatalError
Où triggerFatalError
se voit attribuer une référence de fonction au gestionnaire d’erreurs fatales de Swift par défaut.
Comme je n’avais plus de fonction avec des paramètres par défaut, j’ai dû modifier légèrement le code sur mon site d’appel et passer les paramètres #file et #line.
if let index = typeIndex {
let message = "circular dependency chain - \(dependencyChain[index...].joined(separator: " > "))"
triggerFatalError(message, #file, #line) // call function variable
}
Cela semblait être un prix mineur à payer. Mais cela signifiait également que l’erreur se déclenchait à nouveau au point où l’erreur s’était produite !
Bien paraître !
Avec ma nouvelle référence de fonction et le changement de site d’appel en place, tout ce que j’avais à faire était d’ajouter la fonction de support nécessaire à ma cible de test unitaire.
extension XCTestCase {
func expectFatalError(expectedMessage: String, testcase: @escaping () -> Void) {
let expectation = self.expectation(description: "expectingFatalError")
var assertionMessage: String = ""triggerFatalError = { (message, _, _) in
assertionMessage = message()
DispatchQueue.main.async {
expectation.fulfill()
}
Thread.exit()
Swift.fatalError("will never be executed since thread exits")
}
Thread(block: testcase).start()
waitForExpectations(timeout: 0.1) { _ in
XCTAssertEqual(expectedMessage, assertionMessage)
triggerFatalError = Swift.fatalError
}
}
}
Il s’agit d’une modification du Réponse StackOverflow fourni par Andrew Hershberger et Mikhail Yaskou. La réponse la mieux notée sur la page était le code original « inaccessible » indiqué ci-dessus, qui a laissé un fil abandonné tourner pendant les tests.
La fonction d’Andrew et Mikhail commence à exécuter le scénario de test sur un nouveau thread qui, lorsque l’erreur fatale est déclenchée, est tué par Thread.exit
.
Les trois changements que j’ai apportés où définir le triggerFatalError
fonction d’interception directement, déplacez l’affectation de message qui s’échappe maintenant hors de la capture DispatchQueue
fermeture asynchrone et, enfin, réinitialisation triggerFatalError
pour Swift.fatalError
.
Avec mon code d’assistance maintenant en place, écrire le test était trivial. On donne simplement à la fonction fatalError
message attendu, puis fait tout ce que l’on fait pour déclencher l’erreur réelle.
func testCircularDependencyFailure() {
expectFatalError(expectedMessage: "circular dependency chain - RecursiveA > RecursiveB > RecursiveC > RecursiveA") {
let _ = Container.recursiveA()
}
}
J’ai exécuté le code, validé la vérification de dépendance circulaire et enfin récolté ma récompense.
Succès!
Si vous avez les yeux perçants, vous avez peut-être remarqué un peu de code supplémentaire à l’intérieur de la capture d’écran qui montre le triggerFatalError
message. Étant donné que l’erreur fatale n’était plus une erreur fatale, quelques éléments internes de l’usine devaient être réinitialisés afin que tous les cas de test exécutés après celui-ci démarrent avec une ardoise vierge.
Voilà donc le voyage. Ce serait bien si Apple construisait une sorte de mécanisme dans Xcode pour tester de telles choses, mais encore une fois, s’ils l’avaient fait, je l’aurais fait et vous ne liriez probablement pas cet article.
Espérons que l’astuce présentée ici vous aidera sur votre propre chemin vers une couverture de code à 100%. Alors profitez!
Comme toujours, n’oubliez pas d’applaudir plusieurs fois si vous aimez l’article et que vous voulez en voir plus. Cela aide vraiment à promouvoir et à maintenir la visibilité des articles sur Medium.
Et laissez des commentaires ou des questions si vous en avez.
Jusqu’à la prochaine fois.