本文へスキップ
Articles

2026-09-11 公開 / アプリ開発

ストアの掲載画像は、気づかないうちに嘘になる。テストで撮り直せるようにした

iPhone向けの小さなユーティリティを2本、提出前の状態まで作っています。掲載用のスクリーンショットは手で撮っていました。ある日並べて見比べたら、4枚のうち1枚だけ、画面の文言が1世代前のままでした。アプリのラベルを直したときに、その1枚だけ撮り直していなかったからです。手で撮る限り、この差は静かに開き続けます。

症状: どれが古いのか、画像からは分からない

そのアプリでは、動画を分割する上限の見出しを「1本の長さ」から「1本の長さの上限」に直していました。「2分」だと2分ちょうどで切ると読めてしまうので、上限であることが分かる言い方に変えた、という変更です。

ところが掲載画像を並べると、3枚は新しい文言で、1枚だけ古い文言でした。画像そのものは壊れていないので、見ても違和感がありません。並べて、文字を1つずつ照合して、初めて気づきます。

撮った時期がバラバラなのが原因です。文言を直したのはある日で、その日に撮り直したのは関係する画面だけ。残りは前に撮ったものが、そのまま掲載フォルダに残っていました。

撮影を、テストにする

UIテストからスクリーンショットを撮って、ファイルに書き出します。

func testCaptureSplitScreen() {
    let app = XCUIApplication()
    app.launchArguments = ["-seedDemo", "-noAds"]
    app.launch()

    // 画面が出たことを文言で確かめてから撮る
    XCTAssertTrue(app.staticTexts["1本の長さの上限"].waitForExistence(timeout: 600))

    let shot = XCUIScreen.main.screenshot()
    let url = URL(fileURLWithPath: NSTemporaryDirectory())
        .appendingPathComponent("shot-01-split.png")
    try? shot.pngRepresentation.write(to: url, options: .atomic)
    print("SHOT \(url.path)")   // ← ホスト側がこの行を拾って回収する
}

シミュレータの一時領域に置いて、パスを標準出力に流します。アプリの領域は実行のたびに変わるので、ホスト側のスクリプトはこの出力行からディレクトリを割り出して回収します。

テストにする利点は「自動化」よりも、同じ構図をいつでも再現できることのほうが大きいです。文言を直したら全部撮り直す、が1コマンドで済むなら、撮り直しを後回しにする理由が消えます。

ここで重要なのは、撮る前に必ず文言で存在確認を入れることです。待たずに撮ると、読み込み中の画面や、真っ白な状態が掲載画像になります。失敗しても何も言わずに「それらしい画像」ができてしまうのが、この作業のいちばん危ないところです。

落とし穴1: テストプロセスに環境変数が届かない

日本語と英語で2回走らせたいので、言語を環境変数で渡すことにしました。ところが、こう書いても届きません。

STORE_SHOT_LANG=en xcodebuild test ...      # テスト側からは見えない

テストは別プロセスで動くので、呼び出し側のシェル変数はそのままでは渡りません。渡すにはTEST_RUNNER_ を頭に付けます。接頭辞は外れた状態でテストプロセスに入ります。

TEST_RUNNER_STORE_SHOT_LANG=en xcodebuild test ...

// テスト側
ProcessInfo.processInfo.environment["STORE_SHOT_LANG"] ?? "ja"

気づきにくいのは、届かなくてもエラーにならないことです。既定値の「ja」で動き続けるので、英語のつもりで撮った画像が日本語で出来上がります。

落とし穴2: 端末の地域が前回のまま残る

これで英語版は撮れるようになりました。次に日本語版を撮ったら、テストが全部失敗しました。日本語の文言を探しているのに、画面が英語で表示されていたからです。

原因は、英語で撮ったときにシミュレータ本体の地域と言語を英語に変えたまま戻していなかったことです。日本語の撮影では言語を指定していなかったので、端末の設定がそのまま効いていました。

対処は単純で、どちらの言語でも必ず明示することです。「英語のときだけ変える」にすると、前回の状態が残ります。

case "$LANG_CODE" in
  en) LOCALE="en_US"; LANGS=(en-US) ;;
  *)  LOCALE="ja_JP"; LANGS=(ja-JP en-JP) ;;
esac
xcrun simctl spawn "$UDID" defaults write -g AppleLocale -string "$LOCALE"
xcrun simctl spawn "$UDID" defaults write -g AppleLanguages -array "${LANGS[@]}"

「片方のときだけ設定する」は、たいてい後で壊れます。状態を持つものは、毎回両方向に書く。これは環境変数でも設定ファイルでも同じでした。

落とし穴3: 時計と電波が画像ごとに違う

日本語版のステータスバーは 9:41 で電波も満タン、英語版は 18:43 で電波が弱い、という状態になっていました。撮った時刻がそのまま写るので当然です。並べると英語版だけ浮きます。

シミュレータはステータスバーを固定できます。撮影前に1回叩けば済みます。

xcrun simctl status_bar "$UDID" override \
  --time "9:41" --dataNetwork wifi --wifiMode active --wifiBars 3 \
  --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100

落とし穴4: テストのビルドだけが失敗する

アプリは普通にビルドできるのに、テストを走らせようとするとビルドが落ちる、という状態にもなりました。メッセージはパッケージが解決できないというもので、一見すると依存関係の問題に見えます。

実際はビルドするアーキテクチャの数の問題でした。デバッグ設定に ONLY_ACTIVE_ARCH = YES が入っていなかったため、使う予定のない側までビルドしようとして、そちらで解決に失敗していました。1行足したら通りました。

教訓としては、「依存が解決できない」と言われたときに、まず依存を疑わないこと。何を何個ビルドしようとしているかを先に見ると早いです。

いちばんの収穫: 画像がコードの誤りを教えてくれた

英語のスクリーンショットを撮って見返したら、購入を促すボタンに日本円の金額が出ていました。英語表示の画面に、です。

コードを見ると、こうなっていました。

var displayPrice: String { product?.displayPrice ?? "¥600" }   // ← 固定の逃げ道

ストアから商品情報を取れなかったときのフォールバックです。手元で動かすぶんには「それらしく」見えるので、入れたことを忘れていました。しかしこれは実機でも起こります。通信が悪い、地域が違う、商品登録が間に合っていない——そのどれでも、日本以外の利用者に日本円が表示されます。

直し方は、価格を任意の値にして、取れたときだけ出すことでした。

var displayPrice: String? { product?.displayPrice }

// 表示側
if let price = displayPrice {
    Text("広告を消す(\(price) 買い切り)")
} else {
    Text("広告を消す(買い切り)")     // 金額を言わない
}

「分からないときは、それらしい既定値を返す」は、書いている瞬間はやさしさに見えます。実際には誤った情報を自信満々に表示する仕掛けでした。分からないなら、分からないまま表示を減らすほうが正しい。

この誤りは、レビューでもテストでも見つかっていませんでした。掲載画像を並べて眺めたから見つかりました。撮影を自動化した副次効果として、いちばん価値があったのはこれです。

もうひとつ: 見出しを画像の中だけに置かない

掲載画像には見出しを載せます。私はそれを合成スクリプトに直接書いていました。つまり見出しの文言は、合成済みのPNGの中にしか存在しない状態でした。

撮り直そうとしたとき、過去の見出しを思い出せず、画像を切り出して読み取るはめになりました。文章の情報を、文章として持っていなかったからです。

掲載文のメモに表として書き出し、スクリプトがその表どおりに合成する形に変えました。

| 掲載 | もとの画面 | 見出し | 補足 |
|---|---|---|---|
| 01 | 01-split | 2分以下に、きれいに分ける | 最後の1本だけ短くなりません |

このとき、5枚目として書いてあった見出しに対応する画面が存在しないことにも気づきました。「分けるだけなら画質はそのまま」という主張は正しいのですが、それを写した画面がアプリに無い。裏付けのない見出しを画像に載せるのはやめて、説明文の側に置きました。

デバッグ用の撮り漏れが混ざる

最後に小さな事故をひとつ。テストには、画面が出なかったときの手がかり用に「失敗時のスクリーンショット」も撮る処理を入れていました。回収スクリプトが shot-*.png を全部コピーしていたので、その失敗画像が掲載フォルダに紛れ込んでいました。

掲載用と調査用は置き場を分けるか、名前で弾きます。私は 9 始まりを調査用と決めて、回収から外しました。

持ち帰れること

掲載画像は、作業としては最後のほうに来ます。だからこそ雑になりやすく、そして利用者が最初に見るものでもあります。撮り直しを安くしておくと、ここが最後まで正しいままになります。