87件のpytestがGREENでも5つの盲点があった:11件の手動Mutation Testingで検証した記録 [最終段階]
pytest強化シリーズ
はじめに
私は現在、本業でトラックドライバーとして働きながら、Pythonを中心にWebアプリケーション開発を独学しています。
個人開発中のFlaskアプリでは、pytestを単なる「動作確認」ではなく、
一度見つけた事故やヒヤリハットを、次から自動で止めるための「事故防止台帳」
として少しずつ育ててきました。
最初は、
3 passed
しかありませんでした。
そこから段階的に、
第1段階: 3 → 9
第2段階: 9 → 51
第3段階:51 → 69
第4段階:69 → 87
まで強化しました。
そして今回の第5段階。
これまでとは少し方向を変えました。
第1〜4段階までは主に、
問題があるかもしれない
↓
pytestを書く
↓
RED
↓
修正する
↓
GREEN
という流れでした。
しかし、87件すべてがGREENになったところで、一つ疑問が出ました。
そのGREENは、本当に信用していいのか?
テストそのものに盲点があれば、壊れたコードを見逃したままGREENになる可能性があります。
そこで第5段階では、
正常なコードをあえて一時的に壊し、本当にpytestがREDになるのか
を確認することにしました。
今回は、Falsification(反証)の考え方を取り入れながら、手動Mutation Testingを行いました。
結果から書くと、
開始時:87 passed
実施したMutation:11件
初回KILLED:6件
初回SURVIVED:5件
SURVIVEDした5件をpytest強化
↓
同じMutationを再適用
↓
5件すべてREDを確認
最終:91 passed, 2 warnings(既知)
となりました。
つまり、87件もGREENだったのに、5種類の故障を見逃す盲点が残っていました。
今回は、その5件を中心に記録します。
第5段階では「GREEN」を疑う
pytestがGREENということは、
現在書かれているテスト条件では期待通りだった
ことを意味します。
しかし、
GREEN = 完全に安全
ではありません。
例えば、JavaScriptに、
productName.textContent = item[0];
という安全な処理があったとします。
pytestが、
textContentを使っている
ことだけを確認していた場合、
その直後に、
productName.innerHTML = item[0];
が追加されても、
textContent自体は存在しているためテストが通る可能性があります。
つまり、
安全なコードが存在する
ことと、
危険なコードが存在しない
ことは別です。
これを頭の中で想像するだけではなく、
本当に壊して確認する
ことにしました。
Mutation Testingとは
Mutation Testingでは、正常なコードへ意図的に小さな故障を入れます。
例えば、
if not product.is_active:
を、
if False:
へ変える。
あるいは、
DailySales.query.filter_by(
product_id=product.id,
date=sale_date,
).first()
から、
date=sale_date
という検索条件を消す。
その状態でpytestを実行します。
結果は大きく2種類です。
KILLED
Mutationを入れたことでpytestがREDになる状態です。
正常コード
↓
GREEN
Mutation
↓
RED
つまり、その故障をpytestが検知できています。
SURVIVED
Mutationを入れてもpytestがGREENのままです。
正常コード
↓
GREEN
Mutation
↓
GREEN
テストが、
正常コードと故障コードを区別できていない可能性があります。
今回は専用のMutation Testingツールは導入せず、Codexと一緒に代表的な故障を選び、
1件ずつ手動でMutationを入れる
方法を取りました。
意図的に壊すからこそ、安全ルールを決めた
Mutation Testingでは、正常なproduction codeを自分で壊します。
そのため、最初にルールを決めました。
mainではMutationしない
feature/pytest-stage5上で行う
一度に1Mutationだけ
production DBへ接続しない
development DBへ接続しない
Renderへ接続しない
実Gemini APIを呼ばない
外部networkへ接続しない
pytestの隔離SQLite / temporary DBだけを使う
Mutationはcommitしない
Mutationはpushしない
検証後は必ずrestoreする
SURVIVEDした場合も、壊れたコードを残したままpytestを書き換えることはしませんでした。
すべて、
Mutationを入れる
↓
pytest実行
↓
SURVIVEDを確認
↓
正常コードへrestore
↓
pytestを正式に追加・強化
↓
正常コードでGREEN
↓
同じMutationを再適用
↓
REDを確認
↓
再びrestore
↓
正常コードでGREEN
の順番で確認しています。
以降のSURVIVED 5件も、すべてこの手順です。
まず11件の結果を一覧にする
今回選んだMutationは11件です。
| # | Mutation内容 | 初回結果 | 強化後 |
|---|---|---|---|
| 1 | XSS対策の横にinnerHTMLを追加 |
SURVIVED | KILLED |
| 2 | CSRF保護を解除 | KILLED | - |
| 3 | session fingerprint不一致拒否を削除 | KILLED | - |
| 4 | 年月validationのorをandへ変更 |
KILLED | - |
| 5 | 販売終了商品チェックを無効化 | KILLED | - |
| 6 | ModelのUniqueConstraintを変更 | KILLED | - |
| 7 | migrationのUniqueConstraintを変更 | KILLED | - |
| 8 | AI advice取得からyear条件を削除 | SURVIVED | KILLED |
| 9 | 初期ランキングへJinja |safeを追加 |
SURVIVED | KILLED |
| 10 | fingerprint欠落sessionを正常扱い | SURVIVED | KILLED |
| 11 | 売上検索からdate条件を削除 | SURVIVED | KILLED |
初回結果は、
KILLED 6件
SURVIVED 5件
でした。
最初からKILLEDできた6件については、既存pytestがすでに故障を検知できていたため、新しいtest functionは追加していません。
今回の記事では、SURVIVEDした5件を中心に見ていきます。
最初からKILLEDできた6件
最初から検知できたのは、
- #2 CSRF保護解除
- #3 session fingerprint不一致拒否削除
- #4 年月validation破壊
- #5 販売終了商品チェック無効化
- #6 Model UniqueConstraint破壊
- #7 migration UniqueConstraint破壊
でした。
例えば#2では、一時的にCSRF保護を外すと、本来HTTP 400で拒否されるリクエストが業務処理まで到達し、既存pytestがREDになりました。
#5では、
if not product.is_active:
を、
if False:
へ変更しました。
販売終了商品が売上処理を通過するようになりますが、これも既存pytestが検知しました。
少し面白かったのは#7です。
migration側の、
product_id + date
というUniqueConstraintを、
product_id + quantity
へ一時変更しました。
この状態でも、
migrationそのものは最後まで成功しました。
しかし、migration完了後のschemaをinspectしているpytestが、
必要なUniqueConstraintが存在しない
ことを検知してREDになりました。
ここでは、
「migrationが成功した」と「正しいschemaが完成した」は別
ということも確認できました。
SURVIVED #1:安全なtextContentがあっても危険なinnerHTMLを見逃した
最初にSURVIVEDしたのはXSS対策でした。
動的ランキングの商品名表示では、
productName.textContent = item[0];
を使用しています。
ここへ一時的に、
productName.innerHTML = item[0];
を追加しました。
つまり、
productName.textContent = item[0];
productName.innerHTML = item[0];
です。
安全なtextContentは残したまま、その直後に危険なinnerHTMLを追加しています。
既存のXSS回帰テストを実行すると、
GREEN。
MutationがSURVIVEDしました。
なぜ見逃したのか
既存pytestでは、
textContentが使用されている
ことを確認していました。
しかし、
innerHTMLが使用されていない
ことまでは確認していませんでした。
つまり、
安全な処理が存在する
ことだけを見ていたわけです。
そこで、
innerHTMLouterHTMLinsertAdjacentHTML
など、未信頼データを渡すとXSSにつながり得るHTML sinkが追加されていないことも確認するようpytestを強化しました。
正常コードでGREENを確認した後、同じMutationを再適用。
今度はREDになりました。
SURVIVED
↓
assertion強化
↓
同じMutationを再適用
↓
KILLED
この時点で、
87 → 88 tests
です。
ここで最初に学んだのは、
「安全なものがある」だけでなく、「危険なものがない」ことを見る必要がある場合もある
ということでした。
第2波では「既存pytestが見逃しそうな故障」だけを選んだ
Mutationは、作ろうと思えばいくらでも作れます。
条件式を変える。
returnを消す。
例外処理を外す。
DB検索条件を消す。
テンプレートへ危険な処理を追加する。
しかし、第5段階の目的は、
Mutationの件数を増やすことではありません。
第1波終了後、一度production codeを変更せずに候補を再調査しました。
その中から、
現在のfixtureでは正しいコードと故障コードを
区別できない可能性がある
現在のassertionでは見逃す可能性がある
既存pytestとは違う学びが得られそう
というものを4件選びました。
それがMutation #8〜#11です。
そして、この4件を第5段階最後のMutation群としました。
結果は、
4件すべてSURVIVED。
ここからが今回の第5段階で、一番大きな学びになりました。
SURVIVED #8:year条件を消してもfixtureが単純すぎて結果が変わらなかった
AI経営アドバイスでは、指定された年・月の売上データを取得しています。
正常コードでは、
_get_sales_from_db(target_year, target_month)
です。
ここからyearを一時的に外して、
_get_sales_from_db(None, target_month)
へ変更しました。
つまり、
2026年8月
を取得したいのに、
何年でもいいから8月
という状態です。
既存pytestを実行しました。
結果は、
GREEN。
SURVIVEDです。
原因はproduction codeではなくfixtureだった
当時のfixtureには、
2026年8月
2026年7月
のデータしかありませんでした。
そのためyear条件を消しても、
8月
という条件だけで2026年8月のデータが取得できてしまいます。
正常コードとMutation後のコードで、
結果が変わらないfixture
になっていました。
そこで、
2025年8月
のデータを追加しました。
商品名は、
前年8月対象外商品
quantityは77。
そして2026年8月を取得した場合に、
assert "前年8月対象外商品" not in contents
となるよう既存テストを強化しました。
正常コードではGREEN。
同じMutationを再適用するとRED。
KILLEDできました。
この変更では既存test functionを強化しただけなので、
pytest件数は88件のままです。
ここで気づいたこと
テストデータは、
存在すればいいわけではありません。
正常コードと壊れたコードで違う結果が出るfixtureでなければ、
故障を識別できません。
コード行を通っている
だけでは、
その条件が本当に必要だと確認できている
とは限らないことが分かりました。
SURVIVED #9:Jinjaの|safe追加を見逃した
次はdashboardの初期ランキングです。
Jinjaでは通常、
{{ name }}
と書けばautoescapeが働きます。
これを一時的に、
{{ name | safe }}
へ変更しました。
既存XSSテストを実行すると、
6 passed
でした。
SURVIVEDです。
なぜXSSテストがあるのに通ったのか
既存pytestでは、
- JavaScriptで後から作るランキング
- AI返答
- unsafe HTML sink
などを中心に確認していました。
しかし、
サーバー側で最初から描画されるランキングの商品名
を直接確認していませんでした。
そこで、
<em>HTML風商品名</em>
という商品名をDBへ入れました。
正常なJinja autoescapeが働けば、
<em>要素として解釈されず、文字列として表示されるはずです。
新しいpytestでは、
- raw HTMLにはescape済み文字列が存在する
-
.prod-nameの表示テキストは元の商品名になる -
.prod-name配下にem要素が生成されていない
ことを確認しました。
正常コードではGREEN。
|safe Mutationを再適用するとRED。
SURVIVED
↓
サーバー側描画のXSSテスト追加
↓
KILLED
となりました。
pytest件数は、
88 → 89 tests
です。
ここでは、
「XSSテストがある」という名前だけでは、すべての表示経路を守っているとは限らない
ことが分かりました。
SURVIVED #10:正常loginしか使っていないfixtureでは「欠落session」を作れなかった
既存のsessionテストでは、管理者認証情報が変更された場合に、
古いsessionを拒否できるか
を確認していました。
しかし、
fingerprintそのものがsessionから消えている場合
はどうなるのでしょうか。
正常コードではfingerprintが存在しなければ拒否します。
そこでMutationとして、
fingerprintが存在しない場合、
現在のfingerprintをdefault値として使用する
よう変更しました。
イメージとしては、
fingerprintが存在しない
↓
現在のfingerprintと同じだったことにする
です。
既存pytestは、
2 passed
でした。
SURVIVEDです。
原因は「正常loginを通るfixture」
既存テストは正常loginを経由していました。
正常なloginでは、当然fingerprintがsessionへ保存されます。
つまり既存テストでは、
fingerprintが存在しないsession
そのものを作っていませんでした。
そこで、新しいテストでは正常login後に、
session_transaction()
を使用し、
fingerprintだけをsessionから削除しました。
Flask-Loginの_user_idは残しています。
この状態では、
正常コード:
loginへredirect
Mutation後:
dashboardへ200
となります。
正常コードでGREENを確認。
Mutationを再適用するとRED。
KILLEDできました。
89 → 90 tests
です。
ここでは、
正常な操作だけで作られたfixtureでは、異常な状態を再現できないことがある
と分かりました。
SURVIVED #11:date条件を消しても同じ商品の別日データがなかった
最後に試したMutationです。
DailySalesでは更新対象を、
DailySales.query.filter_by(
product_id=product.id,
date=sale_date,
).first()
で検索しています。
つまり、
商品
+
日付
の両方が一致する売上を探します。
ここから一時的にdate条件を削除しました。
DailySales.query.filter_by(
product_id=product.id,
).first()
です。
既存の正常売上POSTテストを実行。
PASS。
DB UniqueConstraintテストも、
PASS。
SURVIVEDしました。
またfixtureが原因だった
既存fixtureには、
同じ商品の別日の売上
が存在していませんでした。
例えば対象商品に、
8月2日の売上
しかなければ、
date条件を削除してもproduct_idだけで偶然同じ行を取得できます。
そこで、
2026-08-01 quantity=5
という既存売上を先に作りました。
その状態で、
2026-08-02 quantity=9
をPOSTします。
正常コードなら、
8月1日 quantity=5
8月2日 quantity=9
になるはずです。
ところがdate条件を削除したMutationでは、
8月1日 quantity=9
となりました。
つまり、
- 8月1日の過去売上を5から9へ誤更新
- 本来作られる8月2日の売上が作成されない
という事故です。
新しいpytestでは、
8月1日の既存データが変更されていない
かつ
8月2日の新しいデータが作成されている
ことを確認するようにしました。
正常コードでGREEN。
Mutation再適用でRED。
KILLEDです。
これで、
90 → 91 tests
となりました。
第2波4件は、すべてSURVIVEDした
第2波の結果です。
| Mutation | 初回 | 原因 | 強化後 |
|---|---|---|---|
| #8 year条件削除 | SURVIVED | 前年同月fixtureがない | KILLED |
#9 Jinja |safe追加 |
SURVIVED | 初期描画を直接見ていない | KILLED |
| #10 fingerprint欠落 | SURVIVED | 異常session fixtureがない | KILLED |
| #11 date条件削除 | SURVIVED | 同一商品の別日売上がない | KILLED |
ここはかなり印象に残りました。
第4段階までで、
87 passed
まで育てていました。
それでも、
「これはまだ弱いかもしれない」と選んだ4 Mutationが、4件ともGREENを通過しました。
テスト件数が増えれば増えるほど安心感も増えます。
しかし、
87件ある
ことと、
87件が重要な故障を区別できる
ことは同じではありませんでした。
最終的に11 MutationすべてKILLED可能になった
今回実施したMutationは11件。
初回結果は、
KILLED 6件
SURVIVED 5件
でした。
SURVIVEDしたのは、
#1
#8
#9
#10
#11
です。
この5件について、
Mutationをrestore
↓
正式なpytestを追加・強化
↓
正常コードでGREEN
↓
同じMutationを再適用
↓
RED
↓
再restore
↓
正常コードでGREEN
まで確認しました。
そのため、今回選択した11件については、
最終的にすべてpytestでKILLできる状態
になりました。
ただし、ここは誤解しないようにしておきます。
これは、
アプリ全体のMutation Score 100%
という意味ではありません。
今回Mutationしたのは、
こちらで選んだ代表的な11件
だけです。
pytestは87件から91件になった
第5段階開始時は、
87 tests
でした。
終了時は、
91 tests
です。
増えたのは4件だけでした。
11 Mutationも実施したので、もっと増える可能性も考えていました。
しかし、
- 6件は既存pytestが最初からKILLED
- #8は既存test functionのfixture強化
- 新規test function追加は#1、#9、#10、#11
だったため、
87 + 4 = 91
となりました。
これはむしろ良かったと思っています。
もし、
100件にしたい
という数字を目標にしていたら、必要性の低いテストまで追加していたかもしれません。
今回の目的は、
テスト件数を増やすことではなく、今あるGREENの検出能力を確認すること
です。
最初の3件から約30倍になった
pytest強化開始前は、
3 tests
でした。
最終的には、
第1段階 9
第2段階 51
第3段階 69
第4段階 87
第5段階 91
となりました。
数だけ見れば、最初の約30倍です。
ただ、第5段階を終えた今は、
91件あることそのものより、何を壊したときにREDになるのか説明できること
の方が重要だと感じています。
第5段階でproduction codeは正式変更していない
今回、正式な変更として残ったのは、
test_ai_integration.py
test_auth.py
test_sales.py
test_xss_regressions.py
の4ファイルです。
production code側には正式差分を残していません。
app.py 正式差分なし
templates 正式差分なし
models 正式差分なし
migrations 正式差分なし
Mutationとして一時的に変更した箇所は、すべてrestoreしています。
つまり第5段階では、
アプリ本体を修正したのではなく、pytest側の検出能力を強くした
ことになります。
今回特に学んだ5つのこと
1. GREENでも故障を見逃す
今回、実際に5件のMutationがGREENを通過しました。
つまり、
pytestがGREEN
でも、
壊れたコードが存在しない
とは限りません。
GREENは、
現在書かれている条件では問題を検知しなかった
という結果です。
2. fixtureにも「識別力」が必要
Mutation #8では、
前年同月のデータがなければyear条件削除を見抜けませんでした。
Mutation #11では、
同じ商品の別日売上がなければdate条件削除を見抜けませんでした。
つまりfixtureは、
テストを実行できるデータ
であるだけでなく、
正常コードと故障コードを区別できるデータ
である必要があります。
3. assertは「ある」だけでなく「ない」も見る
Mutation #1では、
textContentを使用している
ことは確認していました。
しかし、その後に危険なinnerHTMLが追加されても、このassertは成立します。
そこで、
安全な処理が存在する
だけでなく、
危険な処理が存在しない
という逆方向の確認も追加しました。
4. 正常系fixtureだけでは異常状態を作れない
Mutation #10では、
正常loginを経由するとfingerprintが必ず保存されます。
そのため、
fingerprintだけが欠落したsession
という異常状態を明示的に作る必要がありました。
正常なユーザー操作だけを再現していると、
本当に確認したい異常状態へ到達できない場合があります。
5. REDになれば何でもKILLEDではない
Mutation Testingでは、
pytestがREDになっただけで、
すぐに、
KILLED
とは判断しませんでした。
狙ったMutationによって、
狙ったassertionが失敗したことを確認しました。
別の原因で偶然REDになっていたら、
そのMutationを本当に検知できたとは言えないからです。
壊すことより「壊したまま残さないこと」の方が怖い
今回の検証で特に気を付けたのは、
Mutationそのものではありません。
意図的に壊したコードを、そのまま残してしまうこと
です。
そのため、Mutationコードは、
commitしない
pushしない
を徹底しました。
毎回、
Mutation
↓
pytest
↓
restore
↓
git diff
↓
正常コードでpytest
まで確認しています。
Mutation Testingは「壊して確認する」手法ですが、
壊したものを確実に元へ戻すところまで含めて1セットだと感じました。
最終結果
最終pytestは、
91 passed, 2 warnings
でした。
2 warningsは以前から把握している、
migrations/env.py
内のFlask-SQLAlchemy get_engine()に関するDeprecationWarningです。
今回のMutation Testingで新しく発生したwarningではありません。
第5段階とは別の保守作業として扱うため、今回は修正していません。
それでも「完全安全」ではない
今回11件のMutationを実施し、選択した11件すべてについて、最終的にpytestが故障を検知できる状態になりました。
しかし、
このアプリは完全に安全
とは言えません。
今回の範囲外にも、
- PostgreSQL固有の挙動
- locking
- concurrency
- race condition
- 同時POST
- 二重送信
- Render環境との差
- production環境変数
- 実Gemini API
- API quota
- timeout
- SDK変更
- 実ブラウザ上のDOM挙動
- CSP
- 未決定の仕様
- 将来追加されるコード
- まだ想定していない故障
などがあります。
Mutation Testingについても、アプリ全体のコードを網羅的にMutationしたわけではありません。
今回確認できたのは、
「今回選んだ代表的な11種類の故障について、pytestが本当に検知できるのか」
という範囲です。
トラックの安全装置に少し似ている
私は普段、トラックドライバーとして働いています。
トラックには、さまざまな安全装置があります。
しかし、
安全装置が付いている
というだけで、絶対に事故が起きないとは言えません。
危険な状況になったときに、
その安全装置が実際に想定通り作動するのか。
そして、
安全装置があるから大丈夫だと過信していないか。
そこまで含めて考える必要があります。
pytestにも少し似たところを感じました。
pytestがある
CIがある
認証がある
CSRFがある
DB制約がある
だけではなく、
それらが壊れたとき、本当に異常として検知できるのか
を見る必要があります。
さらに、安全装置が優秀でも、
それを使う側の判断まで自動的に安全になるわけではありません。
経験年数が長ければ必ず安全というわけでもなく、
慣れや過信から、
これくらい大丈夫だろう
という判断になることもあります。
ソフトウェアでも、
pytestが全部GREENだから大丈夫だろう
で思考を止めてしまえば、似たことが起こるのかもしれません。
「だろう」ではなく「かもしれない」
今回の第5段階では、本業で普段から意識している、
「かもしれない」
という考え方をかなりそのまま使いました。
pytestがGREENでも
故障を見逃しているかもしれない
安全なコードの横に
危険な処理が追加されるかもしれない
fixtureが単純すぎて
壊れたコードと正常コードを
区別できていないかもしれない
migrationが成功しても
完成schemaが間違っているかもしれない
正常loginだけを確認していて
異常sessionを見ていないかもしれない
その可能性を考え、
実際に小さく壊して確認する。
今回の第5段階は、
そんな検証になりました。
まとめ
pytest強化の第5段階では、
87 passed
↓
91 passed
となりました。
しかし、今回の中心は4件増えたことではありません。
11種類の故障を実際に入れてみた結果、
初回KILLED 6件
初回SURVIVED 5件
となりました。
SURVIVEDした5件を調べていくと、
問題は単純に、
テストが足りない
だけではありませんでした。
assertionの向きが弱い
fixtureに比較対象が足りない
サーバー側描画を確認していない
異常なsession状態を作れていない
同一商品の別日データが存在しない
など、テストそのものの設計に盲点がありました。
その5件について、
正常コードへrestore
↓
pytest追加・強化
↓
正常コードでGREEN
↓
同じMutationを再適用
↓
RED
↓
再restore
↓
最終GREEN
まで確認しました。
最終的に、
今回選んだ11 MutationすべてをpytestでKILLできる状態
になりました。
第1段階では、3件しかなかったpytestを回帰テストとして育て始めました。
第2段階では、DB更新やvalidation、rollbackまで確認しました。
第3段階では、認証・CSRF・アクセス制御まで範囲を広げました。
第4段階では、migrationやAI障害、sessionなど、まだ起きていない事故を先回りして確認しました。
そして第5段階では、
そのGREENそのものを疑いました。
今回一番大きかった学びは、
GREEN = 安全証明
ではないということです。
GREENは、
今書かれているテスト条件について期待通りだった
という結果です。
だからこそ、
本当にこのテストは故障を見抜けるのか?
を、ときどき逆側から確認する価値があると感じました。
最初は3件だったpytestが、最終的に91件になりました。
でも今は、
「91件あること」より、「何を壊したらREDになるのか説明できること」
の方が大切だと思っています。
私の中では、これでpytest強化の第5段階は一区切りです。
単なる正常確認の一覧から始まったpytestが、
少しずつ、
実際に故障を検知できるかまで耐衝撃試験した「事故防止台帳」
へ育ってきました。
pytest強化シリーズ
