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 →