선반 위에 16 GB 메모리를 갖춘 Apple M4 Mac mini 한 대가 있습니다. macOS 15, Xcode 26.6, Swift 6.3.3을 돌리며, 로컬 네트워크에서 오직 SSH로만 접근할 수 있습니다. 2026년 9월 5일, 이 기계는 오후 내내 우리의 두 Mac 앱 모두의 인터페이스 테스트를 화면 녹화와 함께 연달아 실행했습니다. MeatPad, 우리의 macOS 노트 및 코드 편집기는 XCUITest로 테스트되는 SwiftUI 앱입니다: 케이스 50개, 12:58부터 시작합니다. MailVault, 우리의 로컬 우선 이메일 아카이버는 WebdriverIO로 테스트되는 Tauri 앱입니다: 케이스 539개, 14:43부터 시작합니다. 이로써 카메라에 담긴 테스트 케이스는 한 대의 기계에서, 한 번의 오후 동안 총 589개가 되며, 두 실행은 서로 전혀 다르게 움직입니다.

1막: MeatPad, 405초 동안 50번의 실행

YouTube에서 보기

화면 왼쪽에서, MeatPad 0.15.0을 상대로 한 명령 하나:

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

그 녹화 속 나머지 모든 것은 이 한 줄의 결과입니다. Xcode는 앱과, 테스트 러너라 불리는 두 번째 번들을 빌드한 다음, 둘 다 기계에 넘깁니다. 테스트 케이스마다 러너는 앱을 처음부터 다시 실행하고, 창이 뜨기를 기다리고, 포인터를 움직이고, 키를 누르고, 돌아온 결과를 읽은 뒤, 앱을 종료합니다. 그리고 다음 케이스를 위해 다시 처음부터 반복합니다. 창이 나타났다 사라지는 것은 시각 효과가 아닙니다. 그것이 바로 이 루프입니다.

XCUITest 케이스 하나에 몇 초씩 드는 이유

이번 실행은 50개 케이스를 405.032초 만에 끝냈습니다. 50개 중 하나는 일부러 자기 자신을 건너뛰므로, 실제로 실행된 49개는 평균 약 8.3초씩 걸린 셈입니다. 인터페이스 아래에 있는 프레임워크인 MeatPadKit과 비교해 보면, 그곳에서는 테스트 하나가 평균 19밀리초입니다.

그 차이를 만드는 것은 세 가지입니다. 앱은 케이스마다 한 번씩 새로 실행되므로, 매 케이스가 콜드 스타트와 창이 안정되기를 기다리는 값을 치릅니다. 테스트는 별도의 프로세스에서 돌며 macOS 접근성 트리를 통해서만 앱을 볼 수 있으므로, 화면에 대한 질문 하나하나가 메모리 읽기가 아니라 프로세스 경계를 넘는 쿼리가 됩니다. 그리고 포인터와 키보드가 진짜이므로, 드래그 한 번은 진짜 드래그만큼의 시간이 걸립니다.

일회용 세계 쉰 개

카드를 클릭하는 케이스에는 클릭할 카드가 있어야 합니다. 실행 중인 프로세스에는 아무것도 주입되지 않으므로, 상태는 앱이 시작되기 전에 이미 존재해야 합니다. 각 케이스는 시스템 임시 폴더에 새 저장소 디렉터리를 만들고, 그 안에 보드를 하나 심은 뒤, 그 디렉터리를 가리키는 앱을 실행합니다.

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

이 세 플래그는 릴리스 바이너리 안의 진짜 코드입니다. 테스트가 어떤 화면에 도달할 수 있도록 존재하며, 모든 사용자에게 그대로 출시됩니다. 이것이 네이티브 인터페이스를 외부에서 테스트하는 데 드는 정직한 비용이고, 다시 하더라도 같은 값을 치르겠지만, 누군가 소스를 읽다가 발견하기보다는 이렇게 소리 내어 말해두는 편이 낫습니다. 좋은 점은 이 실행이 아무 흔적도 남기지 않는다는 것입니다: 모든 케이스가 종료 단계에서 자신의 디렉터리를 삭제하므로, 여러분 자신의 보드는 전혀 건드리지 않으며, 실패 하나가 다음 케이스를 오염시킬 수도 없습니다.

메모로 시작하세요. 편집기로 이어가세요. 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

열두 개의 스위트가 있고, 각각은 자신이 지키는 대상의 이름을 땄습니다: 세 가지 카드 표시 모드, 색상 스와치와 마감일을 갖춘 카드 편집기, 제목을 그 자리에서 편집하기와 카드를 열 사이로 드래그하기, 카드 메모 속 링크, 드롭 대상, 라벨과 라벨 필터링, 빠른 추가 입력란의 줄바꿈, 검색, 새 보드 시트의 키보드 포커스, 메모 편집기의 링크 힌트, 그리고 Launch Services에 MeatPad가 자신이 지원한다고 선언한 모든 파일 형식에 대해 제시되는지 묻는 여섯 개의 케이스.

가장 비싼 것은 BoardLabel로, 케이스 8개에 102.638초가 걸립니다. 라벨 필터링이 키 입력 후 한두 프레임 뒤에야 카드를 화면에서 지우기 때문에, 그 케이스들은 열이 안정되기를 기다리며 폴링 루프에 앉아 있게 됩니다. 이번 실행에서 가장 느린 단일 케이스는 27.992초로, 긴 카드 제목이 컴팩트 표시 모드에서는 잘리고 나머지 두 모드에서는 줄바꿈되는지를 확인하는 것이었는데, 이는 같은 제목을 세 번의 실행에 걸쳐 세 번 측정한다는 뜻입니다. 가장 빠른 케이스는 1.507초로, Launch Services에 MeatPad가 자신이 등록한 각 파일 형식에 대해 제시되는지 묻는 것이었으며, 앱이 실행 중이어야 하지만 창은 전혀 건드리지 않습니다.

당신을 속이는 요약 줄

MeatPad의 Xcode 프로젝트는 매니페스트로부터 생성되며 커밋되지 않습니다. 이는 테스트 파일을 추가하고 재생성을 잊은 채 그대로 스위트를 돌리는 날까지는 편리합니다. xcodebuild의 관점에서는 아무 문제가 없습니다. 주어진 프로젝트를 컴파일하고, 그 안의 테스트 0개를 실행한 뒤, 모두가 찾는 그 두 단어를 출력합니다:

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

케이스 쉰 개, 하나는 일부러 건너뜀, 실패 없음, 405.032초. 개수와 마지막 줄이 일치하는 것, 그것만이 신뢰할 가치가 있는 조합입니다.

초록색 테이크 이전에 있었던 일

녹화된 실행은 그날의 첫 실행이 아니었습니다. 녹화되지 않은 12:19 실행은 빨간불이었습니다: 같은 50개 케이스, 1개 건너뜀, 그리고 2개 실패였는데, 둘 다 OpenWith에 있었습니다. 멀티 선택 케이스와 두 번째 파일 케이스로, 위 발췌에서 각각 3.799초와 4.066초에 통과하는 모습을 볼 수 있는 바로 그 둘입니다. 크래시 리포트에 따르면 앱은 실행 후 0.75초 만에 중단되었는데, SwiftUI 자체의 실행 경로 내부에서, Dock이 아직 앱에 알림을 보내는 중이던 applicationWillFinishLaunching에서 발생한 AttributeGraph 전제 조건 실패 때문이었습니다. 그 시점에는 아직 아무것도 앱에 전달되지 않았으므로, 이것은 파일 열기 코드의 문제가 아닙니다. 같은 6개의 OpenWith 케이스를 그 직후 따로 세 번 돌렸을 때는 3번 모두 통과했습니다. 이는 18개 케이스 중 18개이며, 녹화된 실행에서도 6개 모두 다시 통과했습니다. 스위트가 앱을 연달아 재실행할 때만 나타나는 실행 타이밍 결함처럼 보입니다. 아직 조사 중이며, 어떤 것도 편집으로 잘라내지 않았습니다.

설정 과정에서 발견한 것 하나는 따로 문단을 줄 만합니다. 앱을 터미널 옆에 맞추기 위해, 먼저 MeatPad의 저장된 창 프레임을 1440x900 포인트에서 1010x900으로 줄였습니다. 그러자 케이스 네 개가 한꺼번에 실패했습니다. 카드 편집기의 색상 스와치, 라벨 필터, 검색이었는데, 모두 순전히 찾으려는 요소가 화면 밖에 있었기 때문이었습니다. 원래의 1440x900 프레임으로 다시 실행하자 같은 케이스들이 9개 중 9개 모두 통과했습니다. 창 크기는 UI 테스트의 입력값이며, 이 스위트는 앱의 기본 프레임을 암묵적으로 전제하고 있습니다. 그래서 녹화 영상에서 MeatPad는 오른쪽에 기본 크기로, 좁은 터미널은 왼쪽에 나오는 것입니다.

이전 테이크 두 개는 MeatPad와는 무관한 이유로 폐기되었습니다: 하나에는 12:19 실행이 남긴 크래시 대화 상자가 화면에 그대로 떠 있었고, 다른 하나는 다른 작업에서 온 MailVault 스위트가 도중에 자신의 창들을 위에 올려버렸는데, mini가 공유 기계이기 때문입니다. 이는 오후 후반부를 소개하기에 딱 좋은 사례입니다.

2막: MailVault, 열여섯 분 동안 스펙 파일 81개

YouTube에서 보기

MailVault 2.11.3은 다른 방식으로 만들어졌습니다: Rust 코어에 웹 프런트엔드를 얹고, Tauri로 패키징합니다. 엔드투엔드 스위트는 WebdriverIO로 구동되며, 14:43의 명령은 세 스위트 중 두 개를 요청했습니다:

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

ui-headless 스위트는 계정이 하나도 설정되지 않은 환영 상태를 다루는 스펙 파일 7개입니다. connected-ci 스위트는 시드된 모의 IMAP 계정을 상대로 실행되는 스펙 파일 74개입니다. 합쳐서 스펙 파일 81개입니다. 세 번째 스위트인 local-manual은 백업, 마이그레이션, 아카이브, 시각적 검사를 위한 스펙 파일 6개를 더 갖고 있지만, 이번 실행에는 포함되지 않았습니다.

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

스펙 파일 80개가 통과했고, 1개가 실패했으며, 합계 81개, wdio 자체 시계로 16분 3초가 걸렸습니다. 영상은 16:18로 나오는데, 명령이 시작되기 전에 녹화가 시작되고 끝난 뒤에 멈추기 때문입니다. 이 파일들 아래에는 테스트 케이스 539개가 있습니다: 533개 통과, 5개 건너뜀, 그리고 앞서 말한 1개 실패. ui-headless 절반은 테스트 시간 약 56초 동안 55개 통과와 3개 건너뜀을 기여했고, connected-ci는 약 811초 동안 478개 통과, 1개 실패, 2개 건너뜀을 기여했습니다.

앱은 케이스마다가 아니라 스펙 파일마다 한 번씩 실행되므로, 이날 오후의 MailVault 케이스 539개는 실행 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개에 담긴 vitest 단위 테스트 2,683개가 6.77초 만에 끝나고, Rust 테스트 318개가 코어를 검사하며, 녹화된 두 스위트는 소스상으로 it() 케이스 518개입니다.

녹화에서 두 가지는 짚어둘 필요가 있습니다. 왼쪽 터미널은 wdio의 파일별 RUNNING, PASSED, FAILED 줄만 출력하는데, wdio가 상세 리포트를 실행의 맨 끝을 위해 아껴두기 때문입니다. 그리고 막바지에는 export 스펙이 내보낸 메시지를 Preview로 여는데, 이것이 마지막 1분 동안 터미널을 가리게 됩니다. 이는 사고가 아니라 테스트가 제 할 일을 하는 것입니다.

하네스가 준비하는 것

각 실행은 mock.test의 luke, vader, yoda라는 세 개의 시드 계정으로 자체 모의 IMAP 서버를 실행하며, vader의 INBOX는 700개의 메시지를 담고 있어 앱의 페이징 윈도우보다 넉넉히 많으므로, 페이지네이션이 가정이 아니라 실제로 검증됩니다. 네 번째 계정은 화면에서 직접 타이핑되는 모습을 볼 수 있는 테스트에 의해 추가됩니다. 테스트 대상 앱은 webdriver 기능이 켜진 빌드로, tauri-webdriver-automation 크레이트인 tauri-wd에 의해 시작되며, 일회용 HOME 디렉터리를 상대로 실행되므로 실제 vault는 전혀 건드리지 않습니다. 그 하네스에 대해서는 필드 노트 004.

하나의 실패, 분류되지 않음

539개 케이스 중 하나가 실패했습니다: 스펙 connected-storage-matrix, 테스트 "a row deleted in unified mode stays gone across account churn and a reload". 이 테스트의 헬퍼 switchToUnified는 대기 없이 사이드바의 모든 받은편지함 버튼이 존재하고 보이는지를 즉시 단언하는데, 직전 테스트가 앱을 리로드한 직후에 실행됩니다. Expected true, received false. 이후 이 스펙 하나만 다시 돌려볼 수 없었는데, 그 체크아웃에서 새 드라이버 세션을 시작하려는 모든 시도가 드라이버에 닿기도 전에 세션 요청에서 undici UND_ERR_INVALID_ARG로 실패했기 때문입니다. 그래서 이 실패는 분류되지 않은 채로 남습니다. 제품 버그라기보다는 리로드 이후의 대기 누락처럼 보이며, 영상에서도 그대로 남아 있습니다.

케이스당 1.8초 대 8.3초

실제 경과 시간 963초 동안의 MailVault 케이스 539개는 케이스당 약 1.8초입니다. MeatPad의 실제로 실행된 케이스 49개는 405.032초 동안 각각 약 8.3초입니다. 같은 기계, 같은 오후인데도, 둘 사이에는 대략 다섯 배의 차이가 있습니다.

하나의 영리한 최적화가 아니라, 구조적인 이유 두 가지 때문입니다. WebDriver는 실행 중인 애플리케이션 내부에서 JavaScript를 실행하고 DOM을 직접 읽으므로, 검사는 macOS 접근성 트리로 프로세스 경계를 넘어가는 대신 엔드투엔드 스펙 안의 494개 data-testid 훅 중 하나를 대상으로 이루어집니다. 그리고 실행 산술도 다릅니다: MailVault 케이스 539개는 앱 실행 81번의 비용이 들지만, MeatPad 케이스 50개는 실행 50번의 비용이 듭니다. 이 격차는 몇 주 전 필드 노트 006.

그렇다고 어느 한쪽 앱이 더 잘 테스트되었다는 뜻은 아닙니다. 두 스위트는 서로 다른 제품을 검사하는 것이고, 케이스당 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의 그 한 가지 실패까지 포함해서요. MailVault 소스는 MeatPad 소스 옆, GitHub.

측정값이 함께 딸려 오는 로컬 우선 Mac 소프트웨어. MailVault는 여러분의 메일을 여러분 자신의 디스크 위 vault에 보관하며, 매 릴리스 전에 기계 한 대가 앱 전체를 클릭해 나갑니다.

MailVault 살펴보기