机の下には、メモリ 16 GB の Apple M4 Mac mini が置いてあります。その仕事は、私たちのアプリがまだ動くかどうかを確かめることです。 MailVault は Tauri で作られています。 MeatPad は SwiftUI で作られています。mini は両方のスイートを回します。同じマシン、同じ午後、テストを書くのも同じ人間です。

2 つのスイートのコストは同じではありません。近いところにすらありません。以下のすべては 2026 年 8 月 25 日に測定したもので、どのコマンドもあなた自身の手で実行できます。

今日グリーンになったもの

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

それぞれのブロックの最後の 2 行を互いに突き合わせて読んでください。そこにこの記事のすべてがあります。MailVault はウィンドウなしの検査 1,440 件を実時間 7 秒未満で得ており、さらに自分の実バイナリを E2E スペック 53 本で動かせます。MeatPad はウィンドウなしの検査 601 件を 11 秒で得ており、実際のインターフェイスに触れるテストは 31 件です。

そして、表に収まらない数字があります。この 31 件のインターフェイステストのうち 8 件を mini で実行したところ、98.186 秒かかりました。個々のケースは 6.9 秒から 17.2 秒の間でした。1 テストあたり 12 秒と考えてください。

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

同じマシンでの MeatPadKit のユニットテストは平均 19 ミリ秒です。MailVault の vitest のケースは平均 2 ミリ秒未満です。SwiftUI のビューへのシミュレートされたクリック 1 回は、ユニットテスト約 600 件分のコストになります。

8 月を、2 本の曲線で

MailVault はこの 1 か月、テストを書いてもらうことに費やしました。その形は、リポジトリのテストケースを週ごとに数えれば簡単に見えてきます。

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

ケース数は 25 日でほぼ倍になり、E2E スペックは倍以上になりました。この伸びはアプリ全体に均等に広がったわけではありません。バグのあった場所に向かったのです。作成と下書き、一括選択、変更を伴う操作のたびにディスク上の状態と一致していなければならないストレージマトリクス、そしてアカウント切り替えです。その期間のコミットタイトルは告白の一覧のように読めますが、テストのログはそう読めるのが正しいのです。

MeatPad の曲線はもっと短いものです。アプリが若いからですが、正直に語る価値のある段差がひとつあります。

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 は 7 月に 517 件のテストとともに出荷され、そのうちウィンドウを開くものは 1 件もありませんでした。最初のインターフェイステストが書かれたのは 8 月 24 日、リリースの 1 か月後です。この空白は怠慢ではありません。この記事の残りは、まさにその話です。

本当に中身を確かめられるメールアーカイブ。 MailVault はあなたのメールボックスをローカルにバックアップして保管します。そしてその何かがあなたのもとに届く前に、53 本の E2E スペックが実際のアプリケーションを操作します。

MailVault にアクセスしてください

ほかのすべてを説明する違い

Tauri アプリのインターフェイスはウェブページです。テストハーネスはそれに WebDriver で話しかけます。つまりハーネスは、実行中のアプリケーションの内部でコードを実行できるということです。

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

このアロー関数はテスト側で評価されるのではありません。アプリ自身の webview に送り込まれ、そこで生きたドキュメントに対して実行され、その結果が返ってきます。テストは、アプリケーション自身とまったく同じやり方でインターフェイスに問い合わせできます。

XCUITest にこれはできませんし、今後もできません。これは別プロセスであり、macOS のアクセシビリティ層を通して外側からアプリを操作します。任意のコードを流し込むチャネルはなく、アクセシビリティツリーがすでに答えていない問いをビューに投げる方法もありません。以下に続くことはすべて、この 1 本の境界線から生じています。

ドキュメントの中では、すべてに取っ手がある

MailVault の E2E スイートは、テスト ID 属性を通して 305 回にわたり要素を選択しています。ひとつ追加するのにコストはかからず、アプリの動き方も何も変わりません。アプリが描画するものは、誰かがそう計画したかどうかに関係なく、すべてアドレス可能です。

MeatPad のソースツリー全体にあるアクセシビリティ識別子は 34 個で、そのひとつひとつを、出荷される製品コードの中でビューに意図的に取り付ける必要がありました。

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

識別子のないものは、テストの目的から言えば存在しません。SwiftUI アプリのテスト対象面は「インターフェイス」ではありません。「誰かが名前を付けることを覚えていたインターフェイスの部分」であり、その一覧はテストが書かれているときにしか増えません。

しかも、名前を付けたものが何なのかは尋ねられない

識別子があっても、SwiftUI のビューは自分が何になったのかを約束してはくれません。MeatPad のテストがボードの検索フィールドに到達する方法は次のとおりです。

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

任意の子孫の型に対してマッチさせるのは降参です。ソースには TextField と書いてありますが、それがテキストフィールドとして現れるのか、検索フィールドとして現れるのか、まったく別の何かとして現れるのかは、その日 SwiftUI がどう組み立てたかによって決まり、OS のアップデートで変わることもあります。あらゆる要素型を探すのは、自分が書いたものを名指しで求めるより遅く曖昧ですが、それが唯一信頼できる選択肢です。

値を読み戻すのも同じ話です。カードのタイトルは、型の分からない値に対するオプショナルのキャストとして返ってきます。アクセシビリティ層が差し出すのはそれだけであり、正しく推測するのはあなたの仕事だからです。

誰もあなたを待ってはくれない

WebDriver には待機が組み込まれています。XCUITest はひとつの要素が存在するのを待つだけで、複数にまたがる条件については何も提供しません。そこで MeatPad のテストは自前で書いています。

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
}

カードは、それらを絞り込んだキー入力から 1、2 フレーム遅れてビュー階層を去ります。ですから入力直後の 1 回きりのアサーションはコイン投げです。操作のあとにリストを確認するテストはどれもこのループを必要とし、そのループのひとつひとつが、本物の失敗が 10 秒かけて「遅い成功」のように見えることを許す場所になります。

アプリを既知の状態に持っていく

MailVault のスイートは、アカウントごとに 700 通を仕込んだ受信トレイを持つモック IMAP サーバーを起動し、アプリをそこに向け、実行全体に使い捨てのホームディレクトリを与えます。テスト対象のアプリケーションはまったく改変されていません。自分がテストされていることを知りません。

MeatPad には教えてやる必要があります。UI テストはストレージ用のディレクトリをディスクに書き出し、それから、出荷されるバイナリが理解する引数を付けてアプリを起動します。

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

これらのフラグは、公開されているアプリケーションの中の本物のコードで、テストが画面に到達できるようにするために存在しています。これは公正な取引であり、もう一度同じ選択をしますが、はっきり言っておく価値はあります。XCUITest では、テストハーネスの一部が利用者のもとへ出荷されるのです。

テストが走ることを許される場所

MailVault のウィンドウなしの E2E スイートは、ごく普通の Ubuntu の GitHub Actions ランナーで走ります。本物の Tauri バイナリを Linux 向けにビルドし、仮想フレームバッファとメッセージバスのセッションの中で起動し、分あたりの費用がゼロのハードウェア上で WebDriver がクリックしていきます。正確な呪文は下にあります。

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

XCUITest に相当する行はありません。macOS と、ウィンドウサーバーと、本物のユーザーセッションが必要です。実際にポインタを動かしてキーを押しているからです。MeatPad のリポジトリにある GitHub ワークフローはちょうど 1 本で、それはリリースをビルドするものです。テストは mini の上で暮らしています。「小さなスタジオはどこで Mac の UI テストを走らせるのか」への正直な答えであり、あのマシンが存在する理由そのものです。MailVault のためにどう働いてくれているかは、こちらに書きました。 以前のノート.

どんな UI テストも決して届かない MeatPad の部分

MeatPad の 31 件のインターフェイステストがどこにあるか、もう一度見てください。カードの表示、カードのエディタ、ラベル、改行、検索。すべてボードです。本当の製品であるエディタに触れるものは 1 件もありません。

エディタは TextKit 2 上の STTextView を NSViewRepresentable で包んだもので、アクセシビリティツリーから見れば、テキストを含んだひとかたまりの不透明な領域にすぎません。複数カーソル、スニペットのプレースホルダ、コードの折りたたみ、tree-sitter による差分ハイライト、括弧の対応。そのどれも、問い合わせできる要素としては存在しません。2 番目のキャレットが 40 行目に着いたと表明することはできません。「2 番目のキャレット」という要素が存在しないからです。

ですからその作業はインターフェイス越しにはテストされません。その下でテストされます。MeatPadKit は独立した Swift パッケージで、ソース 47 ファイル。折りたたみスキャナ、複数カーソルのモデル、スニペットのパーサ、ハイライタ、あいまい検索、LSP の位置変換、プロジェクトのシンボルインデックスを収めています。ビューのコードは入っておらず、まさにそのおかげで 601 件のテストが、画面にウィンドウを 1 枚も出さずに 11 秒で走れるのです。

MeatPad の UI テストのひとつの冒頭にあるコメントは、この分担を私たちが言い換えるよりうまく言い表しています。マッチャそのものは MeatPadKit がユニットテストしており、そこに届かないのは、そのフィールドがそもそも列に配線されているかどうかだ、と。それが SwiftUI の UI テストの職務内容のすべてです。「ロジックは動くか」ではなく、それには速いウィンドウなしのテストがすでに答えています。「つながっているか」であり、それに答えられるものは他にありません。

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

MeatPad を見る

これが作り方に実際もたらす変化

教訓は SwiftUI が悪いということではありません。ネイティブのインターフェイスを検証するコストは、後付けの発想ではなく設計上の入力として扱うべきほど高い、ということです。

  • ビューが退屈になるまで、ロジックをビューの外へ押し出す。 MeatPad はインターフェイスのソース 51 ファイルとフレームワークの 47 ファイルからなり、テストの 95 パーセントはフレームワーク側が担っています。SwiftUI のビューに残るものはすべて検査が高くつくので、そこに残しておく適量はレイアウトです。
  • インターフェイステストは振る舞いではなく配線に使う。 1 件 12 秒では、端の場合を確かめる道具としては間違っており、「検索フィールドが列につながっている」を確かめる道具としては正しいのです。
  • 識別子はテストを書くときではなく、ビューを書くときに付ける。 後から付けるということは、テストを可能にするために製品コードのファイルを編集するということで、それはまさに人がテストを書くのをやめる瞬間です。
  • インターフェイスが webview なら、ただ飯をいただく。 実行中の自分自身のアプリケーションの内部でクエリを走らせられることは Tauri スタックの本物の利点であり、MailVault がひと月で E2E スペックを 31 本増やせた理由でもあります。

カバーできていないこと

正直な穴を 2 つ。勝ちだけを報告するテストの記事は、パンフレットにすぎないからです。

MeatPad のエディタには、インターフェイスの水準の網羅がまったくありません。その下のロジックは十分にテストされており、そのロジックとテキストビューをつなぐ接着剤は人間が確認しています。そこでの退行はリリースにまで届くでしょう。1 件 12 秒の価値がある操作について、少数の高価なテストでそれを削っていくのが次の仕事です。

MailVault の側では、継続的インテグレーションで走るのはウィンドウなしのスイートだけです。仕込み済みの 41 本とローカルの 5 本は mini の上で、必要なときに走ります。つまりマージの前ではなくリリースの前に問題を捕まえるということです。そしてグリーンのスイートが証明するのは、結局のところ誰かが確かめようと思ったことだけです。だからあの表の数字よりも、その数字が動き続けているという事実のほうが大事なのです。

どれでも自分で再現する

# 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

最後のコマンドが、2 分かかるものです。理由はもうお分かりですね。

2 本のアプリと、それを正直に保つ 1 台のマシン。 ローカルファーストの Mac 用ソフトウェアを、測定結果を、都合の悪いものも含めて公開するスタジオから。

私たちが作るものを見る