ある朝、CI のチェックはすべて緑でした。E2E スイートも 42 本すべて pass。そのまま OTA を配信した数時間後、決済(Stripe 連携)完了画面でのクラッシュレポートが届きます。しかもその動線は、E2E で毎回テストしているはずの流れでした。
緑だったのに、通っていなかった。
ログを遡って理由が分かったとき、正直なところ胸のあたりが重くなりました。原因はテストコードのバグでも、アプリの新しい不具合でもありません。自動リトライが、不安定なテストの赤を静かに握りつぶしていたのです。決済フローのテストは3回に1回ほど失敗していて、CI はそれを3回まで再実行し、たまたま通った1回を「緑」として記録し続けていました。
このメモは、その「信用できない緑」に気づき、フレーク(不安定なテスト)を計測して隔離し、本物の失敗だけをリリースの歯止めに残すまでの運用記録です。Rork や Rork Max で生成したアプリに Detox / Maestro の E2E を載せている個人開発者向けに、私自身が手元のアプリ群で踏んだ順番のまま書いています。
緑が緑でなくなる仕組み — リトライは失敗を消しゴムにする
多くの E2E ランナーは、不安定さへの対処として自動リトライを備えています。Detox の Jest 連携なら jest.retryTimes(2)、Maestro なら CI 側で retry を回す構成が定番です。
リトライ自体は悪ではありません。実機・エミュレータの E2E は、アニメーションの完了待ちやネットワークの揺らぎで、コードが正しくても落ちることがあります。問題は、リトライの結果を「最終的に通ったか」だけで記録してしまうことにあります。
3回リトライして1回通れば緑。この記録方法だと、次の2つが区別できません。
| 状態 | 3回の結果 | 記録 | 本当の意味 |
|---|---|---|---|
| 安定して健全 | ○ ○ ○ | 緑 | 問題なし |
| フレーク(不安定) | × × ○ | 緑 | いつ本番で落ちてもおかしくない |
下の行が落とし穴です。表向きは緑なので誰も気づきません。けれど失敗率 67% のテストは、テスト環境より条件の悪い実機(App Store 配信版)で、いつか本物の赤に変わります。私の決済フローがまさにこれでした。
大事な発想の転換は、合否を二値でなく「試行の履歴」として持つことです。最終結果だけを見るのをやめ、リトライを跨いだ各試行の○×を残せば、フレークは数字になって見えてきます。
フレーク率を計測する — JUnit XML を試行単位で読む
Detox(Jest)も Maestro も、JUnit 形式の XML を吐けます。ここに各テストの <testcase> と、失敗時の <failure> が入ります。リトライを別々の実行として出力させれば、試行の履歴を機械的に集計できます。
まず、リトライを「上書き」でなく「追記」で残す設定にします。GitHub Actions で Maestro を回す場合、リトライごとにレポートを別ファイルへ出すのが確実です。
# .github/workflows/e2e.yml(抜粋)
- name: Run Maestro E2E (3 attempts, keep every report)
run: |
for attempt in 1 2 3; do
maestro test .maestro/ \
--format junit \
--output "reports/e2e-attempt-${attempt}.xml" || true
done
# || true で途中失敗しても全試行を回し切る。合否判定は後段の集計に委ねるポイントは || true です。ここで失敗即中断にすると、フレークの「×」が記録される前にジョブが止まってしまいます。判定をランナーから引き剥がし、集計スクリプトへ移すのが要点です。
次に、複数の XML を読んでテスト単位のフレーク率を出す Node スクリプトです。依存は軽量な XML パーサ1つだけにしています。
// scripts/flake-rate.mjs
import { readdirSync, readFileSync } from "node:fs";
import { XMLParser } from "fast-xml-parser";
const DIR = "reports";
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" });
// テスト名 -> { runs: 総試行数, fails: 失敗回数 }
const stats = new Map();
for (const file of readdirSync(DIR).filter((f) => f.endsWith(".xml"))) {
const xml = parser.parse(readFileSync(`${DIR}/${file}`, "utf8"));
const suites = [].concat(xml.testsuites?.testsuite ?? xml.testsuite ?? []);
for (const suite of suites) {
for (const tc of [].concat(suite.testcase ?? [])) {
const name = `${tc["@_classname"] ?? ""} › ${tc["@_name"]}`;
const rec = stats.get(name) ?? { runs: 0, fails: 0 };
rec.runs += 1;
if (tc.failure || tc.error) rec.fails += 1; // 1試行でも赤なら失敗として計上
stats.set(name, rec);
}
}
}
const rows = [...stats.entries()]
.map(([name, { runs, fails }]) => ({ name, runs, fails, rate: fails / runs }))
.filter((r) => r.fails > 0 && r.fails < r.runs) // 全部通る/全部落ちるは除外=フレークのみ
.sort((a, b) => b.rate - a.rate);
for (const r of rows) {
console.log(`${(r.rate * 100).toFixed(0).padStart(3)}% ${r.fails}/${r.runs} ${r.name}`);
}
process.exitCode = 0; // 計測は常に成功で返す。ゲート判定は別ジョブが担うr.fails > 0 && r.fails < r.runs の条件が肝心です。全試行で落ちるものは本物の失敗(別で赤にすべき)、全試行で通るものは健全。その中間、つまり「通ったり落ちたりする」ものだけがフレークです。ここを取り違えると、本物のバグをフレーク扱いで見逃します。
手元の6アプリで初めて回したとき、フレーク率トップは決済フローの 67%(3試行中2失敗)でした。次点がプッシュ通知の許諾ダイアログ待ちで 40% ほど。数字にして初めて、握りつぶされていた不安定さの輪郭が見えました。