本文へスキップ
hdknr blog
戻る

「対策済みのはず」のバグはなぜ生まれるのか — 規約が形だけ守られて意図を外すとき

あるバックエンドシステムの開発で、2 つの「内部バグ」を踏んだ。どちらも、すでに対策ルールが存在していたはずの領域で起きた。

「規約はあった。テストもあった。なのに通り抜けた」——この構造はおそらく多くのチームに共通する。本稿はその解剖と、再発防止を「人の規律」から「実行される機構」へ昇格させる話。スタックは Python + SQLAlchemy + SQLite + pytest だが、教訓は言語非依存だと思う。


バグ①: 「旧スキーマ」を名乗る fixture が、新カラムを最初から持っていた

trades テーブルにはパフォーマンス用に plan_id カラムとそのインデックスがある。マイグレーション関数 _migrate_add_columns() は、古い DB を起動時に現行スキーマへ追いつかせる役割を持つ(ORM の create_all() は既存テーブルに ALTER を行わないため、手書きの ALTER で差分を埋める方式)。

ところがこの関数は plan_id 列を ALTER TABLE ADD していなかった。一方で、最後にこうしていた:

# trades.plan_id インデックス作成
if "trades" in inspector.get_table_names():
    existing_indexes = {idx["name"] for idx in inspector.get_indexes("trades")}
    if "ix_trades_plan_id" not in existing_indexes:
        with engine.begin() as conn:
            conn.execute(text(
                "CREATE INDEX IF NOT EXISTS ix_trades_plan_id ON trades (plan_id)"
            ))

plan_id 列を持たない本当に古い DB では、この CREATE INDEX ... (plan_id)no such column: plan_id で落ち、マイグレーション全体が中断する。

規約は守られていた

開発規約には明確にこう書いてある:

既存テーブルにカラムを追加する場合 … マイグレーション関数に ALTER TABLE 文を追加する + 旧スキーマからのマイグレーションテストを追加する

そして plan_id 用のテスト(インデックスが追加されることを確認するテスト)は実在していた。問題はその fixture だ:

@pytest.fixture
def legacy_db(tmp_path):
    """trade_source カラムがない旧スキーマのDBを作成."""
    # ...
    conn.execute(text("""
        CREATE TABLE trades (
            id INTEGER PRIMARY KEY,
            ...
            plan_id INTEGER,    # ← 「旧スキーマ」を名乗りつつ plan_id を最初から持っている
            ...
        )
    """))

「旧 DB」を名乗る fixture が、すでに plan_id を含んでいた。だからインデックス作成テストは通り続け、「plan_id 列が無い本当に古い DB」という経路は一度も実行されなかった

規約(テストを追加した)は満たされている。しかし規約の意図(=本当に古いスキーマからの移行を検証する)は満たされていない。形式遵守と意図遵守の乖離だ。

既知の問題 family だった

このプロジェクトは、別の領域で同じ罠を何度も踏んでいた。外部 API・LLM 出力・例外文字列を扱う fixture について、規約にはこう書かれている:

ハンドクラフトで「通常時の綺麗な応答」しか再現しないと、実運用の過渡状態で踏むバグを検出できない エラー文字列を fixture に使う場合は実例外/実テンプレートを再現する

つまり「手書き fixture は、再現したい劣化状態ではなく、現行の綺麗な状態に寄ってしまう」という認識はすでにあった。にもかかわらず、マイグレーション fixture だけはこの原則の管理下に入っていなかった。「一番古いスキーマ」が、コードのどこにも表現されていなかったのだ。


バグ②: もうひとつの対策が、欠陥を完璧に隠していた

2 つ目のバグは、テストが実運用 DB を黙って触っていたこと。

ある実行ガードのテストは、対象モジュールの get_session(DB セッション生成関数)をモックしていた。しかし実行経路の奥で別のモジュール群が、そのテストがモックしたのとは別のセッション参照を使って DB を引いていた。Python では from x import get_session で取り込んだ束縛名は、x.get_session を差し替えても更新されない。だから「import 済みの別名」経由の呼び出しはモックをすり抜け、環境変数 DATABASE_URL が指す ambient な DB に素通りする。

規約はこう言う:

DB に書き込むモジュールのテストはモックセッションではなく実 SQLite セッション(隔離 fixture)を使う

しかしこれは慣習であって強制ではない。fixture を要求しないテストは、黙って ambient DB に落ちる。誰も気づかない。なぜか——常に緑だったから

皮肉: 過去の正しい対策が、欠陥を覆い隠していた

以前、相対パスの DATABASE_URL のせいで別の作業ディレクトリから起動したプロセスが新規 DB を作り、本番データを失う事故があった。その対策として「DATABASE_URL は実 DB の絶対パスで設定ファイルに固定する」という規約が入った。

これは本番では正しい。だが副作用として、CI も開発機も常に「テーブルとデータの揃った実 DB」を指すようになった。結果、分離していないテストでも必ず緑になる。テスト分離の欠陥が露見する唯一の条件——「ambient DB が空 or 不在」——が、この対策によって発生しなくなっていた。

バグが見えたのは、設定ファイルを持たない別ディレクトリ(git worktree)で pytest を回したときだけ。相対パスにフォールバックした DATABASE_URL が空の DB を作り、no such table: trades で初めて落ちた。ひとつの対策が、別の欠陥を見えなくしていたわけだ。


共通の根本原因

2 件はまったく違うバグに見えて、根は同じだった。

1. 規約はプロース(散文)であって、実行されるガードではない

「Xせよ」という文章は、書き忘れ・レビュー見逃しで容易に破れる。そして破ってもビルドは落ちない。①は「テストを書く」を形式的に満たしつつ意図を外し、②は「隔離 fixture を使う」を単に忘れられた。人間の規律に依存するチェックは、緑のCIの前では沈黙する

2. fixture の忠実度ギャップ — 「最古の状態」がどこにも存在しない

実 DB はとっくに移行済み。fixture は現行寄りに手書きされる。誰も「列を追加する前の DB」を手元に持っていない。だから「本当に古い状態」を誰もテストしていない。これは外部 API fixture で散々学んだ教訓と同型だが、マイグレーションには適用されていなかった。

3. 検証環境がひとつしかない — 負の対照が無い

リポジトリのルート + 実設定ファイル + データの揃った DB、という単一の「黄金環境」でしかテストしない。空 DB / 設定ファイル無し / 別ディレクトリ / 最古スキーマ、といった負の対照(negative control)が CI に無い。黄金環境の外でしか出ないバグは、構造的に検出不能になる。


対策: 規約を「機構」へ昇格させる

すぐ入れたもの — 慣習を強制に変えるデフォルト

修正で、全テストの DATABASE_URL を「テーブル作成済みの隔離した一時 DB」へ向ける autouse fixture を追加した:

@pytest.fixture(autouse=True)
def _isolate_database_url(tmp_path, monkeypatch):
    """全テストの DATABASE_URL を分離した一時 DB に向ける."""
    from app import config
    from app.data import database

    db_url = f"sqlite:///{tmp_path / 'isolated.db'}"
    # ... settings cache をクリア ...
    monkeypatch.setenv("DATABASE_URL", db_url)
    database.create_tables(db_url)
    yield
    # ... 後始末 ...

ポイントは、デフォルトが「空の隔離 DB」になったこと。これで「fixture を付け忘れたテストが、実 DB のデータのおかげで偶然通る」という事象が起きなくなる。fixture を忘れれば空 DB で即座に露見する。慣習が、破れないデフォルトに変わった。明示的な DB fixture はこの上から従来どおり優先される。

そしてこの fixture は、モックしきれない「import 済みの別名」経由の呼び出しすら安全な空 DB に向ける。個別テストで全経路をモックする戦いから解放される。

まだ残る穴 — 正直に言うと

①の回帰テストとして minimal な fixture (CREATE TABLE trades (id INTEGER PRIMARY KEY)) を 1 本足したが、これも結局また手書き fixtureだ。今回は最小にしたので忠実度は正しいが、「次にカラムを足す人」が同じ罠を踏むのは防げていない。本当に機構化するなら、次の 2 つが要る。

機構化①: モデル ↔ マイグレーションのパリティテスト

個別の手書き fixture をやめ、モデル定義の全テーブルについて「最小スキーマの旧 DB → マイグレーション関数 → 全カラム・インデックスが揃うか」を総当たりで検証する 1 本を置く。カラムを追加してマイグレーションを書き忘れたら、このテストが自動で落ちる。

機構化②: 負の対照 CI レーン

設定ファイル無し / 空 or 最古 DB / ルート以外のディレクトリ、という前提を意図的に外して全スイートを回す CI job を別途追加する。今回の 2 件は、このレーンがあればマージ前に落ちていた。前提として「正当に環境依存するテスト(認証が要る等)」を marker で分類し、負レーンから除外する整理が要る。


教訓

  1. 規約が「ある」ことと「効いている」ことは別物。 形式遵守ですり抜けられる規約は、規約ではなく願望だ。チェックは実行可能なガード(テスト・lint・CI job)に落として初めて効く。

  2. 対策が別のバグを隠すことがある。 「絶対パスで DB を固定する」という正しい対策が、テスト分離の欠陥を見えなくしていた。「常に緑」は「正しい」を意味しない。緑の理由が偶然でないかを疑う。

  3. fixture は再現したい状態ではなく、現行の綺麗な状態に寄る。 劣化状態・最古状態・過渡状態は、明示的に作らない限りテストされない。「一番古いスキーマ」をコードのどこかに 1 箇所、実体として持つ。

  4. 検証環境がひとつだと、その外のバグは構造的に見えない。 黄金環境の隣に、わざと壊した負の対照を置く。

——「内部バグを産まない仕組み」は、ルールを増やすことではなく、ルールを人が守らなくても成立するデフォルトと、黄金環境の外を見る目を用意することだった。



前の記事
Dario Amodeiの職業予測エッセイが示す「AIを武器にする側」への転換フレーム
次の記事
Claudeは株式取引の天才 — X(旧Twitter)で70万ビューを記録した「AI投資自動化プロンプト」7選の実践ガイド