0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

📝pytestを「事故防止台帳」として育てる 第5段階:GREENを疑い、Mutation Testingで「本当に守れているか」を検証した

0
Last updated at Posted at 2026-08-15

pytest_No5.png

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のorandへ変更 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が使用されていない

ことまでは確認していませんでした。

つまり、

安全な処理が存在する

ことだけを見ていたわけです。

そこで、

  • innerHTML
  • outerHTML
  • insertAdjacentHTML

など、未信頼データを渡すと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強化シリーズ


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?