Tester une application visuelle
526 tests. Aucun ne regarde un pixel.
Comment teste-t-on un analyseur de disque ? Le treemap est visuel, le scanner touche au vrai système de fichiers, l’interface est un Canvas qui peint des pixels : rien de tout ça ne se prête au schéma classique « appeler une fonction, vérifier la valeur de retour ».
La réponse, pour Renala, a été de tout tester sauf les pixels. Mais le plus utile est de comprendre pourquoi chaque composant mérite l’investissement de test qu’on lui consacre. Chaque heure passée à écrire un test est une heure de moins pour construire des fonctionnalités. La question n’est pas « quel taux de couverture ? », c’est « où cette heure empêche-t-elle le plus de dégâts ? »
La carte des risques
Tester, c’est allouer du risque. Voici l’allocation de Renala, en treemap (délicieusement méta). Chaque rectangle est dimensionné par lignes de code, coloré par niveau de risque. La zone grise représente environ 40 % du code, n’a quasiment aucune couverture de tests unitaires : c’est précisément le but.
La zone rouge est minuscule. C’est aussi la seule zone où un bug signifie une perte de données définitive. Les chemins de code qui appellent FileManager.trashItem ont un dialogue de confirmation, une gestion d’erreurs par lot et une couverture de tests. La zone grise, les vues SwiftUI, est énorme. Les vues sont de fins wrappers autour des ViewModels. Un glitch de rendu est cosmétique ; un bug de suppression de fichier est catastrophique. On alloue en conséquence.
Tests d’algorithme : la disposition est-elle correcte ?
L’algorithme squarified est une fonction pure : étant donné une liste de tailles et un rectangle englobant, il renvoie une liste de rectangles. Pas d’effet de bord, pas d’état. C’est la partie de l’application la plus facile à tester.
graph LR
accTitle: Squarify function test properties
accDescr: The squarify function takes sizes and bounds as input and produces rectangles as output. Four properties are verified: areas proportional to sizes, aspect ratios bounded, deterministic results, and all rectangles inside bounds.
SIZES["[tailles]"] --> FN["squarify()"]
BOUNDS["[limites]"] --> FN
FN --> RECTS["[rects]"]
RECTS -.- P1["✓ Surfaces ∝ tailles"]
RECTS -.- P2["✓ Ratios d'aspect bornés"]
RECTS -.- P3["✓ Déterministe"]
RECTS -.- P4["✓ Rects dans les limites"]
Le déterminisme semble acquis, jusqu’à ce qu’un changement d’optimisation LLVM entre deux versions d’Xcode altère les résultats en virgule flottante pour le même code source.1 Une disposition qui dérive de quelques points après une mise à jour est une régression qu’aucun utilisateur ne signalera, mais que chacun remarquera.
SquarifiedTreemapTests contient 20 tests (9 de disposition, 11 de coussin et d’ombrage) qui vérifient les propriétés qu’une disposition squarified correcte doit satisfaire :
- Proportionnalité des surfaces : la surface de chaque rectangle en sortie est proportionnelle à la taille en entrée, aux tolérances de virgule flottante près.
- Bornes de rapport d’aspect : aucun rectangle n’a un rapport d’aspect pire qu’un seuil dérivé de la distribution des entrées.
- Déterminisme : la même entrée produit la même sortie, à chaque fois, d’une exécution à l’autre.
- Contenance dans l’englobant : chaque rectangle en sortie tient dans le rectangle englobant en entrée.
- Coefficients de coussin : les paramètres de coussin hérités des répertoires parents sont correctement accumulés à chaque niveau d’imbrication.
Voici le test de proportionnalité. Quatre fichiers, tailles connues, tolérance de 1 % :
@Test("Area proportionality: rect areas match file size ratios within 1%")
func areaProportionality() {
let root = makeFileNode(name: "root", size: 0, children: [
makeFileNode(name: "a.txt", size: 400),
makeFileNode(name: "b.txt", size: 300),
makeFileNode(name: "c.txt", size: 200),
makeFileNode(name: "d.txt", size: 100),
])
let bounds = CGRect(x: 0, y: 0, width: 1000, height: 1000)
let rects = layout.layout(node: root, bounds: bounds, minRectArea: 0)
#expect(rects.count == 4)
let totalArea = Double(bounds.width * bounds.height)
let totalSize: Double = 1000
for rect in rects {
let expectedFraction = Double(rect.fileNode.sizeOnDisk) / totalSize
let actualFraction = Double(rect.frame.width * rect.frame.height) / totalArea
let error = abs(actualFraction - expectedFraction) / expectedFraction
#expect(
error < 0.01,
"File \(rect.fileNode.name): expected \(expectedFraction), got \(actualFraction)"
)
}
}
Ces tests s’exécutent en millisecondes. Ils détectent des régressions invisibles à l’œil mais perceptibles pour un utilisateur qui remarque « ce dossier était plus gros que celui-là, avant ».
Tests du scanner : de vrais fichiers, pas de mocks
La correction du scanner tient à ce que l’OS renvoie réellement comme attributs de fichiers.2
Un mock testerait la logique de parsing contre ce que je pense que l’OS renvoie. Un vrai répertoire la teste contre ce que l’OS renvoie réellement.
Les protocoles servent de frontière de test et rendent tout cela praticable. Le scanner de production utilise getattrlistbulk. Les tests peuvent substituer un scanner plus simple dès que la logique de plus haut niveau ne dépend pas de la couche d’appels système :
public protocol DirectoryScannerProtocol: Sendable {
func scan(
root: URL,
options: ScannerOptions,
powerMode: ScanPowerMode, // bridé vs pleine vitesse
progress: @escaping @Sendable (ScanProgress) -> Void
) async throws -> ScanResult
}
Les tests du scanner créent de vrais répertoires temporaires avec de vrais fichiers, les scannent et vérifient l’arbre résultant. DirectoryScannerTests compte 31 tests, SpotlightScannerTests en ajoute 7, et les tests du volume manager en contribuent 22 de plus, couvrant :
- Cohérence de l’arbre : les relations parent-enfant correspondent à la structure du système de fichiers.
- Annulation : un scan peut être annulé en cours de parcours et le résultat partiel reste cohérent.
- Cohérence en parallèle : plusieurs scans concurrents du même répertoire produisent des arbres identiques.
- Fichiers cachés : les fichiers commençant par
.sont inclus ou exclus selon la configuration du scan.
Le bug des nœuds orphelins
Le test d’annulation a révélé un vrai bug. Un scan interrompu en plein parcours laissait des nœuds enfants orphelins dans le store, c’est-à-dire l’arbre à plat présenté dans l’article 9 : le parent déclarait N enfants, mais seuls certains étaient réellement peuplés. Invisible jusqu’à ce que la disposition suivante tente de lire des enfants jamais remplis.
Le correctif : nettoyer les orphelins avant de renvoyer les résultats partiels ; le test : vérifier la cohérence de l’arbre après chaque annulation. Voilà pourquoi les tests sur de vrais fichiers comptent. Un mock conçu pour simuler une complétion partielle aurait pu faire remonter ce bug, mais c’est la latence d’E/S réelle et le jitter de l’ordonnanceur ont fait émerger la condition de course sans que personne ne l’ait cherchée.
Le garde-fou de performance
Les tests de performance vivent derrière une variable d’environnement : RENALA_PERF_TESTS=1. Ils ne s’exécutent pas dans la CI par défaut : trop lents, trop dépendants du matériel.
Le test clé vérifie la linéarité O(n). SyntheticFixtureGenerator crée des arbres de 1 000 et 10 000 nœuds. Le test lance le scanner sur chacun, mesure le temps écoulé et confirme que le ratio reste sous 15× pour une entrée multipliée par 10 :
@Test("Scan scales linearly (1K → 10K files)")
func scanScalesLinearly() async throws {
let smallRoot = try SyntheticFixtureGenerator.createOnDisk(
fileCount: 1_000, folderCount: 10)
let largeRoot = try SyntheticFixtureGenerator.createOnDisk(
fileCount: 10_000, folderCount: 100)
defer {
try? FileManager.default.removeItem(at: smallRoot)
try? FileManager.default.removeItem(at: largeRoot)
}
let smallMs = try await SyntheticFixtureGenerator.measureMs {
_ = try await DirectoryScanner().scan(
root: smallRoot, options: ScannerOptions(), progress: { _ in })
}
let largeMs = try await SyntheticFixtureGenerator.measureMs {
_ = try await DirectoryScanner().scan(
root: largeRoot, options: ScannerOptions(), progress: { _ in })
}
let ratio = largeMs / max(smallMs, 0.01)
#expect(ratio < 15,
"Scan scales super-linearly: \(String(format: "%.1f", ratio))x for 10x input")
}
Un ratio de 10 serait parfaitement linéaire. Un ratio de 100 serait quadratique. Le seuil de 15× laisse de la marge pour les effets de cache et la variance d’une mesure unique, tout en détectant toute régression pire que O(n log n).3 La différence à grande échelle n’est pas académique :
L’interactif ci-dessous modélise exactement ce contrôle : une exécution fixe à 1K, une exécution fixe à 10K, et un échec net dès que le plus grand dépasse 15× le plus petit.
C’est un garde-fou, pas un benchmark. Les valeurs absolues dépendent du matériel ; la forme de la courbe, non.
Intégration : du scan à la disposition
ScanToTreemapIntegrationTests câble le pipeline complet : scanner un répertoire temporaire, passer le résultat au moteur de disposition, vérifier que les rectangles en sortie ont des surfaces proportionnelles aux tailles de fichiers en entrée.
graph LR
accTitle: Integration test: scanner to layout pipeline
accDescr: A temporary directory is scanned to produce a FileNode tree, which is laid out into a TreemapRect array. A verification step checks whether areas are proportional to file sizes. Failure indicates a type mismatch or off-by-one bug between layers.
TMP["Rép. temp."] -->|scan| TREE["Arbre FileNode"]
TREE -->|disposition| RECTS["Tableau TreemapRect"]
RECTS -->|vérifier| CHECK{"Surfaces ∝ tailles ?"}
CHECK -->|oui| PASS["✓"]
CHECK -->|non| FAIL["Incompatibilité de type ? Erreur de borne ?"]
Les bugs que ces tests attrapent : incompatibilités de type entre le scanner et le moteur de disposition, erreurs de borne dans le comptage d’enfants, gestion du nœud racine. Deux tests, juste assez pour couvrir la catégorie de bugs qui vit entre les couches.
Ce qui n’est PAS testé
La zone grise de la carte des risques ci-dessus. Les vues SwiftUI restent largement non testées : ni snapshot, ni régression visuelle, ni comparaison de captures d’écran, et plus de 30 fichiers de vues avec zéro couverture de tests unitaires. Cela ne signifie pas que l’UI est totalement ignorée : il existe quelques vérifications UI XCTest en bordure, pour certains workflows précis et quelques correctifs d’accessibilité. Elles comptent comme des tests. Elles ne font simplement pas partie du filet de sécurité rapide, celui qu’on exécute en routine, et cela ne constitue pas une couverture systématique des vues.
C’est un choix délibéré, pas un oubli. Les vues SwiftUI dans Renala sont minces : elles lisent le ViewModel et affichent. La logique vit dans le ViewModel et la couche modèle, tous deux testés. Le risque résiduel est réel : une vue mince peut toujours se lier à la mauvaise propriété ou inverser une condition. Mais une couverture large des vues exigerait soit XCUITest (lent, fragile, dépendant de la résolution), soit un framework de snapshot (maintenance permanente à chaque changement de rendu entre versions d’OS). Dans le contexte d’une application freeware maintenue en solo, ou par une très petite équipe, il était raisonnable de garder un peu de couverture supplémentaire en bordure sans prétendre que cela formait une vraie stratégie de tests UI.
Les cas d’erreur limites (permission refusée en cours de scan, modifications du système de fichiers pendant le scan, tampons d’attributs corrompus) sont partiellement couverts, sans viser l’exhaustivité. Le scanner les gère de façon défensive en production, mais la suite de tests ne fabrique pas chaque condition pathologique.
Les opérations de suppression de fichiers font exception. Regardez la zone rouge dans la carte des risques. Les chemins de code qui appellent FileManager.trashItem ont un dialogue de confirmation, une gestion d’erreurs par lot et une couverture de tests. Le coût d’un bug sur ce chemin n’est pas un mauvais chiffre à l’écran : c’est une perte de données.
Swift Testing vs XCTest
Renala utilise Swift Testing (import Testing, @Test, #expect) pour tous les nouveaux tests. Sur 54 fichiers de tests, 51 utilisent Swift Testing et 3 utilisent XCTest : un ratio de 94/6. Les trois irréductibles sont des fichiers de tests UI, parce que XCUIApplication n’a pas encore d’équivalent Swift Testing.
Ce partage dit plus sur la philosophie de test que sur l’outil. Le chemin rapide passe par Swift Testing via swift test : il tourne tout le temps et porte l’essentiel de la confiance. Les quelques tests UI restent dans XCTest parce qu’ils touchent des frontières que Swift Testing couvre mal aujourd’hui, et parce qu’une partie d’entre eux dépend de workflows plus lents, avec des fixtures, qu’on ne veut pas lancer en permanence. Ils apportent une couverture supplémentaire utile, pas l’ossature de la suite.
La différence en pratique :
// Swift Testing
@Test("Area proportionality within 1%")
func areaProportionality() {
let rects = layout.layout(node: root, bounds: bounds)
#expect(rects.count == 4)
#expect(error < 0.01, "Fraction mismatch")
}
// XCTest
func testColorModeDescriptionShownForFileType() throws {
let win = openDisplaySettings()
selectColoringMode("By File Type", in: win)
let desc = win.staticTexts.containing(
NSPredicate(format: "value CONTAINS 'category'")
).firstMatch
XCTAssertTrue(desc.waitForExistence(timeout: 3))
}
Swift Testing offre de meilleurs diagnostics d’assertion, des tests paramétrés via @Test(arguments:) et une syntaxe plus propre. La migration depuis XCTest a été mécanique pour les tests unitaires. Les tests paramétrés sont réellement utiles : on peut tester la disposition squarified sur plusieurs distributions d’entrées sans dupliquer les fonctions de test. XCTest reste pour les morceaux plus lourds, plus lents et moins centraux, ce qui est précisément le sens de cette allocation globale.
La thèse de la vélocité
graph TB
accTitle: Test coverage risk allocation by component
accDescr: Five tiers from most to least tested. Critical (red): file operations at 100% coverage. High (orange): scanner with boundary tests. Medium (amber): ViewModel async state machine. Low (green): pure algorithm and model functions. Untested by design (grey): thin SwiftUI view wrappers.
subgraph RED["🔴 Critique: couverture 100 %"]
F["Opérations fichiers<br/>corbeille, suppression, déplacement"]
end
subgraph ORANGE["🟠 Élevé: tests aux frontières"]
S["Scanner<br/>vrais fichiers, vrai OS"]
end
subgraph AMBER["🟡 Moyen: machine à états"]
V["ViewModel<br/>transitions asynchrones"]
end
subgraph GREEN["🟢 Faible: fonctions pures"]
A["Algorithme + Modèles<br/>déterministes, rapides"]
end
subgraph GREY["⚪ Non testé: par choix"]
U["Vues SwiftUI<br/>fins wrappers"]
end
RED --- ORANGE --- AMBER --- GREEN --- GREY
526 tests. Zéro pixel. Le treemap pourrait tout rendre à l’envers et les tests passeraient. C’est la lacune, et elle est acceptable.
Ces tests ne prouvent pas que l’application est correcte. Ils prouvent qu’elle ne s’est pas dégradée. Chaque commit passe devant une suite qui dit « l’algorithme produit toujours des rectangles proportionnels, le scanner construit toujours des arbres cohérents, la performance reste à peu près linéaire, et aucun fichier n’est supprimé sans confirmation ». Ça suffit pour livrer en confiance.
Références
- Meet Swift Testing : WWDC 2024
- Testing in Xcode : WWDC 2019
- Swift Testing documentation : Apple Developer Documentation
Footnotes
-
Swift n’active pas
-ffast-mathpar défaut : la reproductibilité bit-à-bit est la norme pour un niveau d’optimisation donné. Le risque est réel mais étroit : les mises à jour majeures du compilateur, pas les correctifs Xcode de routine. ↩ -
getattrlistbulkécrit les valeurs d’attributs dans un tampon brut avec un alignement sur 4 octets. L’appelant parcourt le tampon en avançant un pointeur à travers chaque attribut dans l’ordre défini par le bitmap. Un décalage erroné ou une longueur mal lue corrompt tous les attributs suivants. C’est le type de parsing où seul l’OS fait autorité. ↩ -
Le test exécute chaque charge une seule fois, sans moyenne. Le seuil généreux est le compromis : il absorbe le bruit de mesure au prix de ne pas détecter les régressions légères. Pour un garde-fou local, c’est acceptable. ↩
