Sous le bureau se trouve un Mac mini Apple M4 doté de 16 Go de mémoire, et son travail consiste à découvrir si nos applications fonctionnent encore. MailVault est construit avec Tauri. MeatPad est construit avec SwiftUI. Le mini exécute les deux suites : même machine, même après-midi, même personne pour écrire les tests.

Les deux suites ne coûtent pas la même chose. Elles ne coûtent même pas quelque chose d'approchant. Tout ce qui suit a été mesuré le 25 août 2026, et chaque commande est une commande que vous pouvez lancer vous-même.

Ce qui est passé au vert aujourd'hui

MailVault 2.10.2 · Tauri (Rust core + web frontend)

  unit and component   npm test               1,218 tests   86 files    2.28 s
  integration          npm run test:integration   64 tests    8 files    2.91 s
  core and daemon      npm run test:imap         158 tests               1.4 s
  end to end           npm run test:e2e           53 spec files
                         7 headless · 41 seeded with mock IMAP · 5 local only

MeatPad 0.13.0 · SwiftUI (AppKit, TextKit 2)

  MeatPadKit           swift test                601 tests   41 files   11.47 s
  interface            xcodebuild test            31 tests    5 files

Lisez les deux dernières lignes de chaque bloc l'une contre l'autre, car c'est là tout l'article. MailVault obtient 1 440 vérifications sans fenêtre en moins de sept secondes de temps réel, et il peut en plus piloter son propre binaire réel à travers 53 specs de bout en bout. MeatPad obtient 601 vérifications sans fenêtre en onze secondes, et 31 tests qui touchent l'interface réelle.

Vient ensuite le nombre qui n'entre pas dans un tableau. Exécuter huit de ces 31 tests d'interface sur le mini a pris 98,186 secondes. Les cas individuels se situaient entre 6,9 et 17,2 secondes. Disons douze secondes par test.

Executed 8 tests, with 0 failures (0 unexpected) in 98.186 seconds

Un test unitaire MeatPadKit sur la même machine prend en moyenne dix-neuf millisecondes. Un cas vitest de MailVault, moins de deux en moyenne. Un seul clic simulé sur une vue SwiftUI coûte environ six cents tests unitaires.

Août, en deux courbes

MailVault a passé le mois à se faire écrire des tests, et la forme de tout cela se voit facilement en comptant les cas de test du dépôt semaine après semaine :

MailVault, JavaScript test cases and end-to-end spec files

  1 March      440 cases
  1 August     850 cases     22 e2e specs
  8 August     972 cases     28 e2e specs
 15 August   1,111 cases     30 e2e specs
 22 August   1,393 cases     43 e2e specs
 25 August   1,604 cases     53 e2e specs

Le nombre de cas a presque doublé en vingt-cinq jours, et les specs de bout en bout ont plus que doublé. Cette croissance n'est pas répartie uniformément dans l'application. Elle est allée là où étaient les bogues : la rédaction et les brouillons, la sélection multiple, la matrice de stockage qui doit s'accorder sur ce qui se trouve sur le disque après chaque action modifiante, et le changement de compte. Les titres de commits de cette période se lisent comme une liste d'aveux, ce qui est la bonne façon de lire un journal de tests.

La courbe de MeatPad est plus courte, parce que l'application est plus jeune, et elle comporte une marche sur laquelle il vaut la peine d'être honnête :

MeatPad, Swift test functions

 18 July      327 tests      0 through the interface
 21 July      517 tests      0 through the interface
 19 August    548 tests      0 through the interface
 25 August    632 tests     31 through the interface

MeatPad est sorti en juillet avec 517 tests et pas un seul n'ouvrait de fenêtre. Le premier test d'interface a été écrit le 24 août, un mois après la sortie. Cet écart n'est pas de la paresse. C'est le sujet du reste de cet article.

Une archive d'e-mails que vous pouvez vraiment vérifier. MailVault sauvegarde et archive votre boîte aux lettres en local, et 53 specs de bout en bout pilotent l'application réelle avant que rien de tout cela ne vous parvienne.

Visitez MailVault

La différence qui explique toutes les autres

L'interface d'une app Tauri est une page web. Le harnais de test lui parle en WebDriver, ce qui signifie que le harnais peut exécuter du code à l'intérieur même de l'application en cours d'exécution :

const searchExists = await browser.execute(() => {
  const input = document.querySelector('input[placeholder*="Search"]');
  return input !== null;
});

Cette fonction fléchée n'est pas évaluée par le test. Elle est envoyée dans la webview de l'application, exécutée là contre le document vivant, et son résultat revient. Le test peut interroger l'interface exactement comme l'application elle-même le peut.

XCUITest ne peut pas faire cela et ne le pourra jamais. C'est un processus distinct qui pilote votre application depuis l'extérieur via la couche d'accessibilité de macOS. Il n'existe aucun canal pour du code arbitraire, aucun moyen de poser à une vue une question à laquelle l'arbre d'accessibilité ne répond pas déjà. Tout ce qui suit découle de cette seule frontière.

Dans un document, tout a une poignée

La suite de bout en bout de MailVault sélectionne des éléments 305 fois via des attributs d'identifiant de test. En ajouter un ne coûte rien et ne change rien au fonctionnement de l'application. Tout ce que l'application affiche est adressable, que quelqu'un l'ait prévu ou non.

MeatPad compte 34 identifiants d'accessibilité dans tout son arbre source, et chacun d'eux a dû être délibérément attaché à une vue dans du code de production livré :

TextField("Filter", text: $query)
    .accessibilityIdentifier("board.labelFilter")

Tout ce qui n'en a pas est, du point de vue des tests, absent. La surface de test d'une app SwiftUI n'est pas « l'interface ». C'est « les parties de l'interface que quelqu'un a pensé à nommer », et cette liste ne s'allonge que lorsqu'un test est en train d'être écrit.

Et vous ne pouvez pas demander quel genre de chose vous avez nommé

Même avec un identifiant, une vue SwiftUI ne vous promet pas ce qu'elle est devenue. Voici comment les tests de MeatPad atteignent le champ de recherche du tableau :

app.descendants(matching: .any).matching(identifier: "board.search").firstMatch

Faire correspondre n'importe quel type de descendant est une capitulation. La source dit TextField, mais qu'il apparaisse comme un champ de texte, un champ de recherche ou tout autre chose dépend de la manière dont SwiftUI a choisi de le construire ce jour-là, et cela peut changer avec une mise à jour du système. Parcourir tous les types d'éléments est plus lent et plus vague que de demander la chose que vous avez écrite, et c'est la seule option fiable.

Relire la valeur, c'est la même histoire. Un titre de carte revient sous la forme d'une conversion optionnelle sur une valeur de type inconnu, parce que c'est tout ce que la couche d'accessibilité propose, et deviner juste est votre travail.

Personne ne vous attend

WebDriver intègre l'attente. XCUITest attend qu'un élément existe et n'offre absolument rien pour une condition portant sur plusieurs. Alors les tests de MeatPad la fabriquent à la main :

private func waitForCardTitles(_ expected: [String]) -> Bool {
    let deadline = Date().addingTimeInterval(10)
    while Date() < deadline {
        if visibleCardTitles == expected.sorted() { return true }
        usleep(200_000)
    }
    return false
}

Les cartes quittent la hiérarchie de vues une ou deux images après la frappe qui les a filtrées, si bien qu'une assertion unique juste après la saisie relève du pile ou face. Chaque test qui vérifie une liste après une interaction a besoin de cette boucle, et chacune de ces boucles est un endroit où un vrai échec peut passer dix secondes à ressembler à une réussite lente.

Amener l'application dans un état connu

La suite de MailVault démarre un serveur IMAP simulé par compte avec une boîte de réception préremplie de 700 messages, y dirige l'application, et donne à toute l'exécution un répertoire personnel jetable. L'application testée est totalement inchangée. Elle n'a aucune idée qu'on la teste.

À MeatPad, il faut le dire. Ses tests d'interface écrivent un répertoire de stockage sur le disque, puis lancent l'application avec des arguments que le binaire livré comprend :

app.launchArguments = [
    "-meatpad.storageRootOverride", storageRoot.path,
    "-meatpad.revealBoard", boardID.uuidString,
    "-hasSeenFirstRunIntro", "YES",
]

Ces indicateurs sont du vrai code dans l'application publiée, présents pour que des tests puissent atteindre un écran. C'est un échange équitable et nous le referions, mais cela mérite d'être nommé : avec XCUITest, une partie de votre harnais de test est livrée aux utilisateurs.

Là où les tests ont le droit de s'exécuter

La suite de bout en bout sans fenêtre de MailVault s'exécute sur un runner GitHub Actions Ubuntu ordinaire. Le vrai binaire Tauri est construit pour Linux, lancé dans un tampon d'image virtuel et une session de bus de messages, et cliqué par WebDriver sur du matériel qui ne coûte rien à la minute. L'incantation exacte se trouve ci-dessous.

dbus-run-session -- xvfb-run --auto-servernum \
  --server-args="-screen 0 1920x1080x24" \
  npx wdio run wdio.conf.js --suite ui-headless

Il n'existe pas de ligne équivalente pour XCUITest. Il lui faut macOS, un serveur de fenêtres et une vraie session utilisateur, parce qu'il déplace réellement un pointeur et appuie sur des touches. Le dépôt de MeatPad compte exactement un workflow GitHub et celui-ci construit des versions. Les tests vivent sur le mini, ce qui est la réponse honnête à « où un petit studio exécute-t-il des tests d'interface Mac », et la raison même de l'existence de cette machine. Nous avons raconté comment elle gagne sa place pour MailVault dans une note précédente.

La partie de MeatPad qu'aucun test d'interface n'atteindra jamais

Regardez à nouveau où se trouvent les 31 tests d'interface de MeatPad : affichage des cartes, éditeur de carte, étiquettes, sauts de ligne, recherche. Tout cela, c'est le tableau. Pas un seul ne touche l'éditeur, qui est le produit lui-même.

L'éditeur enveloppe STTextView sur TextKit 2 dans un NSViewRepresentable, et pour l'arbre d'accessibilité c'est une seule région opaque contenant du texte. Curseurs multiples, emplacements de fragments, repliement de code, coloration incrémentale par tree-sitter, correspondance des parenthèses : rien de tout cela n'existe comme éléments interrogeables. Vous ne pouvez pas affirmer que le second curseur a atterri à la ligne quarante, parce qu'il n'existe aucun élément nommé « le second curseur ».

Ce travail n'est donc pas testé à travers l'interface. Il est testé en dessous. MeatPadKit est un paquet Swift distinct de 47 fichiers source, qui contient l'analyseur de repliement, le modèle multi-curseur, l'analyseur de fragments, la coloration, le comparateur flou, le pont de positions LSP et l'index des symboles du projet. Il ne contient aucun code de vue, et c'est exactement pourquoi 601 tests peuvent s'exécuter contre lui en onze secondes sans aucune fenêtre à l'écran.

Le commentaire en tête de l'un des tests d'interface de MeatPad résume la division mieux que nous ne saurions la paraphraser : MeatPadKit teste unitairement le comparateur lui-même, et ce qu'il ne peut pas atteindre, c'est de savoir si le champ est seulement relié aux colonnes. Voilà toute la fiche de poste d'un test d'interface SwiftUI. Non pas « la logique fonctionne-t-elle », ce à quoi un test rapide sans fenêtre a déjà répondu, mais « est-ce branché », ce à quoi rien d'autre ne peut répondre.

Commencez par une note. Restez pour l’éditeur. MeatPad est un carnet de notes et un éditeur de code natifs pour macOS : des fichiers simples sur votre Mac, pas de compte, pas de moteur de synchronisation, pas de télémétrie.

Découvrir MeatPad

Ce que cela change vraiment à votre façon de construire

La leçon n'est pas que SwiftUI est mauvais. C'est que le coût de la vérification d'une interface native est assez élevé pour devenir une donnée d'architecture plutôt qu'une réflexion après coup.

  • Sortez la logique de la vue jusqu'à ce que la vue soit ennuyeuse. MeatPad, c'est 51 fichiers source d'interface et 47 de framework, et le côté framework porte 95 pour cent des tests. Tout ce qui reste dans une vue SwiftUI est coûteux à vérifier, donc la bonne quantité à y laisser, c'est la mise en page.
  • Dépensez les tests d'interface pour le branchement, pas pour le comportement. À douze secondes pièce, ils sont le mauvais outil pour les cas limites et le bon outil pour « le champ de recherche est relié à la colonne ».
  • Ajoutez les identifiants quand vous écrivez la vue, pas quand vous écrivez le test. Les rajouter après coup signifie modifier des fichiers de production pour rendre un test possible, et c'est précisément le moment où les gens cessent d'écrire des tests.
  • Si votre interface est une webview, prenez le repas gratuit. Pouvoir exécuter une requête à l'intérieur de votre propre application en cours d'exécution est un véritable avantage de la pile Tauri, et c'est pourquoi MailVault a pu ajouter 31 specs de bout en bout en un seul mois.

Ce qui n'est pas couvert

Deux lacunes assumées, car un article sur les tests qui ne rapporte que les victoires est une brochure.

L'éditeur de MeatPad n'a aucune couverture au niveau de l'interface. La logique en dessous est testée à fond, et la colle entre cette logique et la vue texte est vérifiée par un humain. Une régression à cet endroit atteindrait une version publiée. Grignoter cela avec un petit nombre de tests coûteux, sur les interactions qui valent douze secondes chacune, vient ensuite.

Du côté de MailVault, seule la suite sans fenêtre s'exécute en intégration continue. Les 41 specs préremplies et les 5 locales tournent sur le mini, à la demande, ce qui veut dire qu'elles attrapent les choses avant une publication plutôt qu'avant une fusion. Et une suite au vert ne prouve toujours que les choses auxquelles quelqu'un a pensé à vérifier, ce qui explique pourquoi le nombre dans ce tableau compte moins que le fait qu'il continue de bouger.

Reproduire n'importe laquelle de ces mesures

# MailVault
git clone https://github.com/GraphicMeat/mail-vault-app
npm install
npm test                    # 1,218 unit and component tests
npm run test:integration    # 64 integration tests
npm run test:imap           # 158 Rust tests, core and daemon
npm run test:e2e            # builds the app, drives it through WebDriver

# MeatPad
cd MeatPadKit && swift test # 601 tests, no window required
xcodebuild test -project MeatPad.xcodeproj -scheme MeatPad \
  -destination "platform=macOS" -only-testing:MeatPadUITests

La dernière commande est celle qui prend deux minutes. Vous savez maintenant pourquoi.

Deux applications, une machine qui les garde honnêtes. Des logiciels Mac local-first, par un studio qui publie ses mesures, y compris celles qui ne l'avantagent pas.

Voir ce que nous construisons