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を「事故防止台帳」として育てる 第4段階:障害・異常系を想定して71件→87件へ強化した記録

0
Last updated at Posted at 2026-08-13

pytest_No4.png

この記事は、個人開発中の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

まで進めます。

確認した内容は、

  • products
  • daily_sales
  • alembic_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を作るのではなく、

  1. 同じclientで有効なtokenを取得
  2. 先頭1文字だけ変更
  3. 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 に完全反映済み

mainorigin/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の作業では、

  1. REDを書く
  2. REDになった
  3. しかしREDの原因を調べる
  4. テストfixture側の問題を発見
  5. REDテストを修正
  6. 本当のREDを確認
  7. 修正
  8. 実装が複雑すぎると判断
  9. 一度作った実装を捨てる
  10. より単純なfingerprint方式へ再設計

という流れになりました。

最初に動いたコードをそのまま採用するのではなく、

「本当にこの実装を今後も維持したいか?」

まで考える必要があることを実感しました。

また、pytestは単なる「動作確認」ではなく、

事故が起きるかもしれない場所を先回りして確認する仕組み

として考えると、自分にはとても理解しやすいです。

物流現場で行う「かもしれない運転」と、かなり似ています。

次回:第5段階は「耐衝撃試験」

次の第5段階を、pytest強化シリーズの最終段階にする予定です。

これまでは、

テストを増やして安全装置を作る

ことが中心でした。

第5段階では逆に、

「その安全装置は本当に事故を検知できるのか?」

を確認します。

具体的にはfeature branch上でコードを一時的に小さく壊し、

わざと故障させる
↓
pytest実行
↓
正しくREDになるか確認
↓
即座に元へ戻す

という手動mutation testingに近い耐衝撃試験を予定しています。

意図的に壊したコードはcommit・pushせず、必ず元へ戻してから次の確認へ進みます。

本番環境で行う作業ではありません。

第1段階終了時点では9件だったpytestが、第4段階終了時点で87件まで増えました。

最終回では、

「87件もある」ではなく、「その87件は本当に事故を発見できるのか」

を確認してみます。

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?