棚の上に、メモリ 16 GB の Apple M4 Mac mini が 1 台あります。macOS 15、Xcode 26.6、Swift 6.3.3 が動いていて、到達できるのはローカルネットワーク上の SSH 経由だけです。2026 年 9 月 5 日、この mini はその午後をまるごと使って、私たちの 2 つの Mac アプリのインターフェーステストを、画面を録画しながら立て続けに実行しました。 MeatPadは、私たちの macOS 向けノートアプリ兼コードエディタで、XCUITest でテストされた SwiftUI アプリです。50 件のケースが 12:58 に始まります。 MailVaultは、私たちのローカルファーストなメールアーカイバーで、WebdriverIO でテストされた Tauri アプリです。539 件のケースが 14:43 に始まります。1 台のマシン、1 つの午後で、合わせて 589 件のテストケースを録画したことになりますが、2 つの実行の振る舞いはまるで似ていません。

第一幕: MeatPad、405 秒で 50 回の起動

YouTube で見る

画面左側で実行しているコマンドは 1 つ、対象は MeatPad 0.15.0 です:

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

その録画に映っている残りすべては、このコマンド 1 行の結果です。Xcode はアプリと、テストランナーと呼ばれる 2 つ目のバンドルをビルドし、両方をマシンに渡します。テストケースごとに、ランナーはアプリを最初から起動し、ウィンドウが現れるのを待ち、ポインタを動かし、キーを押し、返ってきた結果を読み取り、アプリを終了します。それを次のケースに対してもう一度繰り返します。ウィンドウが現れては消えるのは映像効果ではありません。それがこのループの正体です。

XCUITest のケースがなぜ数秒かかるのか

この実行は 50 件のケースを 405.032 秒で終えました。50 件のうち 1 件は意図的に自分自身をスキップするので、実際に走った 49 件は平均するとおよそ 8.3 秒ずつです。インターフェースの土台となるフレームワーク MeatPadKit では、テスト 1 件の平均が 19 ミリ秒であることと比べてみてください。

その差を生んでいるのは 3 つの要因です。アプリはケースごとに 1 回起動されるので、どのケースもコールドスタートとウィンドウが落ち着くまでの時間を負担します。テストは別プロセスで走り、アプリを macOS のアクセシビリティツリー越しにしか見られないため、画面についての問い合わせはすべて、メモリの読み取りではなくプロセス境界をまたぐクエリになります。そしてポインタとキーボードは本物なので、ドラッグにはドラッグそのものと同じだけの時間がかかります。

50 個の使い捨ての世界

カードをクリックするケースには、クリックする対象のカードが必要です。実行中のプロセスには何も注入されないので、状態はアプリの起動前に用意しておく必要があります。各ケースはシステムの一時フォルダに新しいストレージ用ディレクトリを作成し、そこにボードを 1 つ仕込んでから、そのディレクトリを指定してアプリを起動します:

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

この 3 つのフラグは、リリースバイナリの中に実在するコードです。テストが画面に到達できるようにするために存在していて、それは全員に出荷されます。ネイティブなインターフェースを外側からテストすることの正直なコストであり、私たちはまた同じ対価を払うつもりですが、それはソースを読んで気づかれるのではなく、はっきり言葉にしておくべきことです。その見返りとして、実行は何も後に残しません。どのケースもティアダウンで自分のディレクトリを削除するので、あなた自身のボードが触られることは一切なく、失敗が次のケースを汚染することもありません。

メモから始める。 エディタで続ける。 MeatPad は macOS ネイティブのノート兼コードエディタです。あなたの Mac 上のただのファイル、アカウントなし、同期エンジンなし、テレメトリなし。

MeatPad を見る

スイートごとの所要時間

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

12 のスイートがあり、それぞれ自分が守っているものにちなんだ名前がついています。3 つのカード表示モード、カラースウォッチと期限を持つカードエディタ、その場でのタイトル編集とカードをカラム間でドラッグする操作、カードのメモ内のリンク、ドロップターゲット、ラベルとラベルによる絞り込み、クイック追加フィールドの改行、検索、新規ボードシートのキーボードフォーカス、メモエディタのリンクヒント、そして Launch Services に対して申告するすべてのファイルタイプに MeatPad が候補として提示されるかを尋ねる 6 件のケースです。

最も高くつくのは BoardLabel で、8 件に 102.638 秒かかっています。ラベルによる絞り込みはキー入力の 1、2 フレーム後にカードをビューから取り除くため、これらのケースはカラムが落ち着くのを待つポーリングループの中で足止めされるからです。実行中で最も遅かった単体ケースは 27.992 秒で、長いカードタイトルが compact 表示で途中省略され、残り 2 つのモードでは折り返されることを確認するもので、同じタイトルを 3 回の起動にわたって 3 回測定していることになります。最も速かったケースは 1.507 秒で、Launch Services に対して、登録している各ファイルタイプに MeatPad が候補として提示されるかを尋ねるものであり、アプリの起動は必要でもそのウィンドウには一切触れません。

あなたに嘘をつく要約行

MeatPad の Xcode プロジェクトはマニフェストから生成されるもので、コミットされていません。それは、テストファイルを追加し、再生成を忘れたままスイートを実行してしまう日までは便利です。xcodebuild の視点からは何も問題はありません。渡されたプロジェクトをコンパイルし、その中にある 0 件のテストを実行し、誰もが見たがる 2 つの単語を出力します:

** TEST SUCCEEDED **

何も実行しなかった実行は、すべてを実行した実行とまったく同じに見えます。ですから、読む価値があるのは最後の行では決してありません。読むべきはその上にあるカウントで、実際に何件のケースが起きたかを示しています。以下は、上の録画にある実行の最後の行です。動画中のターミナルは xcodebuild を grep と sed に通しているため、同じ出力を短縮した形で表示していて、行がアプリのウィンドウの横に収まるようになっています。コマンド自体は変わっていません:

[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 **

50 件のケース、意図的なスキップが 1 件、失敗はゼロ、405.032 秒。カウントと最後の行が一致していること、それだけが信頼に値する組み合わせです。

グリーンになったテイクより前に何があったか

録画された実行は、その日最初の実行ではありませんでした。12:19 に録画なしで走らせた実行はレッドになりました。同じ 50 件のケース、1 件スキップ、そして失敗が 2 件、いずれも OpenWith 内で、複数選択のケースと 2 つ目のファイルのケースです。これは上の抜粋で 3.799 秒と 4.066 秒でパスしているのが見える、まさにその 2 件です。クラッシュレポートによれば、アプリは起動から 0.75 秒後、SwiftUI 自身の起動処理の中で、Dock がまだアプリに通知を送っている最中に applicationWillFinishLaunching から発生した AttributeGraph の precondition 違反によって強制終了しています。この時点ではまだ何も渡されていないので、これはファイルを開くコード自体の問題ではありません。この同じ 6 件の OpenWith ケースを、直後に単独で 3 回走らせたところ 3 回とも成功し、これは 18 件中 18 件です。そして録画された実行でも 6 件すべてが再び成功しています。これは、スイートがアプリを立て続けに再起動したときにだけ現れる、起動タイミングのフレークのように見えます。まだ調査中であり、何も編集で取り除いてはいません。

セットアップ中に見つかったことがもう 1 つ、独立した段落に値します。アプリをターミナルの隣に収めるため、まず MeatPad の保存済みウィンドウフレームを 1440x900 ポイントから 1010x900 に縮めました。すると 4 件のケースが一斉に失敗しました。カードエディタのカラースウォッチ、ラベルフィルター、そして検索で、いずれも純粋に、狙っている要素が画面外にはみ出していたことが原因でした。元の 1440x900 のフレームでもう一度実行すると、同じ組み合わせが 9 件中 9 件パスしました。ウィンドウサイズは UI テストへの入力の一つであり、このスイートはアプリのデフォルトフレームを暗黙のうちに前提としています。だからこそ録画では、右側に既定サイズの MeatPad、左側に幅の狭いターミナルが映っているのです。

それより前の 2 つのテイクは、MeatPad とは無関係の理由でボツになりました。1 つは、12:19 の実行が残していった古いクラッシュダイアログが画面に居座っていたもの、もう 1 つは、別のジョブの MailVault スイートが途中で自分のウィンドウを上に重ねてきたものです。mini は共有マシンだからです。この午後の後半への、ちょうどいい前置きになりました。

第二幕: MailVault、16 分で 81 のスペックファイル

YouTube で見る

MailVault 2.11.3 は逆の作り方をされています。Rust のコアに Web フロントエンドを載せ、Tauri でパッケージしたものです。そのエンドツーエンドスイートは WebdriverIO によって駆動され、14:43 のコマンドは 3 つのスイートのうち 2 つを指定しました。

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

ui-headless スイートは、アカウントが 1 つも設定されていないウェルカム状態を扱う 7 のスペックファイルです。connected-ci スイートは、シードされたモック IMAP アカウントに対して実行される 74 のスペックファイルです。合わせて 81 のスペックファイルになります。3 つ目のスイートである local-manual は、バックアップ、移行、アーカイブ、見た目のチェックのためのスペックファイルをさらに 6 つ保持していますが、今回の実行には含まれていません。

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

wdio 自身の時計で、80 のスペックファイルが成功、1 つが失敗、合わせて 81、16 分 3 秒でした。動画自体は 16:18 ありますが、これはコマンドの実行前から始まり、実行後まで続いているためです。これらのファイルの下には 539 件のテストケースがあり、533 件が成功、5 件がスキップ、そして 1 件が失敗です。ui-headless の側は約 56 秒のテスト時間で 55 件成功、3 件スキップ、connected-ci の側は約 811 秒で 478 件成功、1 件失敗、2 件スキップという内訳です。

アプリはケースごとではなくスペックファイルごとに 1 回起動されるので、この午後の 539 件の MailVault ケースは 81 回の起動で済んでいます。スペックファイルは平均するとおよそ 10.7 秒で、中央値は 6 秒です。つまり平均を押し上げているのは一握りの長いファイルだということで、connected-custody-claims は 9 件のテストで 54.3 秒、connected-performance は 7 件で 53.4 秒、connected-compose-editor は 19 件で 39.6 秒、connected-compose-autosave は 15 件で 38.7 秒かかっています。最も速いファイルである connected-backup-partial-failure は、5 件のテストを 32 ミリ秒で終えています。最も件数が多い connected-email-viewer は 21 件のテストを抱えています。これらすべては、はるかに大きくはるかに安価な土台の上に乗っています。216 のファイルにまたがる 2,683 件の vitest ユニットテストは 6.77 秒で終わり、318 件の Rust テストがコアをカバーし、今回録画した 2 つのスイートはソース上では 518 件の it() ケースです。

録画については、2 つだけ補足しておきます。左側のターミナルには wdio のファイルごとの RUNNING、PASSED、FAILED という行しか表示されません。wdio が詳細なレポートを実行の一番最後まで取っておくためです。そして終盤で、export のスペックがエクスポートしたメッセージを Preview で開き、最後の 1 分間ターミナルを覆い隠しますが、これはテストが自分の仕事をしているだけで、事故ではありません。

ハーネスが用意するもの

各実行は、mock.test にある luke、vader、yoda という 3 つのシードされたアカウントで、自前のモック IMAP サーバーを起動します。vader の INBOX には 700 通のメッセージが入っていて、アプリのページング単位を余裕を持って上回るので、ページングは想定されるだけでなく実際に運動させられます。4 つ目のアカウントは、入力されるところを映像で見られるテストによって追加されます。テスト対象のアプリは webdriver 機能を有効にしたビルドで、tauri-wd、つまり tauri-webdriver-automation クレートによって起動され、使い捨ての HOME ディレクトリに対して実行されるので、実物のボールトが触られることはありません。そのハーネスについては フィールドノート 004.

唯一の失敗、原因未分類

539 件中 1 件が失敗しました。スペックは connected-storage-matrix、テストは "a row deleted in unified mode stays gone across account churn and a reload" です。そのヘルパーである switchToUnified は、待機なしで、サイドバーの「すべての受信トレイ」ボタンが存在し表示されていることを即座にアサートしますが、これは直前のテストがアプリをリロードした直後に実行されます。期待値は true、実際の値は false でした。この 1 件のスペックだけをその後再実行することはできませんでした。そのチェックアウトで新しいドライバセッションを開始しようとするたびに、ドライバに到達する前に、セッションリクエストで undici の UND_ERR_INVALID_ARG が発生して失敗したからです。そのため原因は未分類のままです。プロダクトのバグというより、リロード後の待機漏れのように読めますが、映像からは取り除いていません。

1 件 1.8 秒対 8.3 秒

963 秒の実時間で 539 件の MailVault ケースをこなしているので、1 件あたりおよそ 1.8 秒です。MeatPad は 405.032 秒で 49 件を実行しているので、1 件あたりおよそ 8.3 秒です。同じマシン、同じ午後で、その差はおよそ 5 倍です。

何か 1 つの巧妙な最適化ではなく、構造的な理由が 2 つあります。WebDriver は実行中のアプリケーションの内部で JavaScript を実行し、DOM を直接読み取るので、チェックは macOS のアクセシビリティツリーへとプロセス境界を越える代わりに、エンドツーエンドのスペックにある 494 個の data-testid フックのどれかに対して解決されます。そして起動の計算も違います。539 件の MailVault ケースは 81 回のアプリ起動で済みますが、50 件の MeatPad ケースは 50 回の起動を必要とします。この差についてはすでに数週間前、 フィールドノート 006.

だからといって、どちらかのアプリがより手厚くテストされているということにはなりません。それぞれのスイートは違うプロダクトを検証しているのであり、1 件 8.3 秒は、プロセスの外側から macOS が提供する唯一の方法でネイティブなインターフェースを操作することの対価です。

自分で実行してみる

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

上記の理由により、xcodegen の行は省略しないでください。MeatPad のソースは GitHubにあります。MailVault のスイートも、もう一度クローンして、録画で実行したのと同じコマンドを打つだけです:

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

すべて公開されています。MeatPad のフレークと MailVault の 1 件の失敗も含めてです。MailVault のソースは、MeatPad のものと同じ GitHub.

測定結果つきの、ローカルファーストな Mac ソフトウェア。 MailVault はあなたのメールを自分のディスク上の保管庫に保ち、リリースのたびにマシンがアプリ全体をクリックして確かめます。

MailVault を見る