There is an Apple M4 Mac mini under the desk with 16 GB of memory, and its job is to find out whether our apps still work. MailVault is built with Tauri. MeatPad is built with SwiftUI. The mini runs both suites: same machine, same afternoon, same person writing the tests.

The two suites do not cost the same. They do not cost anything like the same. Everything below was measured on 25 August 2026, and every command is one you can run yourself.

What ran green today

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

Read the last two lines of each block against each other, because that is the whole article. MailVault gets 1,440 headless checks in under seven seconds of wall clock, and it can additionally drive its own real binary through 53 end-to-end specs. MeatPad gets 601 headless checks in eleven seconds, and 31 tests that touch the actual interface.

Then there is the number that does not fit in a table. Running eight of those 31 interface tests on the mini took 98.186 seconds. Individual cases came in between 6.9 and 17.2 seconds. Call it twelve seconds per test.

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

A MeatPadKit unit test on the same machine averages nineteen milliseconds. A MailVault vitest case averages under two. One simulated click on a SwiftUI view costs about six hundred unit tests.

August, in two curves

MailVault spent the month having tests written for it, and the shape of that is easy to see by counting test cases in the repository week by week:

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

The case count nearly doubled in twenty-five days, and the end-to-end specs more than doubled. That growth is not evenly spread across the app. It went where the bugs were: compose and drafts, bulk selection, the storage matrix that has to agree about what is on disk after every mutating action, and account switching. The commit titles from that stretch read like a list of confessions, which is the correct way for a test log to read.

MeatPad's curve is shorter, because the app is younger, and it has a step in it that is worth being honest about:

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 shipped in July with 517 tests and not one of them opened a window. The first interface test was written on 24 August, a month after release. That gap is not laziness. It is the subject of the rest of this article.

An email archive you can actually check. MailVault backs up and archives your mailbox locally, and 53 end-to-end specs drive the real application before any of it reaches you.

Visit MailVault

The difference that explains all the others

A Tauri app's interface is a web page. The test harness speaks WebDriver to it, which means the harness can run code inside the running application itself:

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

That arrow function is not evaluated by the test. It is shipped into the app's own webview, executed there against the live document, and its result comes back. The test can interrogate the interface exactly the way the application itself can.

XCUITest cannot do this and never will. It is a separate process, driving your app from outside through the macOS accessibility layer. There is no channel for arbitrary code, no way to ask a view a question the accessibility tree does not already answer. Everything that follows is a consequence of that single boundary.

In a document, everything has a handle

MailVault's end-to-end suite selects elements 305 times through test-id attributes. Adding one costs nothing and changes nothing about how the app runs. Anything the app renders is addressable, whether anyone planned for it to be or not.

MeatPad has 34 accessibility identifiers in its entire source tree, and every one of them had to be deliberately attached to a view in shipping production code:

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

Anything without one is, for testing purposes, not there. The test surface of a SwiftUI app is not "the interface". It is "the parts of the interface somebody remembered to name", and that list only grows when a test is being written.

And you cannot ask what kind of thing you named

Even with an identifier, a SwiftUI view does not promise you what it became. Here is how MeatPad's tests reach the board's search field:

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

Matching on any descendant type is a surrender. The source says TextField, but whether it surfaces as a text field, a search field, or something else entirely depends on how SwiftUI chose to build it that day, and that can change with an OS update. Searching every element type is slower and vaguer than asking for the thing you wrote, and it is the only reliable option.

Reading the value back is the same story. A card title comes back as an optional cast over a value of unknown type, because that is all the accessibility layer offers, and guessing right is your job.

Nobody waits for you

WebDriver has waiting built in. XCUITest waits for one element to exist and offers nothing at all for a condition across several. So MeatPad's tests hand-roll it:

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
}

Cards leave the view hierarchy a frame or two after the keystroke that filtered them out, so a single assertion right after typing is a coin flip. Every test that checks a list after an interaction needs this loop, and every one of those loops is a place where a real failure gets to spend ten seconds looking like a slow success.

Getting the app into a known state

MailVault's suite starts a mock IMAP server per account with a seeded 700-message inbox, points the app at them, and gives the whole run a throwaway home directory. The application under test is completely unmodified. It has no idea it is being tested.

MeatPad has to be told. Its UI tests write a storage directory on disk, then launch the app with arguments the shipping binary understands:

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

Those flags are real code in the released application, existing so tests can reach a screen. That is a fair trade and we would make it again, but it is worth naming: with XCUITest, some of your test harness ships to users.

Where the tests are allowed to run

MailVault's headless end-to-end suite runs on an ordinary Ubuntu GitHub Actions runner. The real Tauri binary is built for Linux, launched inside a virtual framebuffer and a message-bus session, and clicked through by WebDriver on hardware that costs nothing per minute. The exact incantation is below.

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

There is no equivalent line for XCUITest. It needs macOS, a window server, and a real user session, because it is genuinely moving a pointer and pressing keys. MeatPad's repository has exactly one GitHub workflow and it builds releases. The tests live on the mini, which is the honest answer to "where does a small studio run Mac UI tests", and the reason that machine exists at all. We wrote about how it earns its keep for MailVault in an earlier note.

The part of MeatPad no UI test will ever reach

Look again at where MeatPad's 31 interface tests are: card display, the card editor, labels, newlines, search. All of it is the board. Not one of them touches the editor, which is the actual product.

The editor wraps STTextView on TextKit 2 inside an NSViewRepresentable, and to the accessibility tree it is one opaque region containing text. Multiple cursors, snippet placeholders, code folding, incremental tree-sitter highlighting, bracket matching: none of it exists as elements to query. You cannot assert that the second caret landed on line forty, because there is no element called "the second caret".

So that work is not tested through the interface. It is tested underneath it. MeatPadKit is a separate Swift package, 47 source files, holding the fold scanner, the multi-caret model, the snippet parser, the highlighter, the fuzzy matcher, the LSP position bridge, the project symbol index. It has no view code in it, which is exactly why 601 tests can run against it in eleven seconds with no window on screen.

The comment at the top of one of MeatPad's UI tests puts the division better than we can paraphrase it: MeatPadKit unit-tests the matcher itself, and what it cannot reach is whether the field is wired to the columns at all. That is the whole job description of a SwiftUI UI test. Not "does the logic work", which a fast headless test already answered, but "is it plugged in", which nothing else can answer.

Start with a note. Stay for the editor. MeatPad is a native macOS notebook and code editor: plain files on your Mac, no account, no sync engine, no telemetry.

See MeatPad

What this actually changes about how you build

The lesson is not that SwiftUI is bad. It is that the cost of verifying a native interface is high enough to be an architectural input rather than an afterthought.

  • Push logic out of the view until the view is boring. MeatPad is 51 source files of interface and 47 of framework, and the framework side carries 95 percent of the tests. Anything left in a SwiftUI view is expensive to check, so the correct amount to leave there is layout.
  • Spend interface tests on wiring, not behaviour. At twelve seconds each they are the wrong tool for edge cases and the right tool for "the search field is connected to the column".
  • Add identifiers when you write the view, not when you write the test. Retrofitting them means editing production files to make a test possible, which is exactly when people stop writing tests.
  • If your interface is a web view, take the free lunch. Being able to run a query inside your own running application is a genuine advantage of the Tauri stack, and it is why MailVault could add 31 end-to-end specs in a single month.

What is not covered

Two honest gaps, since a testing article that only reports the wins is a brochure.

MeatPad's editor has no interface-level coverage at all. The logic beneath it is tested thoroughly, and the glue between that logic and the text view is checked by a person. A regression there would reach a release. Chipping away at that with a small number of expensive tests, on the interactions worth twelve seconds each, is next.

On MailVault's side, only the headless suite runs in continuous integration. The 41 seeded specs and the 5 local ones run on the mini, on demand, which means they catch things before a release rather than before a merge. And a green suite still only proves the things somebody thought to check, which is why the number in that table matters less than the fact that it keeps moving.

Reproduce any of it

# 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

The last command is the one that takes two minutes. Now you know why.

Two apps, one machine that keeps them honest. Local-first Mac software from a studio that publishes its measurements, including the unflattering ones.

See what we build