90枚。6言語(en、ru、es、ja、zh-Hans、ar)× 2セット(サイトとApp Store)× 2テーマ。MeteoHealth の撮影リストを手作業でこなせば何日もかかる。そして問題は時間だけではない。「アナリティクス」が相関を描画し終えた「後」に — 前ではなく — 「睡眠」のシートを開く、という手順を人間は忘れてしまう。
そこでXCUITest上にハーネスを組んだ。4つのテストクラスが連携して、90枚のうち86枚を人間のタップ一切なしでカバーする。残りの4枚はカメラで撮る心拍数のフレームで、シミュレータには物理的にカメラが存在しない。以下では、この工場の仕組みと、スクリーンショットそのもの以外に工場が見つけたものを紹介する。
launch argument の裏に隠れたシーダー#
実ユーザーのプロフィールをそのまま使うことはできない。30日分の履歴もなければ、指標間の相関もなく、なにより人によって中身が違う。
デモプロフィール ScreenshotSeederSite は launch argument MH_SCREENSHOT_SEED の裏に完全に隠れて存在し、通常ビルドでは文字どおり何もしない — no-opであり、明示的な起動引数なしには物理的に実行されないコードだ。シーダーは周期、喫煙、服薬履歴つきの薬、水分、エネルギーとストレスの履歴を書き込み — さらに第2の引数 MH_SCREENSHOT_SEED_HEALTH の裏で、30日分のHealthKitデータ(睡眠フェーズつきの睡眠、HRV、安静時心拍数、歩数)を書き込む。
この分離は偶然ではない。「睡眠」と「ストレス」の画面はHealthKitを直接読み、Core Dataに自前のコピーを持たない。これがないとフレーム上で空っぽになる — ドキュメントからではなく、真っ黒なカードが並んだ最初の撮影ランから学ぶ事実だ。
func test_0_prepareDemoProfile() {
let app = XCUIApplication()
app.launchArguments += [
"SKIP_ONBOARDING",
"MH_SCREENSHOT_SEED",
"MH_SCREENSHOT_SEED_HEALTH", // ここだけ:ScreenshotSeederSite を参照
"-AppleLanguages", "(en)",
"-AppleLocale", "en_US",
"-\(themeDefaultsKey)", Theme.light.rawValue
]
app.launch()
acceptAllHealthSheets(app)
sleep(25) // シードを Core Data と HealthKit に書き込む
...
}スイート全体の中で「ヘルスケア」アクセスのシステムダイアログを目にする唯一のテストがこれで、名前順で最初に実行される(test_0_)。以降は許可が既に付与済みで、再シードはデータベース非空チェックによりスキップされ、このクラスの残り340行がそのダイアログに再び出会うことはない。
ラベル検索の代わりにルートを使う#
フレーム6–13は「今日」の詳細シート(睡眠、ストレス、周期、栄養、薬、喫煙)とアナリティクスの各セクションだ。「今日」を開いてラベルをタップしてカードを探す方法は通用しない。カードは動的にソートされ、順序は言語ごとに異なる。英語での位置を頼りに「睡眠」を探すテストは、日本語のフレームでは別の何かを開いてしまう。
解決策はUI検索そのものを迂回することだ。画面は launch argument で直接開く:
private func captureTodaySheet(_ route: String, name: String, language: String, locale: String, theme: Theme) {
let app = launch(
language: language, locale: locale, theme: theme,
extraArguments: ["MH_SCREENSHOT_ROUTE", route]
)
sleep(4) // シートは onAppear から開き、コンテンツは後から読み込まれる
shoot(name)
app.terminate()
}アナリティクスのセクションも同じ仕組みだが、専用の引数 MH_SCREENSHOT_ANALYTICS を使う。「予報」のフレームには繊細な点がある。撮影前にテストはまず「アナリティクス」に14秒間入り、その後で初めて「予報」に切り替える。
理由は迷信ではない — 相関はアナリティクスを開いた時に一度だけ計算され(CorrelationEngine.correlations はエンジンのメモリ上に生きている)、「予報」のリスクカードは計算済みの結果を読むだけだからだ。この立ち寄りがなければ、データベースにどれだけ履歴があろうと、画面は正直に「あなたのリズムを学習中」と描画する。
SpringBoard は敵地である#
フレーム5 — ホーム画面のウィジェット — はセットの中で唯一、アプリ内ではなくSpringBoardに生息する部分で、これがすべてを変える。462行の SiteWidgetsUITests は工場で最も長いファイルであり、その大部分は、SpringBoardが期待と違う応答を返すことへの防御だ。
罠は3つ。どれもドキュメントではなく、実際に撮影セットが失敗したことで見つかった。
1つ目 — 新しい言語でシミュレータを再起動した直後、springboard.icons は空のリストを返す。ウィジェットは物理的に画面上にあるのにだ。SpringBoardがまだホーム画面を再構築している最中に、クエリが先に応答を返してしまう。治療法はタイムアウトではなくリトライだ:
private func existingWidgetCount() -> Int {
var count = 0
for attempt in 0 ..< 6 {
count = springboard.icons
.matching(NSPredicate(format: "identifier == %@", "MeteoHealth"))
.allElementsBoundByIndex
.filter { $0.frame.width > 200 }
.count
if count >= 2 { return count }
if attempt < 5 { sleep(4) }
}
return count
}コード内のコメントは初期バージョンが払った代償を正直に記している。テストは空の応答を信じ、既に置かれていたウィジェットを削除して置き直しに行き、削除確認のところで立ち往生した — そしてそれを押さえ込んでいるのがちょうど2つ目の罠だ。
2つ目 — システムアラートのボタンは頻繁に isHittable == false を返し、通常の tap() は黙って何もしない。落ちもせず、エラーも報告せず、ループはただタイムアウトまで回り続ける。widgets-es セットを台無しにしたのはまさにこのメカニズムで、削除アラートが表示されたまま残り、撮影ラン全体がそれにぶつかり続けた。回避策はどこでも同じ — 要素の中心座標をタップする:
func tapCenter(_ element: XCUIElement) {
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}3つ目 — ラベルを一切使わないアドレッシング。ホーム画面の編集メニューを開くボタンは、ロシア語では «+»、スペイン語では «Editar» と呼ばれる — フレームごとに切り替わるシステム言語での名前だ。機能する経路は座標ベースのみで、テキストではなく画面のジオメトリに紐づける。
RTL と iPad:ページの順序までもが鏡映しになる場所#
アラビア語はミラーリングを持ち込む。SpringBoardのウィジェット設定画面にあるバリエーションのカルーセルは右から左にしかスワイプしておらず、RTLインターフェースではその方向は「戻る」を意味する。8回連続の試行はすべて同じ追加済みバリエーションを返し、関数は正直に「見つからない」と答え、widgets-ar セットはウィジェット2つのはずが1つで仕上がった。修正は両方向を試すこと:
let directions: [(from: CGFloat, to: CGFloat)] = [(0.85, 0.15), (0.15, 0.85)]
for direction in directions {
for _ in 0 ..< 8 {
// ...
}
}ホーム画面のページも同様だ。言語切替後は、どちら向きのスワイプがウィジェットにたどり着くのか分からないため、撮影は一方向に賭けるのではなく両方向を試す。
iPadが加えるのは言語ではなくレイアウトの次元だ。iPadではタブバーが下ではなく上にあり、iPhoneの下部バー向けの座標フォールバックは誤ったタブを開いた — 目的の画面ではなくエネルギーのカードを開く、といった具合に。解決策は座標より洗練されている。ネイティブの TabView はタブボタンを、tabItem の SF Symbol 名と等しい identifier つきで公開する — あまり知られていないが信頼できる事実で、両方のレイアウトで機能する:
let order: [(symbol: String, id: String, slot: Int, name: String)] = [
("cloud.sun.fill", "tab_forecast", 1, "5_forecast"),
("chart.xyaxis.line", "tab_analytics", 3, "3_analytics"),
("book.fill", "tab_journal", 2, "4_journal")
]座標はiPhone用のフォールバックとしてのみ残った。スクロールも同じ道をたどった。swipeUp() はiPadの重いアナリティクスダッシュボードでタイムアウトした — 階層全体のスナップショットを要求するからで、座標ドラッグはスナップショットを構築しない。2つのデバイスのファイルは UIDevice.current.userInterfaceIdiom に基づき iPhone_/iPad_ のプレフィックスで分けられる。
撮影そのものの罠はここまでだ。難しかったのは「どうやってフレームを撮るか」ではなく、「そのフレームに何を映すべきか」という問いだった。
デモデータは統計検定を通過しなければならない#
この工場の最も非自明な発見 — デモデータもまたコードであり、責務を負ったコードだということ。「予報」画面は、気圧と体調の関連について「あなたのリズムを学習中」ではなく、意味のある結論を表示しなければならない。シーダーの初期バージョンは、関連をステップ関数として書いていた。「下がった → 2…4、そうでなければ 6…9」。
30日間ではこれが r ≈ 0.45、補正前の p ≈ 0.012 を与える — 一見有意だ。しかしエンジンは検定済みの全因子ペア(約~100組)に対して Benjamini-Hochberg 補正を実行し、補正後には p 値が沈んでしまう。画面は自らの結論を正直に「暫定的」とマークした — App Store 用のフレームの中でだ。
修正ではステップを線形の依存関係に置き換えた。r ≈ 0.72、t = 5.5 で、個々の評価のばらつきは 1 から 10 までフルに残る。この関連は補正を通過し、確信を持った判定を出す。
2つ目のケースは同じ性質の逆パターンだ。頼んでもいない場所に相関が現れた。シードしたエネルギー履歴が気温の波と位相で一致し、「相関」画面の先頭に「気温 → エネルギー +0.96」という行が表示された — 実データではあり得ない係数だ。エネルギー履歴を独立した波で気温から切り離し、期待どおりの関連 — 「気圧変化 → 体調 +0.72」— が先頭に来るようにした。
デモデータ工場すべてへの教訓:アプリが統計的な結論を表示するなら、デモプロフィールは、実ユーザーのデータと同じ統計検定を正直に通過するデータを生成する義務がある。だが、正直なフレームが暴き出すのは統計だけではない。
フレームはテストが見つけないものを見つける#
プロジェクトには900を超えるグリーンのテストがあるが、完成したフレームを普通に眺めるだけで見つかった8つの実在する欠陥を、どのテストも捕まえられなかった。テストはコードが意図どおりに動くことを検証する。フレームはユーザーが見るものを映す — そして両者は必ずしも同じではない。
見つかったものの中には:「日記」のリストで症状がローカライズ済みの「Joint Pain」ではなく生のキー symptom.jointPain として描画されていた — 詳細画面はずっと前からローカライズしていたのに、リストの行は配列をそのまま連結していた。今日開始した薬のコースは服薬遵守率 0% を表示していた — 日付の差を「そのまま」取ると丸一日がゼロになり、そのゼロが分母のパーセンテージを打ち消していた。日本語と中国語のサマリーウィジェットには、翻訳された因子名の代わりに「Good Sleep」という文字列がぶら下がっていた — スナップショットには英語の factor.name が格納される一方、画面は localizedName を通して描画していたのだ。
これらのバグはどれも撮影ロジックのものではない — すべてプロダクト側にある。気づけたのは、誰かが特定の文字列に対する assert を書いたからではなく、フレームが6言語すべてで同時に存在していたからだ。
時間の節約以外に、なぜここまでやるのか#
自動撮影の見返りは時間だけではない。もっとも、何日もの手作業がテストターゲットの1回の実行に縮むこと自体、それだけで十分に重い論拠ではある。自動撮影は、すべての言語で同時に、両方のテーマで、両方のデバイスレイアウトで、ユーザーの目でアプリを見ることを強制する — ロジックのテストが構造的にカバーできない視角だ。UI検索の代わりの launch argument は、画面上の要素の並び順への依存を消し去る。座標タップと両方向のスワイプは、ラベルが読めず要素が isHittable について嘘をつく SpringBoard と RTL のための必須最低限だ。そして統計を内蔵したアプリへの独立した教訓:デモデータはプロダクトのコードであり、実在する人間のデータと同じ有意性検定を通過しなければならない。



