Il y a un Mac mini Apple M4 avec 16 GB de mémoire sur une étagère, exécutant macOS 15, Xcode 26.6 et Swift 6.3.3, accessible uniquement en SSH sur le réseau local. Le 5 septembre 2026, il a passé une après-midi à exécuter les tests d'interface de nos deux applications Mac, l'une après l'autre, avec l'enregistrement d'écran. MeatPad, notre bloc-notes et éditeur de code macOS, est une application SwiftUI testée avec XCUITest : 50 cas, à partir de 12:58. MailVault, notre archiveur d'e-mails local, est une application Tauri testée avec WebdriverIO : 539 cas, à partir de 14:43. Cela fait 589 cas de test filmés, sur une seule machine, en une seule après-midi, et les deux exécutions ne se comportent en rien de la même façon.

Acte un : MeatPad, cinquante lancements en 405 secondes

Regarder sur YouTube

Une commande, à gauche de l'écran, contre MeatPad 0.15.0 :

xcodebuild test -scheme MeatPad -destination "platform=macOS" \
  -only-testing:MeatPadUITests

Tout le reste dans cet enregistrement découle de cette ligne. Xcode compile l'application et un second paquet appelé lanceur de tests, puis remet les deux à la machine. Pour chaque cas de test, le lanceur démarre l'application depuis zéro, attend sa fenêtre, déplace le pointeur, appuie sur des touches, lit ce qui revient, puis termine l'application. Puis il recommence pour le cas suivant. Les fenêtres qui apparaissent et disparaissent ne sont pas un effet visuel. C'est la boucle.

Pourquoi un cas XCUITest coûte des secondes

L'exécution a terminé 50 cas en 405.032 secondes. L'un des 50 s'ignore lui-même volontairement, donc les 49 qui ont réellement tourné prennent en moyenne environ 8.3 secondes chacun. À comparer avec MeatPadKit, le framework sous l'interface, où un test prend en moyenne dix-neuf millisecondes.

Trois choses expliquent cette différence. L'application est lancée une fois par cas, donc chaque cas paie un démarrage à froid et le temps que la fenêtre se stabilise. Le test tourne dans un processus séparé et ne peut voir l'application qu'à travers l'arbre d'accessibilité de macOS, donc chaque question sur l'écran est une requête à travers une frontière de processus plutôt qu'une lecture mémoire. Et le pointeur et le clavier sont réels, donc un glisser prend le temps que prend un glisser.

Cinquante mondes jetables

Un cas qui clique sur une carte a besoin d'une carte à cliquer. Rien n'est injecté dans le processus en cours d'exécution, donc l'état doit exister avant que l'application démarre. Chaque cas écrit un répertoire de stockage neuf dans le dossier temporaire du système, y sème un tableau, puis lance l'application pointée dessus :

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

Ces trois indicateurs sont du vrai code dans le binaire de version. Ils existent pour qu'un test puisse atteindre un écran, et ils sont livrés à tout le monde. C'est le coût honnête de tester une interface native depuis l'extérieur, et nous le paierions à nouveau, mais il vaut mieux le dire haut et fort plutôt que de le laisser découvrir par quelqu'un qui lit le code source. L'avantage, c'est que l'exécution ne laisse rien derrière elle : chaque cas supprime son répertoire lors du nettoyage, donc vos propres tableaux ne sont jamais touchés, et un échec ne peut pas contaminer le cas suivant.

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

Le chronomètre, suite par suite

MeatPad 0.15.0 · MeatPadUITests · Apple M4 Mac mini · 2026-09-05

  BoardCardDisplay      4 cases     49.973 s
  BoardCardEditor       9 cases     68.393 s
  BoardCardFace         5 cases     31.313 s
  BoardCardLink         2 cases     14.380 s
  BoardDrop             3 cases     30.463 s
  BoardLabel            8 cases    102.638 s
  BoardNewline          5 cases     26.818 s
  BoardSearch           5 cases     36.664 s
  BoardShot             1 skipped    0.020 s
  NamePrompt            1 case       6.943 s
  NoteLink              1 case      17.615 s
  OpenWith              6 cases     19.812 s

  total                50 cases    405.032 s

Douze suites, chacune nommée pour ce qu'elle garde : les trois modes d'affichage des cartes, l'éditeur de carte avec ses nuances de couleur et ses dates d'échéance, l'édition du titre sur place et le glisser d'une carte vers une autre colonne, les liens à l'intérieur des notes de carte, les zones de dépôt, les étiquettes et le filtrage par étiquette, les sauts de ligne dans le champ d'ajout rapide, la recherche, le focus clavier de la feuille de nouveau tableau, l'indice de lien de l'éditeur de notes, et les six cas qui demandent à Launch Services si MeatPad est proposé pour chaque type de fichier qu'il revendique.

BoardLabel est le plus coûteux avec 102.638 secondes pour 8 cas, parce que le filtrage par étiquette retire des cartes de la vue une ou deux images après la frappe, donc ces cas restent dans une boucle d'attente active pendant que la colonne se stabilise. Le cas isolé le plus lent de l'exécution était de 27.992 secondes, vérifiant qu'un titre de carte long est tronqué dans l'affichage compact et passe à la ligne dans les deux autres, ce qui revient à mesurer le même titre trois fois en trois lancements. Le cas le plus rapide était de 1.507 seconde, demandant à Launch Services si MeatPad est proposé pour chaque type de fichier qu'il enregistre, ce qui nécessite que l'application tourne mais ne touche jamais sa fenêtre.

La ligne de résumé qui va vous mentir

Le projet Xcode de MeatPad est généré à partir d'un manifeste et n'est pas commité. C'est pratique jusqu'au jour où vous ajoutez un fichier de test, oubliez de régénérer, et lancez quand même la suite. Rien ne va de travers du point de vue de xcodebuild. Il compile le projet qu'on lui a donné, exécute les zéro tests qu'il contient, et affiche les deux mots que tout le monde recherche :

** TEST SUCCEEDED **

Une exécution qui n'a rien exécuté ressemble exactement à une exécution qui a tout exécuté. Donc la ligne qui mérite d'être lue n'est jamais la dernière. C'est le compte juste au-dessus, qui indique combien de cas se sont réellement produits. Voici les dernières lignes de l'exécution de l'enregistrement ci-dessus. Le terminal de la vidéo fait passer xcodebuild par un grep et un sed, donc il affiche une forme raccourcie de la même sortie et les lignes tiennent à côté de la fenêtre de l'application. La commande elle-même est inchangée :

[OpenWithUITests]
  testAMultiSelectionOpensEveryFileAsATab passed 3.799s
  testASecondFileJoinsTheWindowThatIsAlreadyOpen passed 4.066s
  testLaunchServicesOffersMeatPadForEveryFileTypeItClaims passed 1.507s
  testOpeningAFileShowsItAsATabInAProjectWindowForItsFolder passed 3.825s
  testOpeningAFolderOpensItAsTheProjectWithNoTabs passed 3.950s
  testOpeningAnExtensionlessFileOpensItToo passed 2.665s
  Executed 6 tests, with 0 failures in 19.812 s
  Executed 50 tests, with 1 test skipped and 0 failures in 405.032 s

All tests passed
  Executed 50 tests, with 1 test skipped and 0 failures in 405.032 s
** TEST SUCCEEDED **

Cinquante cas, un ignoré volontairement, aucun échec, 405.032 secondes. Le compte et la dernière ligne concordent, ce qui est la seule combinaison digne de confiance.

Ce qui s'est passé avant la prise verte

L'exécution enregistrée n'était pas la première de la journée. Une exécution non enregistrée à 12:19 a viré au rouge : les mêmes 50 cas, 1 ignoré, et 2 échecs, tous deux dans OpenWith, le cas de sélection multiple et le cas du second fichier, les deux que vous voyez réussir à 3.799 et 4.066 secondes dans l'extrait ci-dessus. Les rapports de plantage disent que l'application s'est arrêtée 0.75 seconde après le lancement, à l'intérieur du propre chemin de lancement de SwiftUI, sur un échec de précondition AttributeGraph déclenché depuis applicationWillFinishLaunching pendant que le Dock notifiait encore l'application. Rien ne lui avait encore été transmis, donc ce n'est pas le code d'ouverture de fichier. Ces mêmes 6 cas OpenWith, lancés seuls juste après, ont réussi 3 fois sur 3, soit 18 cas sur 18, et les 6 ont de nouveau réussi dans l'exécution enregistrée. Cela ressemble à un flake de timing de lancement qui n'apparaît que quand la suite relance l'application coup sur coup. C'est encore en cours d'investigation, et ce n'est retiré du montage nulle part.

Une chose découverte pendant la préparation mérite son propre paragraphe. Pour faire tenir l'application à côté du terminal, nous avons d'abord réduit le cadre de fenêtre enregistré de MeatPad de 1440x900 points à 1010x900. Quatre cas ont échoué d'un coup : les nuances de couleur dans l'éditeur de carte, un filtre d'étiquette et une recherche, tous simplement parce que l'élément qu'ils cherchaient à atteindre était hors écran. Relancée avec le cadre d'origine de 1440x900, la même série a réussi 9 sur 9. La taille de fenêtre est une entrée d'un test d'interface, et cette suite suppose silencieusement le cadre par défaut de l'application. C'est pourquoi l'enregistrement montre MeatPad à sa taille par défaut à droite et un terminal étroit à gauche.

Deux prises précédentes sont allées à la poubelle pour des raisons qui n'avaient rien à voir avec MeatPad : dans l'une, une boîte de dialogue de plantage périmée, restée à l'écran depuis l'exécution de 12:19, et dans l'autre, la suite de MailVault venant d'une autre tâche qui a placé ses propres fenêtres par-dessus à mi-parcours, parce que le mini est une machine partagée. Ce qui est une bonne introduction à la seconde moitié de l'après-midi.

Acte deux : MailVault, 81 fichiers de spec en seize minutes

Regarder sur YouTube

MailVault 2.11.3 est construit à l'inverse : un noyau Rust avec une interface web, empaqueté par Tauri. Sa suite de bout en bout est pilotée par WebdriverIO, et la commande lancée à 14:43 demandait deux de ses trois suites :

npx wdio run wdio.conf.js --suite ui-headless --suite connected-ci

La suite ui-headless compte 7 fichiers de spec couvrant l'état d'accueil sans aucun compte configuré. La suite connected-ci compte 74 fichiers de spec qui s'exécutent contre des comptes IMAP fictifs préremplis. Cela fait 81 fichiers de spec. Une troisième suite, local-manual, contient 6 fichiers de spec supplémentaires pour la sauvegarde, la migration, l'archive et les vérifications visuelles, et ne faisait pas partie de cette exécution.

Spec Files:      80 passed, 1 failed, 81 total (100% completed) in 00:16:03

Quatre-vingts fichiers de spec ont réussi, un a échoué, 81 au total, en 16 minutes et 3 secondes selon la propre horloge de wdio. La vidéo dure 16:18 parce qu'elle commence avant la commande et se termine après elle. Sous ces fichiers se trouvent 539 cas de test : 533 réussis, 5 ignorés et l'unique échec. La moitié ui-headless a apporté 55 réussites et 3 ignorés en environ 56 secondes de temps de test, et connected-ci a apporté 478 réussites, 1 échec et 2 ignorés en environ 811 secondes.

L'application est lancée une fois par fichier de spec plutôt qu'une fois par cas, donc les 539 cas de MailVault de l'après-midi coûtent 81 lancements. Un fichier de spec prend en moyenne environ 10.7 secondes et la médiane est de 6 secondes, ce qui montre que la moyenne est portée par une poignée de fichiers longs : connected-custody-claims à 54.3 secondes pour 9 tests, connected-performance à 53.4 secondes pour 7, connected-compose-editor à 39.6 secondes pour 19, connected-compose-autosave à 38.7 secondes pour 15. Le fichier le plus rapide, connected-backup-partial-failure, a exécuté ses 5 tests en 32 millisecondes. Le plus chargé, connected-email-viewer, contient 21 tests. Tout cela repose sur une base bien plus large et bien moins coûteuse : 2,683 tests unitaires vitest répartis sur 216 fichiers se terminent en 6.77 secondes, 318 tests Rust couvrent le noyau, et les deux suites enregistrées représentent 518 cas it() dans le code source.

Deux choses dans l'enregistrement méritent un mot. Le terminal de gauche n'affiche que les lignes RUNNING, PASSED et FAILED de wdio, par fichier, parce que wdio garde son rapport détaillé pour la toute fin de l'exécution. Et vers la fin, la spec d'export ouvre un message exporté dans Preview, ce qui recouvre le terminal pendant la dernière minute ; c'est le test qui fait son travail, pas un accident.

Ce que le harnais met en place

Chaque exécution démarre ses propres serveurs IMAP fictifs avec trois comptes préremplis, luke, vader et yoda sur mock.test, et l'INBOX de vader contient 700 messages, largement plus que les fenêtres de pagination de l'application, de sorte que la pagination est réellement exercée plutôt que simplement supposée. Un quatrième compte est ajouté par un test que l'on peut voir se taper au clavier. L'application testée est une version avec la fonctionnalité webdriver activée, démarrée par tauri-wd, le crate tauri-webdriver-automation, et elle tourne contre un répertoire HOME jetable, de sorte qu'un vrai coffre n'est jamais touché. Ce harnais fait l'objet de sa propre note dans Note de terrain 004.

L'unique échec, non classé

Un cas sur 539 a échoué : la spec connected-storage-matrix, test "a row deleted in unified mode stays gone across account churn and a reload". Son assistant switchToUnified vérifie immédiatement que le bouton Toutes les boîtes de la barre latérale existe et est visible, sans aucune attente, et il s'exécute juste après que le test précédent a rechargé l'application. Vrai attendu, faux reçu. Nous n'avons pas pu relancer cette spec seule ensuite, parce que chaque tentative de démarrer une nouvelle session de pilote dans ce checkout a échoué avant même d'atteindre le pilote, avec une erreur undici UND_ERR_INVALID_ARG sur la requête de session. Elle reste donc non classée. Cela ressemble à une attente manquante après un rechargement plutôt qu'à un bug produit, et elle reste dans la vidéo.

1.8 seconde par cas contre 8.3

539 cas de MailVault en 963 secondes de temps réel, c'est environ 1.8 seconde par cas. Les 49 cas exécutés de MeatPad en 405.032 secondes coûtent environ 8.3 secondes chacun. Même machine, même après-midi, à peu près un facteur cinq entre les deux.

Deux raisons structurelles, pas une astuce d'optimisation. WebDriver exécute du JavaScript à l'intérieur de l'application en cours d'exécution et lit le DOM directement, donc une vérification se résout contre l'un des 494 crochets data-testid des specs de bout en bout, au lieu de franchir une frontière de processus vers l'arbre d'accessibilité de macOS. Et l'arithmétique des lancements diffère : les 539 cas de MailVault coûtent 81 lancements d'application, tandis que les 50 cas de MeatPad en coûtent 50. Nous avons mesuré cet écart correctement il y a deux semaines dans Note de terrain 006.

Rien de tout cela ne rend une application mieux testée que l'autre. Les suites vérifient des produits différents, et 8.3 secondes par cas est le prix à payer pour piloter une interface native de la seule façon que macOS offre depuis l'extérieur du processus.

Exécutez-les vous-même

git clone https://github.com/GraphicMeat/MeatPad
cd MeatPad
brew install xcodegen
xcodegen generate
xcodebuild test -scheme MeatPad -destination "platform=macOS" \
  -only-testing:MeatPadUITests

Ne sautez pas la ligne xcodegen, pour la raison indiquée ci-dessus. Le code source de MeatPad est sur GitHub. La suite de MailVault n'est qu'un clone de plus et la même commande que celle filmée :

git clone https://github.com/GraphicMeat/mail-vault-app
cd mail-vault-app
npm install
npx wdio run wdio.conf.js --suite ui-headless --suite connected-ci

Tout est public, le flake de MeatPad et l'unique échec de MailVault inclus, et le code source de MailVault se trouve juste à côté de celui de MeatPad sur GitHub.

Un logiciel Mac local d'abord, mesures à l'appui. MailVault conserve votre courrier dans un coffre sur votre propre disque, et une machine clique dans toute l'application avant chaque version.

Découvrir MailVault