この記事は、個人開発中のFlaskアプリでpytestを段階的に強化している記録の「第4段階」です。
第4段階では、正常系の確認を増やすだけではなく、
「もし異常な入力が来たら?」
「もし外部AIが止まったら?」
「もし認証設定が途中で変わったら?」
という、いわば運転でいう**「かもしれない運転」**の考え方でテストを追加しました。
実装・調査にはCodexを利用しています。
なお、今回の作業では内部実装が複雑になり、自分自身の理解が追いつかない場面もありました。
そのためこの記事では、理解できていない内部仕様を無理に解説するのではなく、**「何を問題として、何を確認し、どのような結果になったか」**を中心に記録しています。
pytest強化シリーズ
はじめに
これまで、個人開発中のFlaskアプリでpytestを少しずつ強化してきました。
最初はわずか3件だったテストも、
- 第1段階:3件 → 9件
- 第2段階:9件 → 51件
- 第3段階:51件 → 69件
- その後のdemo seedテスト追加:69件 → 71件
まで増えていました。
第4段階開始時点では、
71 passed
という状態です。
ここまで来ると、
「正常に動くか」
だけではなく、
「異常が起きたときに安全側へ倒れるか」
も確認したくなりました。
私は本業でトラックドライバーとして働いています。
物流現場では、
- 飛び出してくるかもしれない
- 荷物が崩れるかもしれない
- 誤納が起きるかもしれない
- 確認装置が正しく動かないかもしれない
と、事故が起きる前に危険を想定します。
今回のpytest強化も、それと似た考え方です。
「事故が起きたら直す」ではなく、「事故が起きるかもしれない場所を先に確認する」
これを第4段階のテーマにしました。
第4段階の結果
最終的にテスト数は、
71 tests
↓
87 tests
となりました。
16件追加です。
追加した16件の内訳
| 分類 | 追加数 |
|---|---|
| 空DB migration | 1 |
| 非整数dashboard query | 6 |
| Gemini障害fallback | 3 |
| 認証済みAI advice正常経路 | 1 |
| fail-closed session | 2 |
| invalid CSRF token | 3 |
| 合計 | 16 |
最終結果は、
======================= 87 passed, 2 warnings in 15.65s ========================
となりました。
① 空DBからmigrationできるか確認する
最初に確認したのはAlembic migrationです。
既存DBがすでに存在する状態ではなく、
「完全に空のDBを渡しても、migration履歴だけで現在の状態まで構築できるか」
をテストしました。
テストではtmp_path配下に隔離したSQLite DBを作成し、
空DB
↓
Alembic base
↓
upgrade head
↓
現在schema
まで進めます。
確認した内容は、
productsdaily_salesalembic_version- 必須カラム
-
daily_sales(product_id, date)の複合一意制約 - DB側revisionと現在のAlembic headが一致すること
です。
revision IDそのものはテストコードへ固定せず、現在のheadを動的に確認する方式にしました。
会社で例えるなら、
「すでに営業中の本社が動くか」ではなく、「更地から新しい支店を作っても設計図どおり完成するか」
を確認するテストです。
結果:
1 passed
② 不正なyear / monthで500にならないようにする
次に調査したのがdashboard系のquery parameterです。
対象は、
/dashboard/api/dashboard-data/api/ai-advice
でした。
当時は、
?year=abc
や、
?month=abc
のような値が来ると、直接int()へ渡していたためValueErrorが発生し、500エラーになる可能性がありました。
そこで仕様を、
「非整数のyear / monthはHTTP 400 Bad Requestで拒否する」
と決めました。
追加したテストは、
3 routes × year/month
= 6 cases
です。
最初は6件すべてREDになりました。
expected: 400
actual: 500
そこでquery parameterを整数へ変換する小さなhelperを追加し、不正値の場合はabort(400)するよう修正しました。
結果:
6 passed
AI advice APIでは、不正queryの場合にGemini Clientまで到達しないことも確認しています。
③ Geminiが止まった場合のfallbackを固定する
このアプリではGemini APIを利用しています。
しかし外部サービスなので、
- 429
- 503
- その他の例外
が発生する可能性があります。
そこで、既存のfallback処理をpytestで固定しました。
今回確認したのは、
429 → 429用fallback
503 → 503用fallback
その他 → 一般fallback
です。
実Gemini APIには接続せず、すべてmockを使用しました。
このテストは新しい不具合を発見したわけではなく、
すでに存在していた安全装置を回帰テストとして記録する
目的です。
結果:
3 passed
会社で例えるなら、
「取引先のシステムが止まったとき、自社まで一緒に倒れないか」
を確認しているようなものです。
④ 認証済みAI adviceの正常経路も確認する
障害時だけではなく、
正常時に各部署が正しく連携しているか
も1件追加しました。
確認した流れは、
認証
↓
query処理
↓
SQLite DB集計
↓
prompt生成
↓
mock Gemini
↓
JSON response
です。
例として、
2026年8月
商品A:10
商品B:5
というデータがある場合、
- 8月の商品A・Bがpromptへ入る
- 別月の商品は入らない
- mock Geminiの返答がJSONとして返る
ことを確認しました。
結果:
1 passed
⑤ fail-closed:認証設定が変わったら古いsessionを信用しない
第4段階で最も試行錯誤したのがここでした。
テーマはfail-closedです。
fail-closedとは、
何かがおかしい場合に、安全側へ倒す
という考え方です。
信号機で例えるなら、
「故障したからとりあえず青」
ではなく、
「故障したなら赤として止める」
というイメージです。
最初に見つかった問題
認証済みsessionが存在している状態で、
ADMIN_PASSWORD_HASH
が不正な値へ変更されても、既存sessionから管理者が復元される可能性が見つかりました。
そこで、
正常login
↓
ADMIN_PASSWORD_HASH変更
↓
GET /dashboard
↓
/loginへ302されることを期待
というREDテストを作りました。
しかしここで、別の問題が発覚しました。
REDテストそのものが正しい経路を通っていなかった
テスト環境ではapp contextが長く維持されており、
g._login_user
に前requestの認証情報が残っていました。
そのため、
session
↓
load_user()
をテストしているつもりが、
実際には、
前requestのg._login_user
↓
そのまま認証済み
という経路を通っていました。
つまり、
REDになったからといって、想定した原因でREDになっているとは限らない
ということです。
そこでテスト側で前requestのキャッシュを除去し、load_user("admin")が本当に再実行されたことをspyで確認しました。
その状態でも、
expected: 302
actual: 200
となり、今度こそ本当のREDを確認できました。
この作業で特に印象に残ったのは、
GREENが正しい理由でGREENなのか確認するだけでなく、REDも正しい理由でREDなのか確認する必要がある
という点です。
一度は約60行のhash parserを作った
最初の修正案では、
Werkzeugのpassword hashが正しい形式か確認するため、
- scrypt
- pbkdf2
- digest
- parameter
などを独自に解析するhelperを実装しました。
テスト自体はGREENになりました。
しかしレビューすると、
「今回の目的に対して実装が複雑すぎる」
という問題が出てきました。
Werkzeugのhash仕様をアプリ側でも維持する必要があり、将来方式が追加された場合にも追従が必要になります。
そこで一度立ち止まり、実装を再設計しました。
fingerprint方式へ変更する
今回本当に確認したかったのは、
「現在のhashが正しい形式か」
ではありませんでした。
確認したかったのは、
ログイン時の認証設定
==
現在の認証設定
かどうかです。
そこで、ログイン成功時のADMIN_PASSWORD_HASHからSHA-256 fingerprintを作り、sessionへ保存する方式へ変更しました。
イメージとしては、
ログイン成功
↓
現在の鍵の「指紋」を記録
↓
次request
↓
現在の鍵からもう一度指紋を作る
↓
一致 → 通す
不一致 → 再ログイン
です。
sessionへ保存するのはfingerprintだけで、
- password hash本体
- 実password
- 平文password
は保存しません。
fingerprintの作成は小さなhelperに分けました。
会社で考えると、
「鍵そのものを預かるのではなく、鍵の指紋を確認する係員を1人置いた」
ようなものです。
最終的に確認したテストは、
hash変更 → session拒否
hash未変更 → session正常復元
の2件です。
結果:
2 passed
⑥ 改ざんCSRF tokenを拒否できるか
最後に確認したのがCSRFです。
既存pytestでは、
CSRF tokenなし
のPOSTが拒否されることは確認済みでした。
しかし、
tokenは存在するが、偽造・改ざんされている
ケースは未確認でした。
空港で例えるなら、
パスポートを持っていない
→ 拒否
だけでなく、
偽造パスポートを持っている
→ 拒否
まで確認するイメージです。
今回のテストでは、完全な偽tokenを作るのではなく、
- 同じclientで有効なtokenを取得
- 先頭1文字だけ変更
- POST
という方法を使いました。
追加したのは3件です。
login
正しいusername/password
+
改ざんCSRF token
↓
400
↓
認証不成立
商品登録
改ざんCSRF token
↓
400
↓
Product不変
DailySales不変
売上登録
改ざんCSRF token
↓
400
↓
DailySales不変
結果:
3 passed
Flask-WTF側の防御はすでに正常に動作していたため、3件とも最初からGREENでした。
つまり今回は、
新しくセキュリティ機能を追加したのではなく、既存の保安検査が本当に機能していることを監査記録へ追加した
形になります。
最終的なpytest
すべての変更後、
pytest -v
を実行しました。
結果:
collected 87 items
...
======================= 87 passed, 2 warnings in 15.65s ========================
failure / errorは0件でした。
第4段階のcommit一覧
bb8b503 test: cover empty database migrations
a1a5578 fix: reject invalid dashboard query parameters
b36ad7a test: cover Gemini failure fallbacks
757ef6f test: cover authenticated AI advice route
412fcb3 fix: invalidate sessions when admin credentials change
26ae4f8 test: cover invalid CSRF tokens
GitHub Actionsでも確認する
ローカルで87件GREENを確認したあと、
feature/pytest-stage4
↓
Pull Request
↓
GitHub Actions
↓
main
という流れで取り込みました。
PRでは、
All checks have passed
を確認し、conflictがないことも確認してからmergeしました。
merge後はローカルmainを更新し、
git switch main
git pull
を実行しました。
さらに念のため、
git merge-base --is-ancestor feature/pytest-stage4 main \
&& echo "OK: feature/pytest-stage4 は main に完全反映済み" \
|| echo "NG: mainに未反映commitあり"
でも確認しました。
結果:
OK: feature/pytest-stage4 は main に完全反映済み
mainとorigin/mainのSHAも一致し、feature branchとmainのファイル差分もありませんでした。
そして、merge後のmainでも再度、
pytest -v
を実行。
最終結果は、
87 passed, 2 warnings
でした。
会社で例えるなら、
試験部署で安全設備を作る
↓
本社監査を受ける
↓
正式採用
↓
本社でも全87項目を再点検
まで行った状態です。
残った2 warnings
現在、migrationテスト実行時に2件のwarningが出ています。
DeprecationWarning:
'get_engine' is deprecated and will be removed in Flask-SQLAlchemy 3.2.
対象は、
migrations/env.py
に残っているget_engine()です。
今回追加したmigrationテストによって表面化しましたが、第4段階の変更による不具合ではありません。
このwarningは認識していますが、第4段階ではscope外として修正していません。
「見つけたからついでに直す」のではなく、今回の目的から外れる変更は別作業として扱う方針にしています。
第4段階を終えて
第4段階ではテスト数が、
71
↓
87
へ増えました。
ただ、今回一番大きかったのは件数ではありません。
特にfail-closedの作業では、
- REDを書く
- REDになった
- しかしREDの原因を調べる
- テストfixture側の問題を発見
- REDテストを修正
- 本当のREDを確認
- 修正
- 実装が複雑すぎると判断
- 一度作った実装を捨てる
- より単純なfingerprint方式へ再設計
という流れになりました。
最初に動いたコードをそのまま採用するのではなく、
「本当にこの実装を今後も維持したいか?」
まで考える必要があることを実感しました。
また、pytestは単なる「動作確認」ではなく、
事故が起きるかもしれない場所を先回りして確認する仕組み
として考えると、自分にはとても理解しやすいです。
物流現場で行う「かもしれない運転」と、かなり似ています。
次回:第5段階は「耐衝撃試験」
次の第5段階を、pytest強化シリーズの最終段階にする予定です。
これまでは、
テストを増やして安全装置を作る
ことが中心でした。
第5段階では逆に、
「その安全装置は本当に事故を検知できるのか?」
を確認します。
具体的にはfeature branch上でコードを一時的に小さく壊し、
わざと故障させる
↓
pytest実行
↓
正しくREDになるか確認
↓
即座に元へ戻す
という手動mutation testingに近い耐衝撃試験を予定しています。
意図的に壊したコードはcommit・pushせず、必ず元へ戻してから次の確認へ進みます。
本番環境で行う作業ではありません。
第1段階終了時点では9件だったpytestが、第4段階終了時点で87件まで増えました。
最終回では、
「87件もある」ではなく、「その87件は本当に事故を発見できるのか」
を確認してみます。
pytest強化シリーズ
