- 検証・テスト
フレーキーテストの原因と対策|不安定なテストの見つけ方と直し方
フレーキーテスト(flaky test)は、コードを変えていないのに、実行するたびに通ったり落ちたりするテストです。 原因の多くは、テストが「時刻・実行順・共有データ・待ち時間・乱数・外部のサービス・並列実行」のどれかに、知らないうちに頼っていることです。
この記事では、7つの原因をそれぞれ小さく再現し、何回も流して失敗率を測ってから直しました。
| 原因 | 再現した例 | 直す前の失敗率 | 直した後 |
|---|---|---|---|
| 時間 | 処理の前後で現在時刻の文字列を比べる(pytest) | 20 / 200 回(10%) | 0 / 200 |
| 順序依存 | 前のテストが変えた設定を戻さない(pytest) | ランダム順で 23 / 50 回 | 0 / 40 |
| 共有状態 | static のリストを全テストで使う(JUnit) |
ランダム順で 16 / 30 回 | 0 / 30 |
| 非同期の待ち | 決め打ちの sleep の後に結果を見る(pytest・JUnit・Playwright) |
101 / 200・45 / 100・36 / 100 | 0 |
| 乱数 | 入力を乱数で作り、たまに空になる(pytest) | 22 / 200 回(11%) | 0 / 800 |
| 外部依存 | 5回に1回くらい 503 を返す API を呼ぶ(pytest) | 36 / 200 回(18%) | 0 / 200 |
| 並列 | 全テストで同じファイルに書く(pytest-xdist) | -n 4 で 12 / 30 回 |
0 / 30 |
確認環境:Python 3.11.15・pytest 9.1.1(pytest-randomly 5.0.0、pytest-xdist 3.8.0、pytest-rerunfailures 16.7、pytest-repeat 0.9.4、freezegun 1.5.5)、JUnit Jupiter 5.9.1(JUnit Platform Console Standalone 1.9.1、OpenJDK 21.0.10)、@playwright/test 1.56.1(Chromium、Node.js 22.22.2)を Linux(2 CPU)で動かし、表の回数はすべて実際に繰り返して数えた値です。Playwright のリトライの分類は公式ドキュメントで確かめました(確認日 2026年10月13日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「フレーキーテストの原因と対策|不安定なテストの見つけ方と直し方」
フレーキーテストが困る理由
| 困ること | 起きること |
|---|---|
| 本物の失敗を見逃す | 「またいつもの」と再実行し、本当のバグが混ざっても気づかない |
| CI が遅くなる | 落ちるたびに再実行し、待ち時間が増える |
| テストを信じなくなる | 赤くなっても誰も見なくなり、テストの意味が無くなる |
| 原因の調べ直しが続く | 同じ不安定なテストを、人が入れ替わって何度も調べる |
大事なのは「たまたま」で済ませず、原因の種類を決めて、再現させてから直すことです。 以下、原因ごとに再現と直し方を示します。
原因の分類表
| 原因 | 典型的な症状 | 再現のさせ方 | 直し方の基本 |
|---|---|---|---|
| 時間 | 日付・秒の境目、タイムゾーン、時間帯で落ちる | 何百回も流す・時刻を変える | 時刻を引数で渡す・時計を固定する |
| 順序依存 | 単独では通るが、全体で流すと落ちる(逆もある) | 実行順をランダムにする | テストごとに初期化し、変えたら戻す |
| 共有状態 | static・グローバル変数・DB・キャッシュの残り |
ランダム順・一部だけ実行 | テストごとに作り直す・トランザクションで戻す |
| 非同期の待ち | 遅いマシン・CI でだけ落ちる | 繰り返し実行・CPU を混ませる | 決め打ちの sleep をやめ、条件を待つ |
| 乱数 | 特定の値のときだけ落ちる | 繰り返し実行・シードを記録 | 境界の値を明示する・シードを固定する |
| 外部依存 | ネットワーク・相手のサービスの状態で落ちる | 繰り返し実行 | モック・スタブに差し替える |
| 並列 | 並列にしたときだけ落ちる | 並列数を上げて繰り返す | ファイル・ポート・データをテストごとに分ける |
pytest で再現して直す
対象のコードです。
# app.py
import random
import threading
import time
import urllib.request
from datetime import datetime
# --- 時間 ---
def receipt_header(now=None):
now = now or datetime.now()
return f"発行 {now:%H:%M:%S}"
# --- 共有状態(モジュールのキャッシュ) ---
_settings = {"tax_rate": 10}
def set_tax_rate(rate):
_settings["tax_rate"] = rate
def price_with_tax(price):
return price * (100 + _settings["tax_rate"]) // 100
# --- 非同期の待ち ---
class Job:
def __init__(self):
self.result = None
self.done = threading.Event()
def start(self):
def work():
time.sleep(random.uniform(0.0, 0.2)) # 処理時間がばらつく
self.result = "ok"
self.done.set()
threading.Thread(target=work).start()
return self
# --- 乱数 ---
def average(values):
return sum(values) / len(values) # 空のリストで ZeroDivisionError
# --- 外部依存 ---
def fetch_rate(url):
with urllib.request.urlopen(url, timeout=1) as res:
return float(res.read())
失敗率は、pytest-repeat の --count で同じテストを繰り返して数えました。
pip install pytest pytest-repeat pytest-randomly pytest-xdist pytest-rerunfailures freezegun
pytest -p no:randomly -q --count=200 test_time_flaky.py
pytest の基本は、関連記事「pytest 使い方|導入からfixture・mock・カバレッジまで実行結果つき」で扱っています。
時間:秒の境目で落ちる
# 不安定
import time
from app import receipt_header
def test_header_flaky():
expected = f"発行 {time.strftime('%H:%M:%S')}"
time.sleep(0.1) # 間に別の処理がある想定
assert receipt_header() == expected
20 failed, 180 passed in 20.47s
E AssertionError: assert '発行 15:04:02' == '発行 15:04:01'
期待値を作った時刻と、比べる時刻の間に秒が変わると落ちます。 間の処理が 0.1 秒なら、約 10% の確率です。日付の変わり目・月末・夏時間の切り替えでも同じことが起きます。
# 直し方 1:時刻を引数で渡す
from datetime import datetime
from app import receipt_header
def test_header_fixed():
fixed = datetime(2026, 10, 13, 9, 0, 0)
assert receipt_header(fixed) == "発行 09:00:00"
# 直し方 2:freezegun で時計を止める
from freezegun import freeze_time
from app import receipt_header
@freeze_time("2026-10-13 09:00:00")
def test_header_frozen():
assert receipt_header() == "発行 09:00:00" # datetime.now() が固定される
どちらも 200 回すべて通りました。本番のコードでも、現在時刻を外から渡せる形(引数・Clock)にしておくと、テストが安定します。
順序依存と共有状態:前のテストの設定が残る
# 不安定
from app import set_tax_rate, price_with_tax
def test_standard_rate():
assert price_with_tax(1000) == 1100 # 既定の 10% を前提にしている
def test_reduced_rate():
set_tax_rate(8) # 変えたまま戻さない
assert price_with_tax(1000) == 1080
| 実行のしかた | 失敗した回数 |
|---|---|
ファイルに書いた順(-p no:randomly) |
0 / 50 |
| pytest-randomly でランダム順 | 23 / 50 |
書いた順では必ず通るので、普段は気づきません。 誰かがテストを足して順番が変わったり、1つのテストだけを流したりしたときに、突然落ち始めます。pytest-randomly はインストールすると既定で有効になり、毎回の順番とシード(乱数の種)を表示します。
Using --randomly-seed=2
collected 2 items
test_order_flaky.py .F
def test_standard_rate():
> assert price_with_tax(1000) == 1100 # 既定の 10% を前提にしている
E assert 1080 == 1100
E + where 1080 = price_with_tax(1000)
同じシードを渡すと、同じ順番で再現できます。
pytest -p randomly --randomly-seed=2 test_order_flaky.py # 毎回同じ失敗
直し方は、テストごとに状態を作り直すことです。
# 直した後
import pytest
import app
from app import set_tax_rate, price_with_tax
@pytest.fixture(autouse=True)
def reset_settings(monkeypatch):
# テストごとに設定を差し替え、終わったら元に戻す
monkeypatch.setattr(app, "_settings", {"tax_rate": 10})
def test_reduced_rate():
set_tax_rate(8)
assert price_with_tax(1000) == 1080
def test_standard_rate():
assert price_with_tax(1000) == 1100
ランダム順で 40 回流して、失敗は 0 回でした。データベースを使うテストでは、テストごとにトランザクションを始めて最後にロールバックする方法がよく使われます。
非同期の待ち:決め打ちの sleep
# 不安定
import time
from app import Job
def test_job_flaky():
job = Job().start()
time.sleep(0.1) # 「たぶん終わっている」時間だけ待つ
assert job.result == "ok"
# 直した後
from app import Job
def test_job_fixed():
job = Job().start()
assert job.done.wait(timeout=5) # 終わった合図を待つ(上限つき)
assert job.result == "ok"
| テスト | 結果(200回) | 合計時間 |
|---|---|---|
sleep(0.1) で待つ |
101 failed, 99 passed | 20.62s |
Event.wait(timeout=5) で待つ |
200 passed | 20.74s |
処理が 0〜0.2 秒でばらつくのに 0.1 秒しか待たないので、約半分が落ちました。 待ち時間を延ばしても、遅いマシンでは同じことが起きます。「終わった」という条件を、上限つきで待つのが基本です。合計時間はほぼ同じで、終わればすぐ次に進むので遅くなりません。asyncio の待ち方は、関連記事「Python asyncioの使い方|TaskGroupとtimeoutを実測で解説」で扱っています。
乱数:たまに空の入力になる
# 不安定
import random
from app import average
def test_average_flaky():
values = [random.randint(1, 100) for _ in range(random.randint(0, 9))]
assert average(values) >= 1
200 回中 22 回、長さ 0 のリストが作られ、ZeroDivisionError で落ちました。乱数のテストが落ちたとき、どの値で落ちたかが残らないと再現できません。
# 直した後:境界の値を明示し、空のときの振る舞いを決めて書く
import pytest
from app import average
@pytest.mark.parametrize("values, expected", [
([5], 5),
([1, 2, 3], 2),
([100] * 9, 100),
])
def test_average(values, expected):
assert average(values) == expected
def test_average_empty():
with pytest.raises(ZeroDivisionError):
average([])
このフレーキーテストは、本物のバグ(空のリストを考えていない)を見つけていました。 乱数で見つかった値は、固定のテストケースとして足します。乱数を使い続けるなら、シードを固定するか、失敗したときにシードを表示するようにします。境界値の選び方は、関連記事「同値分割・境界値分析|テストケース表とpytestコード例で解説」で扱っています。
外部依存:相手のサービスがたまに落ちる
5 回に 1 回くらい 503 を返すサーバーを、テスト用に立てて試しました。
# conftest.py(外部 API の代わり)
import random
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
class FlakyRateHandler(BaseHTTPRequestHandler):
def do_GET(self):
if random.random() < 0.2:
self.send_response(503); self.end_headers(); return
self.send_response(200); self.end_headers(); self.wfile.write(b"150.5")
def log_message(self, *args):
pass
@pytest.fixture(scope="session")
def rate_server():
server = HTTPServer(("127.0.0.1", 0), FlakyRateHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{server.server_port}/rate"
server.shutdown()
# 不安定
from app import fetch_rate
def test_rate_flaky(rate_server):
assert fetch_rate(rate_server) == 150.5
# 直した後:呼び出しを差し替える
import io
import app
def test_rate_fixed(monkeypatch):
monkeypatch.setattr(app.urllib.request, "urlopen",
lambda url, timeout: io.BytesIO(b"150.5"))
assert app.fetch_rate("https://rate.example.com/rate") == 150.5
不安定な方は 200 回中 36 回落ち、差し替えた方は 0 回でした。単体テストでは外部のサービスを呼ばず、つなぎ目の確認は契約テストや少数の結合テストに分けます。 契約テストは、関連記事「契約テスト(Pact)入門|コンシューマー駆動の流れとコード例」で扱っています。
並列:同じファイルを取り合う
# 不安定
from pathlib import Path
OUT = Path("/tmp/s152_report.csv") # 全テストで同じファイル
def write_and_read(rows):
OUT.write_text("\n".join(rows))
return OUT.read_text().splitlines()
def test_report_a():
assert write_and_read(["a"] * 2000) == ["a"] * 2000
# test_report_b・c・d も同じ形
# 直した後:tmp_path でテストごとに別のフォルダ
def write_and_read(path, rows):
path.write_text("\n".join(rows))
return path.read_text().splitlines()
def test_report_a(tmp_path):
assert write_and_read(tmp_path / "report.csv", ["a"] * 2000) == ["a"] * 2000
| 実行のしかた | 不安定な方 | 直した後 |
|---|---|---|
| 1プロセス(直列) | 0 / 30 回 | 0 / 30 回 |
pytest-xdist -n 4 |
12 / 30 回 | 0 / 30 回 |
E AssertionError: assert [] == ['b', 'b', 'b...'b', 'b', ...]
E Right contains 2000 more items, first extra item: 'b'
FAILED test_parallel_flaky.py::test_report_b - AssertionError: assert [] == [...
直列では一度も落ちないので、並列化した日から急に不安定になります。 ほかのテストが書き込み途中のファイルを読んだため、空のリストになりました。ファイル・ポート番号・DB のテーブルやスキーマ・外部のテスト用アカウントなど、並列で取り合うものはテストごとに分けます。
JUnit 5 で再現して直す
JUnit の基本は、関連記事「JUnitの使い方|単体テストの書き方と観点【JUnit 5・6】」で扱っています。
非同期の待ち:@RepeatedTest で失敗率を測る
package com.example;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.RepeatedTest;
class AsyncFlakyTest {
static CompletableFuture<String> sendMail() {
return CompletableFuture.supplyAsync(() -> {
sleep(ThreadLocalRandom.current().nextInt(0, 200)); // 処理時間がばらつく
return "sent";
});
}
@RepeatedTest(100)
void flaky() throws Exception {
CompletableFuture<String> f = sendMail();
Thread.sleep(100); // 決め打ちで待つ
assertEquals("sent", f.getNow("pending"));
}
@RepeatedTest(100)
void fixed() throws Exception {
CompletableFuture<String> f = sendMail();
assertEquals("sent", f.get(5, TimeUnit.SECONDS)); // 終わるまで待つ(上限つき)
}
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
| メソッド | 結果(100回) | 失敗のメッセージ |
|---|---|---|
flaky |
45 tests failed | expected: <sent> but was: <pending> |
fixed |
0 tests failed | — |
@RepeatedTest(n) は、同じテストを n 回流して、成功と失敗の数をそのまま出します。 不安定かどうかを確かめるときに便利です。CompletableFuture の使い方は、関連記事「CompletableFuture入門|Java21の並列処理と例外処理コード集」で扱っています。
共有状態と順序依存:static のフィールド
class OrderFlakyTest {
static final List<String> cart = new ArrayList<>(); // static で全テストが共有
@Test
void addItem() {
cart.add("apple");
assertEquals(1, cart.size());
}
@Test
void emptyCart() {
assertEquals(0, cart.size());
}
}
JUnit 5 はテストメソッドごとにテストクラスのインスタンスを作り直します。 インスタンスのフィールドなら毎回新しいリストになりますが、static にすると全テストで共有されます。
既定の順番(emptyCart → addItem)では通りました。設定でメソッドの順番をランダムにして、シードを変えて 30 回流した結果です。
java -jar junit-platform-console-standalone.jar execute -cp out \
--select-class com.example.OrderFlakyTest \
--config=junit.jupiter.testmethod.order.default='org.junit.jupiter.api.MethodOrderer$Random' \
--config=junit.jupiter.execution.order.random.seed=719459399676
OrderFlakyTest
addItem()
emptyCart() ✘ expected: <0> but was: <1>
| クラス | ランダム順で失敗した回数 |
|---|---|
OrderFlakyTest(static のリスト) |
16 / 30 |
OrderFixedTest(インスタンスのフィールド) |
0 / 30 |
シードに 1・2・3…と小さな値を渡すと、1〜12・100・1000 のどれでも同じ順番(emptyCart が先)になり、123456 で初めて落ちました。 テストが2個だと、近いシードでは並びが変わりにくいことがあります。シードは大きくばらけた値を使うと、いろいろな順番を試せます。Maven・Gradle では、src/test/resources/junit-platform.properties に同じキーを書けます。
junit.jupiter.testmethod.order.default=org.junit.jupiter.api.MethodOrderer$Random
並列:スレッドセーフでないオブジェクトの共有
@Execution(ExecutionMode.CONCURRENT)
class ParallelFlakyTest {
// SimpleDateFormat はスレッドセーフではない
static final SimpleDateFormat FMT = new SimpleDateFormat("yyyy-MM-dd");
@RepeatedTest(1000)
void format() {
Date d = new Date(1_760_000_000_000L + (long) (Math.random() * 1000) * 86_400_000L);
String expected = new SimpleDateFormat("yyyy-MM-dd").format(d);
assertEquals(expected, FMT.format(d));
}
}
@Execution(ExecutionMode.CONCURRENT)
class ParallelFixedTest {
// DateTimeFormatter は不変でスレッドセーフ
static final DateTimeFormatter FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.systemDefault());
@RepeatedTest(1000)
void format() {
Date d = new Date(1_760_000_000_000L + (long) (Math.random() * 1000) * 86_400_000L);
String expected = new SimpleDateFormat("yyyy-MM-dd").format(d);
assertEquals(expected, FMT.format(Instant.ofEpochMilli(d.getTime())));
}
}
並列実行の設定(junit.jupiter.execution.parallel.enabled=true、config.strategy=fixed、config.fixed.parallelism=8)で 3 回流した結果です。
| クラス | 1回目 | 2回目 | 3回目 | 並列なし |
|---|---|---|---|---|
ParallelFlakyTest |
8 / 1000 失敗 | 1 / 1000 | 3 / 1000 | 0 / 1000 |
ParallelFixedTest |
0 / 1000 | 0 / 1000 | 0 / 1000 | — |
=> org.opentest4j.AssertionFailedError: expected: <2028-03-15> but was: <2028-02-16>
=> org.opentest4j.AssertionFailedError: expected: <2027-01-27> but was: <2027-04-16>
失敗率が 0.1〜0.8% と低く、数十回の実行では見つからないことがあります。 並列のバグは、テストの数と並列数を増やして繰り返すと見つけやすくなります。この例は「テストの書き方」ではなく本番のコードのバグ(スレッドセーフでないオブジェクトの共有)を、テストが見つけた形です。日付の書式は、関連記事「Java DateTimeFormatterパターン一覧|日付と文字列の変換」で、排他制御は、関連記事「Java synchronizedで排他制御|スレッドセーフを実行結果で検証」で扱っています。
Playwright で再現して直す
ボタンを押すと、0〜1.5 秒後に「注文が完了しました」と出るページで試しました。
<button id="order">注文する</button>
<p id="status"></p>
<script>
document.getElementById('order').addEventListener('click', () => {
document.getElementById('status').textContent = '送信中…';
setTimeout(() => {
document.getElementById('status').textContent = '注文が完了しました';
}, Math.random() * 1500);
});
</script>
// 不安定
test('注文の完了(不安定)', async ({ page }) => {
await page.goto(url);
await page.click('#order');
await page.waitForTimeout(1000); // 決め打ちで待つ
const text = await page.textContent('#status'); // その瞬間の値を読む
expect(text).toBe('注文が完了しました'); // 自動で待たない
});
// 直した後
test('注文の完了(安定)', async ({ page }) => {
await page.goto(url);
await page.getByRole('button', { name: '注文する' }).click();
// 条件を満たすまで再試行する(既定 5 秒)
await expect(page.locator('#status')).toHaveText('注文が完了しました');
});
npx playwright test tests/order-flaky.spec.ts --repeat-each=100 --workers=2
| テスト | 結果(100回) |
|---|---|
waitForTimeout(1000) + textContent + toBe |
36 failed, 64 passed |
expect(locator).toHaveText(...) |
100 passed |
Error: expect(received).toBe(expected) // Object.is equality
Expected: "注文が完了しました"
Received: "送信中…"
expect(locator).toHaveText() のような Web 向けのアサーションは、条件を満たすまで自動で再試行します。 一方、textContent() で読んだ文字列を toBe で比べると、その瞬間の値で判定されます。waitForTimeout は、Playwright でも決め打ちの sleep と同じ問題を持ちます。Playwright の基本は、関連記事「Playwright入門|E2Eテストの書き方とCI実行まで実行結果つきで解説」で扱っています。
| 不安定になりやすい書き方 | 安定する書き方 |
|---|---|
page.waitForTimeout(ms) |
await expect(locator).toBeVisible() など |
expect(await locator.textContent()).toBe(...) |
await expect(locator).toHaveText(...) |
expect(await locator.isVisible()).toBe(true) |
await expect(locator).toBeVisible() |
| CSS のクラスや並び順に頼るセレクター | getByRole・getByLabel・getByTestId |
| テスト間でログイン状態やデータを共有 | テストごとに作る・storageState を分ける |
見つけ方
繰り返し実行のコマンド
| ツール | 同じテストを繰り返す | 順番を変える | 並列にする |
|---|---|---|---|
| pytest | pytest-repeat --count=200 |
pytest-randomly(入れると既定で有効) | pytest-xdist -n 4 |
| JUnit 5 | @RepeatedTest(100) |
MethodOrderer$Random+シード |
junit.jupiter.execution.parallel.enabled |
| Playwright | --repeat-each=100 |
--workers=1 と並列の結果を比べる |
--workers=4・fullyParallel |
新しく書いたテストや直したテストは、マージ前に 50〜200 回くらい繰り返して流すと、たまにしか落ちないものを見つけやすくなります。 失敗率が 1% 未満のものは、数十回では見つからないこともあります(上の並列の例)。
失敗を再現するための記録
| 記録するもの | 理由 |
|---|---|
| 乱数・順番のシード | 同じ順番・同じ値で再現できる(--randomly-seed=2) |
| 実行順(どのテストの後に落ちたか) | 順序依存を見つける |
| 時刻・タイムゾーン | 境目や時間帯の問題を見つける |
| 並列数・ワーカーの番号 | 並列の取り合いを見つける |
| スクリーンショット・トレース | E2E の失敗の瞬間を見る(Playwright の trace) |
CI の履歴から見つける
同じコミットで、1回目は落ちて再実行で通ったテストは、フレーキーテストの候補です。リトライの機能を使うと、この区別を自動で記録できます(次の章)。
リトライの位置づけ
リトライは、フレーキーテストを「見つける・記録する」ための道具で、直す道具ではありません。 同じテストに --reruns 2(pytest-rerunfailures)を付けて 200 回流すと、次のようになりました。
| テスト | リトライなし | --reruns 2 |
|---|---|---|
| 外部依存(503 を返す) | 36 failed | 2 failed, 46 rerun |
非同期の待ち(sleep(0.1)) |
101 failed | 21 failed, 135 rerun |
リトライを付けると失敗は減って見えますが、原因はそのまま残り、実行時間も延びます(非同期の例は 20.62s → 34.79s)。
Playwright は、リトライを使うと結果を3つに分けます。公式ドキュメントの定義です。
| 分類 | 定義 |
|---|---|
| passed | 1回目で通った |
| flaky | 1回目は落ちたが、リトライで通った |
| failed | 1回目もリトライもすべて落ちた |
不安定なテストを --retries=2 で 30 回流すと、22 passed・8 flaky と出ました。flaky の数が 0 でないことを CI で知らせると、リトライで隠れずに済みます。
| ツール | リトライの設定 | flaky の記録 |
|---|---|---|
| pytest | pytest-rerunfailures --reruns 2(--reruns-delay で間隔) |
結果に rerun の数が出る |
| Playwright | retries: 2(既定は 0)、--retries=2 |
flaky として分けて出る |
| Maven Surefire | -Dsurefire.rerunFailingTestsCount=2 |
再実行で通ったものを flaky として報告 |
| Gradle | test-retry プラグイン(org.gradle.test-retry) |
再実行の結果をレポートに残す |
リトライの運用の目安:CI 全体に付けるなら回数は 1〜2 回に抑え、flaky になったテストは必ず記録して、次の章の「隔離」に回します。
隔離(クオランティン)の運用ルール
フレーキーテストを直すまでの間、CI を止めないために別枠に移すことを隔離(quarantine)と呼びます。隔離したまま放置すると、テストが無いのと同じになるので、ルールを決めて運用します。
隔離の書き方
# pytest:マーカーで分ける
import pytest
@pytest.mark.quarantine # 担当: 山田 / 期限: 2026-10-27 / チケット: QA-123
def test_job_flaky():
...
pytest -m "not quarantine" # 通常の CI
pytest -m quarantine # 隔離したものだけ(失敗してもビルドは止めない)
// JUnit 5:タグで分ける
@Tag("quarantine") // 担当: 山田 / 期限: 2026-10-27 / チケット: QA-123
@Test
void flaky() { ... }
// Playwright:タグで分ける
test('注文の完了', { tag: '@quarantine' }, async ({ page }) => { ... });
// 通常の CI:npx playwright test --grep-invert @quarantine
pytest でマーカーを使うときは、pytest.ini などの markers に quarantine を登録しておくと、警告が出ません。JUnit のタグは、Gradle の useJUnitPlatform { excludeTags("quarantine") } や Surefire の excludedGroups で外せます。
運用ルールのチェックリスト
| ルール | 内容の例 |
|---|---|
| 隔離する基準 | 同じコミットで「落ちて、再実行で通った」が1回でも出たら候補にする |
| 記録 | テスト名・失敗のメッセージ・シード・失敗率をチケットに残す |
| 担当者 | 隔離するときに、直す人を必ず決める |
| 期限 | 例:2週間。過ぎたら直すか、テストを消すかを決める |
| 隔離の上限 | 例:全体の数%や件数の上限を決め、超えたら新しい機能より修正を優先する |
| 隔離中も流す | 別のジョブで流し、失敗率の変化を見る |
| 戻す基準 | 直した後、繰り返し実行(例:200回)で一度も落ちないことを確かめてから戻す |
| 消す判断 | 直せない・価値が低いテストは、同じ観点を別のテストで守れるか確かめて消す |
隔離は「直すまでの一時的な置き場所」です。 期限と担当者の無い隔離は、テストを黙って消したのと同じ結果になります。テストの範囲の決め方は、関連記事「リグレッションテストの範囲の決め方|影響範囲の調べ方」で扱っています。
よくある質問
Q. CI でだけ落ちて、手元では再現しません。
A. CI は手元より CPU が少なく、並列で動くことが多いので、非同期の待ちと並列を先に疑います。手元で並列数を上げる(-n 4・--workers=4)、繰り返し回数を増やすと再現しやすくなります。タイムゾーンや言語の設定の違い(時間)も確かめます。
Q. 待ち時間を延ばせば直りますか。 A. 失敗の確率は下がりますが、遅いマシンでは同じことが起きます。上の例では、処理が最大 0.2 秒なのに 0.1 秒しか待たないと約半分が落ちました。条件を上限つきで待つ形に変えると、待ち時間を延ばさずに安定しました。
Q. 1回だけ落ちたテストも調べるべきですか。 A. 落ちたときのメッセージとシードを記録しておくと、2回目に落ちたときに原因を絞れます。乱数の例のように、フレーキーテストが本物のバグを見つけていることもあります。
Q. GitHub Actions で繰り返し実行するには。 A. matrix で同じジョブを複数回流す方法があります。関連記事「GitHub Actionsのキャッシュとmatrix|コピペ用YAMLとキー設計」で扱っています。
まとめ
- フレーキーテストの原因は、時間・順序依存・共有状態・非同期の待ち・乱数・外部依存・並列の7つに分けると調べやすい
- 再現した例では、決め打ちの sleep で約半分(pytest 101/200、JUnit 45/100、Playwright 36/100)が落ちた。条件を上限つきで待つと 0 になった
- 順序依存は、書いた順では 0 回、ランダム順で 23/50 回。pytest-randomly・
MethodOrderer$Randomとシードで再現する - 並列の問題は失敗率が低い(1000 回中 1〜8 回)ので、並列数と回数を上げて繰り返す
- リトライは見つけるための道具。
--reruns 2で失敗は減って見えるが、原因は残る - 隔離は担当者・期限・戻す基準を決めて運用する
参考・出典
確認日はいずれも 2026年10月13日です。
- pytest ドキュメント「How to use temporary directories and files in tests」:https://docs.pytest.org/en/stable/how-to/tmp_path.html
- pytest-randomly(GitHub):https://github.com/pytest-dev/pytest-randomly
- pytest-rerunfailures(GitHub):https://github.com/pytest-dev/pytest-rerunfailures
- pytest-xdist ドキュメント:https://pytest-xdist.readthedocs.io/
- freezegun(GitHub):https://github.com/spulec/freezegun
- JUnit User Guide「Repeated Tests」「Test Execution Order」「Parallel Execution」:https://docs.junit.org/current/user-guide/
- Playwright ドキュメント「Retries」:https://playwright.dev/docs/test-retries
- Playwright ドキュメント「Assertions」:https://playwright.dev/docs/test-assertions
- Chromium「Fixing Flaky Tests」:https://www.chromium.org/developers/testing/fixing-flaky-tests/
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「フレーキーテストの原因と対策|不安定なテストの見つけ方と直し方」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。