책상 밑에는 메모리 16GB짜리 Apple M4 Mac mini가 있고, 그 일은 우리 앱이 아직 잘 도는지 알아내는 것입니다. MailVault 는 Tauri로 만들었습니다. MeatPad 는 SwiftUI로 만들었습니다. mini는 두 스위트를 모두 돌립니다. 같은 기계, 같은 오후, 테스트를 쓰는 사람도 같습니다.

두 스위트의 비용은 같지 않습니다. 비슷하지도 않습니다. 아래의 모든 것은 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

각 블록의 마지막 두 줄을 서로 맞대어 읽어 보세요. 거기에 이 글의 전부가 있습니다. MailVault는 창 없는 검사 1,440개를 실제 시간 7초 미만에 끝내고, 게다가 자기 실제 바이너리를 종단 간 스펙 53개로 몰아볼 수 있습니다. MeatPad는 창 없는 검사 601개를 11초에 끝내고, 실제 인터페이스를 건드리는 테스트는 31개입니다.

그리고 표에 들어가지 않는 숫자가 있습니다. 그 31개 인터페이스 테스트 중 여덟 개를 mini에서 돌리는 데 98.186초가 걸렸습니다. 개별 케이스는 6.9초에서 17.2초 사이였습니다. 테스트당 12초라고 보면 됩니다.

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

같은 기계에서 MeatPadKit 단위 테스트는 평균 19밀리초입니다. MailVault의 vitest 케이스는 평균 2밀리초 미만입니다. SwiftUI 뷰에 대한 시뮬레이션 클릭 한 번은 단위 테스트 약 600개에 해당하는 비용입니다.

8월을 두 개의 곡선으로

MailVault는 이번 달을 테스트를 받아 쓰는 데 보냈고, 그 모양은 저장소의 테스트 케이스를 주 단위로 세어 보면 쉽게 드러납니다.

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일 만에 거의 두 배가 됐고, 종단 간 스펙은 두 배를 넘겼습니다. 이 증가는 앱 전체에 고르게 퍼진 것이 아닙니다. 버그가 있던 곳으로 갔습니다. 작성과 임시 보관, 다중 선택, 무언가를 바꾸는 동작마다 디스크에 있는 내용과 일치해야 하는 저장소 매트릭스, 그리고 계정 전환입니다. 그 구간의 커밋 제목들은 자백 목록처럼 읽히는데, 테스트 기록은 그렇게 읽히는 것이 맞습니다.

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개와 함께 출시됐고, 그중 창을 여는 것은 하나도 없었습니다. 첫 인터페이스 테스트가 쓰인 것은 출시 한 달 뒤인 8월 24일입니다. 그 공백은 게으름이 아닙니다. 이 글의 나머지가 바로 그 이야기입니다.

정말로 확인해 볼 수 있는 메일 보관함. MailVault는 메일함을 로컬에 백업하고 보관하며, 그중 무엇이든 여러분에게 닿기 전에 종단 간 스펙 53개가 실제 애플리케이션을 몰아 봅니다.

MailVault 방문

나머지 모두를 설명하는 차이

Tauri 앱의 인터페이스는 웹 페이지입니다. 테스트 하네스는 그것과 WebDriver로 대화하는데, 이는 하네스가 실행 중인 애플리케이션 안에서 코드를 실행할 수 있다는 뜻입니다.

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

저 화살표 함수는 테스트 쪽에서 평가되는 것이 아닙니다. 앱 자신의 webview 안으로 보내져 거기서 살아 있는 문서를 상대로 실행되고, 그 결과가 돌아옵니다. 테스트는 애플리케이션 자신과 똑같은 방식으로 인터페이스에 질문할 수 있습니다.

XCUITest는 이렇게 할 수 없고 앞으로도 못 합니다. 별도 프로세스로서 macOS 접근성 계층을 통해 바깥에서 앱을 조종합니다. 임의의 코드를 넣을 통로가 없고, 접근성 트리가 이미 답하지 않는 질문을 뷰에 던질 방법도 없습니다. 뒤따르는 모든 것은 그 하나의 경계에서 비롯됩니다.

문서 안에서는 모든 것에 손잡이가 있다

MailVault의 종단 간 스위트는 테스트 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
}

카드는 그것들을 걸러낸 키 입력보다 한두 프레임 뒤에 뷰 계층을 떠납니다. 그래서 입력 직후의 단 한 번의 단언은 동전 던지기입니다. 상호작용 뒤에 목록을 확인하는 테스트는 모두 이 루프가 필요하고, 그 루프 하나하나가 진짜 실패가 10초 동안 느린 성공처럼 보일 수 있는 자리입니다.

앱을 알려진 상태로 만들기

MailVault의 스위트는 계정마다 700통을 심어 둔 받은편지함을 가진 모의 IMAP 서버를 띄우고, 앱을 그쪽으로 향하게 하고, 실행 전체에 일회용 홈 디렉터리를 줍니다. 테스트 대상 애플리케이션은 전혀 고쳐지지 않았습니다. 자기가 테스트당하고 있다는 것을 모릅니다.

MeatPad에는 알려 줘야 합니다. UI 테스트가 저장용 디렉터리를 디스크에 쓴 다음, 출시되는 바이너리가 이해하는 인자를 붙여 앱을 실행합니다.

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

이 플래그들은 공개된 애플리케이션 안의 진짜 코드이고, 테스트가 어떤 화면에 닿을 수 있도록 존재합니다. 공정한 거래이고 다시 하래도 그렇게 하겠지만, 짚고 넘어갈 가치는 있습니다. XCUITest에서는 테스트 하네스의 일부가 사용자에게 배포됩니다.

테스트가 돌아도 되는 곳

MailVault의 창 없는 종단 간 스위트는 평범한 Ubuntu GitHub Actions 러너에서 돕니다. 진짜 Tauri 바이너리를 Linux용으로 빌드해서, 가상 프레임버퍼와 메시지 버스 세션 안에서 띄우고, 분당 비용이 0인 하드웨어 위에서 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 워크플로가 정확히 하나 있고 그것은 릴리스를 빌드합니다. 테스트는 mini 위에 삽니다. 「작은 스튜디오는 Mac UI 테스트를 어디서 돌리는가」에 대한 정직한 답이자, 그 기계가 존재하는 이유이기도 합니다. 그것이 MailVault를 위해 어떻게 제 몫을 하는지는 여기에 썼습니다. 이전 노트.

어떤 UI 테스트도 결코 닿지 못할 MeatPad의 부분

MeatPad의 인터페이스 테스트 31개가 어디에 있는지 다시 보세요. 카드 표시, 카드 편집기, 라벨, 줄바꿈, 검색. 전부 보드입니다. 진짜 제품인 편집기를 건드리는 것은 하나도 없습니다.

편집기는 TextKit 2 위의 STTextView를 NSViewRepresentable로 감싼 것이고, 접근성 트리에서 보면 텍스트가 담긴 불투명한 영역 하나일 뿐입니다. 다중 커서, 스니펫 자리표시자, 코드 접기, tree-sitter 기반 증분 하이라이팅, 괄호 짝 맞추기. 그 어느 것도 질의할 수 있는 요소로 존재하지 않습니다. 두 번째 캐럿이 40행에 놓였다고 단언할 수 없습니다. 「두 번째 캐럿」이라는 요소가 없기 때문입니다.

그래서 그 작업은 인터페이스를 통해 테스트되지 않습니다. 그 아래에서 테스트됩니다. MeatPadKit은 별도의 Swift 패키지로 소스 47개 파일이며, 접기 스캐너, 다중 커서 모델, 스니펫 파서, 하이라이터, 퍼지 매처, LSP 위치 변환, 프로젝트 심벌 인덱스를 담고 있습니다. 뷰 코드가 없고, 바로 그 덕분에 601개 테스트가 화면에 창 하나 없이 11초 만에 돌 수 있습니다.

MeatPad의 UI 테스트 중 하나의 첫머리에 있는 주석이 이 분담을 우리가 바꿔 말하는 것보다 잘 표현합니다. 매처 자체는 MeatPadKit이 단위 테스트하고 있고, 거기서 닿지 못하는 것은 그 필드가 애초에 열들과 연결되어 있는지다, 라고요. 그것이 SwiftUI UI 테스트의 직무 기술서 전부입니다. 「로직이 동작하는가」가 아니라, 그건 빠른 창 없는 테스트가 이미 답했습니다. 「연결되어 있는가」이고, 그건 다른 무엇도 답할 수 없습니다.

메모로 시작하세요. 편집기로 이어가세요. MeatPad는 macOS 네이티브 노트 겸 코드 편집기입니다. 여러분의 Mac에 있는 평범한 파일, 계정 없음, 동기화 엔진 없음, 텔레메트리 없음.

MeatPad 살펴보기

이것이 만드는 방식을 실제로 바꾸는 지점

교훈은 SwiftUI가 나쁘다는 것이 아닙니다. 네이티브 인터페이스를 검증하는 비용이 뒤늦은 생각이 아니라 설계의 입력값이 될 만큼 충분히 높다는 것입니다.

  • 뷰가 지루해질 때까지 로직을 뷰 밖으로 밀어내세요. MeatPad는 인터페이스 소스 51개 파일과 프레임워크 47개 파일로 되어 있고, 테스트의 95퍼센트는 프레임워크 쪽이 짊어집니다. SwiftUI 뷰에 남는 것은 무엇이든 확인 비용이 비싸므로, 거기에 남겨 둘 적정량은 레이아웃입니다.
  • 인터페이스 테스트는 동작이 아니라 연결에 쓰세요. 하나에 12초라면 가장자리 사례를 확인하는 도구로는 틀렸고, 「검색 필드가 열과 연결되어 있다」를 확인하는 도구로는 맞습니다.
  • 식별자는 테스트를 쓸 때가 아니라 뷰를 쓸 때 붙이세요. 나중에 붙인다는 것은 테스트를 가능하게 하려고 제품 코드 파일을 고친다는 뜻이고, 그때가 바로 사람들이 테스트 쓰기를 그만두는 순간입니다.
  • 인터페이스가 webview라면 공짜 점심을 드세요. 실행 중인 자기 애플리케이션 안에서 질의를 돌릴 수 있다는 것은 Tauri 스택의 진짜 장점이고, MailVault가 한 달 만에 종단 간 스펙 31개를 더할 수 있었던 이유이기도 합니다.

다루지 못한 것

정직한 구멍 두 가지. 이긴 것만 보고하는 테스트 글은 홍보 책자일 뿐이기 때문입니다.

MeatPad의 편집기에는 인터페이스 수준의 커버리지가 전혀 없습니다. 그 아래 로직은 충분히 테스트되어 있고, 그 로직과 텍스트 뷰 사이의 접착제는 사람이 확인합니다. 거기서 생긴 퇴행은 릴리스까지 갈 것입니다. 하나에 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분 걸리는 그것입니다. 이제 이유를 아시겠지요.

앱 두 개와, 그 둘을 정직하게 지키는 기계 한 대. 측정값을, 불리한 것까지 포함해 공개하는 스튜디오가 만드는 로컬 우선 Mac 소프트웨어.

우리가 만드는 것 보기