金曜の夜にビルドを上げて、外部テスターのグループに割り当てて、翌朝には感想が二つ三つ届いているだろうと思っていました。実際に土曜の朝に届いていたのは、配布がまだ始まっていないという通知だけでした。ビルドは処理を終えていましたが、外部への配布はベータ版の審査待ちのまま止まっていて、そこから動き出したのは日曜の午後です。テスターの誰も悪くありません。私が、どこで止まるかを知らないまま週末を当てにしていただけでした。
個人開発でアプリを出していると、この種の「止まる場所」を一つずつ踏んで覚えることになります。そして何度か踏んだあとに気づくのは、外部テストで見つかる不具合と、公開してから段階的に広げる過程で見つかる不具合が、かなりの部分で重なっているという事実です。重なっているなら、限られた時間をどちらに置くかは選べます。
ここでは、外部テスターを募集する道と、段階公開に受け止めさせる道を並べて、個人開発の前提でどう配分するかを整理します。Rork や Rork Max で生成したアプリを初めて他人の端末に載せる場面を想定していますが、Expo で自前に組んだプロジェクトでも判断の骨格は変わりません。
外部テスターを募る前に、そのビルドが外部の目を必要としているかを見る
外部テストが効くのは、開発者が持っていない条件を持った人にしか踏めない経路があるときです。逆に言えば、その経路が自分の手元で再現できるなら、外部テストは待ち時間だけを増やします。
私が使っている見分け方は単純で、そのリリースの変更点を次の三つに仕分けます。
分類 内容 外部テストの価値
手元で再現できる 画面の追加、文言変更、レイアウト調整、内部ロジックの変更 低い。実機一台で足りる
手元では再現しにくい 他人のアカウント状態、既存ユーザーの移行、通知の受信、他アプリとの連携 高い。外部の実データが要る
誰が踏んでも同じ 課金フロー、権限ダイアログ、機種依存のレイアウト 中。ただし内部テスターで足りる場合が多い
真ん中の行が空のリリースに外部テストを挟むと、得られるのは「特に問題ありませんでした」という感想だけになります。感想がないより良いように見えますが、その一往復のために数日を使っている点は変わりません。
壁紙や癒し系のように、ユーザーごとの状態が薄いアプリでは、真ん中の行が空になる回が想像以上に多いです。私自身、更新の八割前後は画面とアセットの入れ替えで、外部の実データを必要としない内容でした。そういう回に外部テストを毎回挟んでいた時期は、リリース間隔が二週間から三週間に伸びただけで、不具合の数は目に見えて減っていません。
外部テストが止まるのは、テスターの人数ではなくビルドの差し替えです
外部テストの運用でつまずくのは、テスターを集める段階ではありません。集めたあとに、ビルドを差し替える段階です。
内部テスターへの配布は、ビルドの処理が終わればすぐに届きます。外部テスターへの配布は、ベータ版の審査を通す必要があります。ここまでは多くの記事に書かれています。実務で効いてくるのはその先で、どのタイミングで再度その審査が走るのか を読み違えると、配布が丸一日止まります。
私が把握している範囲では、審査が再度必要になるのは主に次の場面です。
アプリのバージョン(1.8.0 のような表示上の番号)を上げたとき
テスターに見せる説明文やスクリーンショットなど、審査対象の情報を変更したとき
前回の審査から時間が空き、ビルドの有効期限が切れたあとに新しいビルドを出したとき
逆に、同じバージョンのままビルド番号だけを上げた差し替えは、多くの場合そのまま配布に進みます。この差は小さく見えますが、リリース直前の運用を大きく変えます。金曜に 1.8.0 として上げてしまうと、土日の差し替えがすべて審査待ちに入ります。1.7.9 のまま外部テストを回し、外部の確認が終わってから 1.8.0 に上げる、という順番にするだけで、週末に止まる回数が減りました。
ここは公式の説明を読んでも「バージョンを変えたら審査」と一行あるだけで、それが週末の予定を左右するとは書かれていません。実際に一度止めてみないと、順番の重要さが体に入ってこない部分だと思います。
事前検証に残すのは、課金と権限と機種依存の三つだけにする
外部テストの回数を減らすと決めたあと、では何を事前に確かめるのかという話になります。私が残しているのは三つです。
課金フロー 。購入、復元、解約後の状態遷移の三つを、必ず実機で一往復させます。ここだけは段階公開に受け止めさせてはいけない領域だと考えています。課金が壊れた状態で 5% に配ると、その 5% の中に「お金を払ったのに機能が開かない」人が生まれます。クラッシュなら次の更新で謝れますが、決済の失敗は謝って済む種類の傷ではありません。
権限ダイアログ 。写真、通知、トラッキングの三種は、初回起動の一度しか出ません。順番を一つ間違えると、以後その端末では正しい順番を検証できなくなります。検証用の端末を初期化する手間を惜しむと、間違った順番のまま出荷することになります。
機種依存のレイアウト 。新しい画面サイズが増えた直後の更新だけ、実機かシミュレータで一通り見ます。ここは端末を増やすほど確度が上がる領域なので、外部テスターがいれば有利ではあります。ただし、外部テスターの端末構成は募集した時点で偏っていて、狙った機種が含まれている保証はありません。狙って確かめたいなら、シミュレータを回すほうが速いというのが私の結論です。
この三つ以外は、段階公開とクラッシュ監視に渡します。渡す先の設計についてはクラッシュフリー率を『予算』として運用する — 個人開発 6 アプリで撤退と投資を判断する SLO 設計ノート にまとめています。
ビルドが外部配布に到達したかを、毎朝手で確認しない
外部テストを完全にやめるわけではないので、使う回はあります。そのとき困るのが、ビルドが今どの状態にあるのかを知るために App Store Connect を開く必要があることでした。アプリが六本あると、この確認だけで毎朝十分ほど溶けます。
App Store Connect API に問い合わせて、処理待ちなのか、審査待ちなのか、配布に到達したのかを一度に出すスクリプトを置いています。次のコードは、そのうち判定に必要な部分を切り出したものです。Node.js 20 以上で、jsonwebtoken だけに依存します。
// check-build-state.mjs
// 使い方: node check-build-state.mjs <APP_ID>
// 事前準備: App Store Connect の「ユーザーとアクセス > 統合」で API キーを作り、
// .p8 を安全な場所に置いて環境変数からパスを渡します。
import { readFileSync } from "node:fs" ;
import jwt from "jsonwebtoken" ;
const KEY_ID = process.env. ASC_KEY_ID ; // 例: YOUR_KEY_ID
const ISSUER_ID = process.env. ASC_ISSUER_ID ; // 例: YOUR_ISSUER_ID
const KEY_PATH = process.env. ASC_KEY_PATH ; // 例: /secure/AuthKey_YOUR_KEY_ID.p8
const APP_ID = process.argv[ 2 ];
if ( ! KEY_ID || ! ISSUER_ID || ! KEY_PATH || ! APP_ID ) {
console. error ( "環境変数 ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH と APP_ID が必要です" );
process. exit ( 2 );
}
// トークンの有効期間は 20 分以内に収めます。これを超えると 401 が返ります。
function makeToken () {
const privateKey = readFileSync ( KEY_PATH , "utf8" );
return jwt. sign ({}, privateKey, {
algorithm: "ES256" ,
expiresIn: "15m" ,
issuer: ISSUER_ID ,
audience: "appstoreconnect-v1" ,
header: { alg: "ES256" , kid: KEY_ID , typ: "JWT" },
});
}
async function api ( path , token ) {
const res = await fetch ( `https://api.appstoreconnect.apple.com${ path }` , {
headers: { Authorization: `Bearer ${ token }` },
});
if (res.status === 401 ) throw new Error ( "認証に失敗しました。KEY_ID と .p8 の対応を確認してください" );
if (res.status === 429 ) throw new Error ( "レート制限に達しました。実行間隔を空けてください" );
if ( ! res.ok) throw new Error ( `${ path } が ${ res . status } を返しました` );
return res. json ();
}
// 直近 5 件のビルドについて、処理状態・外部配布可否・審査状態をまとめて出します。
async function main () {
const token = makeToken ();
const query = [
`filter[app]=${ APP_ID }` ,
"sort=-uploadedDate" ,
"limit=5" ,
"include=buildBetaDetail,preReleaseVersion" ,
"fields[builds]=version,processingState,expired,buildBetaDetail,preReleaseVersion" ,
"fields[buildBetaDetails]=externalBuildState,internalBuildState" ,
"fields[preReleaseVersions]=version" ,
]. join ( "&" );
const body = await api ( `/v1/builds?${ query }` , token);
const included = new Map ((body.included ?? []). map (( x ) => [ `${ x . type }:${ x . id }` , x]));
for ( const build of body.data) {
const detailRef = build.relationships?.buildBetaDetail?.data;
const versionRef = build.relationships?.preReleaseVersion?.data;
const detail = detailRef ? included. get ( `buildBetaDetails:${ detailRef . id }` ) : null ;
const version = versionRef ? included. get ( `preReleaseVersions:${ versionRef . id }` ) : null ;
const marketing = version?.attributes?.version ?? "?" ;
const buildNo = build.attributes.version;
const processing = build.attributes.processingState; // PROCESSING / VALID / FAILED / INVALID
const external = detail?.attributes?.externalBuildState; // READY_FOR_BETA_SUBMISSION など
const expired = build.attributes.expired ? " [期限切れ]" : "" ;
console. log ( `${ marketing } (${ buildNo }) 処理=${ processing } 外部=${ external ?? "-"}${ expired }` );
}
}
main (). catch (( err ) => {
console. error ( `確認できませんでした: ${ err . message }` );
process. exit ( 1 );
});
出力はアプリ一本につき五行で、次のような形になります。
1.8.0 (412) 処理=VALID 外部=WAITING_FOR_BETA_REVIEW
1.7.9 (409) 処理=VALID 外部=READY_FOR_BETA_TESTING
1.7.9 (408) 処理=VALID 外部=READY_FOR_BETA_TESTING [期限切れ]
このコードで書き方に迷ったのは include と fields の指定です。include=buildBetaDetail を付けても、fields[buildBetaDetails] を指定しないと属性が落ちた形で返ってくることがあり、externalBuildState が undefined になります。関連リソースは included 配列に平たく入るため、type:id の組で引けるように Map に詰め直しています。この形にしてから、アプリを増やしても同じ処理で回せるようになりました。
複数アプリをまとめて見たい場合は、シェル側で回すのが手軽です。
#!/usr/bin/env bash
set -euo pipefail
for APP_ID in 1111111111 2222222222 3333333333 ; do
echo "--- ${ APP_ID } ---"
node check-build-state.mjs "${ APP_ID }" || echo " (取得できませんでした)"
done
set -euo pipefail を付けたうえで || echo を添えているのは、一本が落ちても残りの確認を続けたいからです。全部止まってしまうと、結局 App Store Connect を開くことになります。
段階公開を進めるか止めるかは、朝に一度だけ決める
段階公開に受け止めさせると決めた以上、受け止める側の運用が要ります。ここで大事なのは、判断の頻度を上げすぎないことだと考えています。
配信を始めた直後は、数字が動くたびに見たくなります。ただ、初日の数字は母数が小さく、たまたま一件のクラッシュで率が跳ねます。跳ねた数字を見て止める判断をすると、実際には問題がなかった更新を何度も引き戻すことになり、次のリリースが怖くなります。
私は、朝に一度だけ見て、その場で三択のどれかを選ぶという運用に落ち着きました。
見るもの 進める 止める
クラッシュの発生率 前バージョンと同水準か改善 前バージョンより明確に悪化
クラッシュの中身 既知の少数の箇所に収まる 起動直後や決済まわりに新しい箇所
問い合わせとレビュー 普段と同じ傾向 同じ症状の報告が短時間に複数
三つのうち二つが「止める」側に寄ったら止めます。一つだけなら進めます。数字の閾値を細かく決めるより、この粗い決め方のほうが迷わずに済みました。閾値を細かくすると、閾値そのものを見直す作業が発生して、結局リリースの時間を削ります。
止めたあとの復旧経路については、Rork アプリの段階的リリース戦略 — 壊さない本番運用を作る で三層に分けて書いています。
Rork で作ったアプリに当てはめるときの注意
ここまでの配分は、生成したコードをそのまま出す場合にも当てはまりますが、二点だけ前提が変わります。
一つは、生成のたびにコードが書き換わる範囲がある点です。外部テスターに配ったビルドと、その後に生成をやり直したビルドでは、テスターが確認した箇所が残っている保証がありません。外部テストを挟むなら、生成を止めてから配る という順番を守らないと、確認の意味が薄れます。私はテスターに配る前に、生成に触れない状態でひと晩置くようにしています。
もう一つは、無線更新との重なりです。React Native 系の構成では、ストアの審査を通さずに JavaScript 側を差し替えられます。これがあると、段階公開で止めたあとの復旧が速くなる一方で、外部テスターの端末が意図しないバンドルを掴むこともあります。外部テストの期間中は無線更新のチャンネルを分けておくほうが、あとで混乱しません。
Rork Max のように Apple 向けのネイティブコードを書き出す構成では、この無線更新の逃げ道がありません。段階公開に寄せる配分は変えないほうがよいのですが、事前検証に残す三つ(課金・権限・機種依存)の比重は、少しだけ上げています。差し戻しの手段が一つ少ない分、前に置くという判断です。
TestFlight では問題がないのに公開後に落ちる場合の切り分けは、TestFlight は問題ないのに App Store 公開後にクラッシュする4つの理由 に別途まとめています。
今日決めておくこと
次のリリースの変更点を、この記事の最初の表に当てはめてみてください。真ん中の行(他人のアカウント状態や既存ユーザーの移行が絡む変更)が空なら、そのリリースは外部テストを挟まずに、段階公開の 5% から始めて構わないと思います。空でなければ、外部テストを使う価値があります。
判断を一つに絞れば、迷っている時間がそのままリリースの速さに変わります。私自身、外部テストを「毎回やるもの」から「必要な回にやるもの」に変えたことで、更新の間隔を戻すことができました。