1
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?

【Django】DBの接続先をコメントアウトで切り替えていたら事故ったので、戻されたら落ちるテストを書いた話

1
Posted at

はじめに

組織内で試験的に運用しているDjangoプロダクトで、テスト体制を固める作業をしています。今後私の手を離れたときにブラックボックス化しないように、というのが主な目的です。あとは、QAエンジニアへの転職を控えているので、その練習も兼ねています。

この記事では、ローカルからのDB接続先をコメントアウトで切り替えていた構成をやめて、環境変数で明示的に選ぶ形に直した話と、そのときに追加した「誰かが元の方式に戻したら落ちるテスト」の話をまとめます。

AI(Claude)に実装してもらいながら進めたのですが、その中で、AIは特に指示しない限り「人間はうっかり元に戻す」という前提でテストを書いてくれないな、と感じました。そのあたりの所感も最後に少し書きます。

なお、考察の部分は与太話です。定量化したわけでもサンプルを取ったわけでもないので、割とガバの可能性も込みで読んでいただければ幸いです。

元の構成: コメントアウト1つで本番に向く

開発の初期から、settings.py で以下のようなコメントアウト形式の切り替えをしていました。

settings.py(当初の形。名前は汎用名に置き換えています)
if ENV == "local":
    # ローカルで行うテスト用データベース
    # """
    DATABASES = {
        "default": {
            "ENGINE": "django.db.backends.postgresql",
            "NAME": "test_db",
            "USER": "test_user",
            "PASSWORD": DATABASE_PASSWORD,
            "HOST": "127.0.0.1",
            "PORT": "5432",
        }
    }
    """
    # 一時的に本番のデータベースへアクセスする場合の設定
    DATABASES = {
        "default": {
            "ENGINE": "django.db.backends.postgresql",
            "NAME": "prod_db",
            "USER": "prod_user",
            "PASSWORD": DATABASE_PASSWORD,
            "HOST": "127.0.0.1",
            "PORT": "5432",
        }
    }
    # """

# """ の # を1文字消すと、テスト用の設定が文字列リテラルになって無効化され、代わりに本番用の設定が有効になる、という仕掛けです。

これは当初、非常に便利でした。好き勝手書いたり消したりしたいならテスト側、本番データの確認や本番環境でないと試験できないAPIのチェックがしたければ本番側と、ワンタッチで切り替えられるからです。

やらかし: 教科書を消したら、章も進捗も全部消えた

ただ、まあ案の定というか、大いなるやらかしをやってしまいました。Django のシェルで、生徒に紐づいている教科書の設定を外そうとしていたところ、教科書そのものを削除してしまったのです。

イメージ
# やりたかったこと: 生徒と教科書の紐づけを外す
student.textbook = None
student.save()

# 実際にやってしまったこと: 教科書本体の削除
textbook = student.textbook
textbook.delete()
# → Chapter(on_delete=CASCADE)を経由して、
#   章・学習文脈・進捗・クイズ履歴まで連鎖的に消える

幸いにして、古いデータを使って実験していたので、利用者はゼロでした。なんとか元データを読み込み直して、データ状態を戻すことができました。

再発防止として、まず以下を行いました。

  • 教科書を物理削除ではなく、無効化(ソフトデリート)として扱うようにした
  • 教科書と章の紐づけを、親を消すと子も消える CASCADE から、子があると親を消せない PROTECT に変えた
models.py(抜粋・簡略化)
class Textbook(models.Model):
    is_active = models.BooleanField(default=True)

    def deactivate(self):
        self.is_active = False
        self.save(update_fields=["is_active"])

    def delete(self, *args, **kwargs):
        # インスタンス単体の delete() は、物理削除ではなく無効化に回す
        self.deactivate()


class Chapter(models.Model):
    # 旧: on_delete=models.CASCADE
    textbook = models.ForeignKey(Textbook, on_delete=models.PROTECT)

記憶と記録が食い違っていた

ここで1つ、記事を書くために当時のコミットを見返して驚いたことがあります。

私の記憶では、この事故は「テストDBにつながっていると思い込んで、本番DBを操作していた」ものでした。カスケード削除になっていることも多分意識すらしておらず、「なんとなく保護されていると思い込んでいた」。そして応急処置で on_delete を直したけれど、根本原因はコメントアウト1つで接続先が変わる settings.py では?と考えて今回の対策に至った……という流れで覚えていました。

ところが、記録のほうはこうでした。

  • 当時のコミットメッセージには「シェル操作中に、生徒の教科書設定を外すつもりで教科書本体を削除してしまった」としか書かれていない。接続先を取り違えた話はどこにもない
  • 今回の対策のIssueを起票したのは事故の約1か月後で、きっかけとして書かれていたのは別件(本番DBへのマイグレーション確認時に、「LOCAL」という表示と実際の接続先の関係が分かりづらかったこと)

コミットの時系列から見ると、当時は「教科書が未登録の生徒には、取り組めない理由を表示する」という変更を本番に反映した直後でした。なので、生徒の教科書を外した状態の表示を確認しようとしていた、あたりが一番ありそうです(これは推測です)。

接続先を勘違いしていたのか、delete() すれば紐づけだけ外れると勘違いしていたのか、その両方か。正直に言えば、今となってはそれすら曖昧です。この点は最後にもう少し触れます。

対策: 接続先を環境変数で明示する

とはいえ、どちらの勘違いだったにせよ、コメントアウト1つで本番に向く構造が危ないことには変わりありません。疲労やらマルチタスクやらで頭がパーになっている時には、いつもならやらないミスをやります。

第1段階: 表示で気づかせる

まずは最短最速でできる対策として、接続先をはっきり示すprint文を挟みました。

print("Now Connecting to test DB")
...
print("[CAUTION] Now Connecting to PRODUCT DB")

ただ、これは「人が表示に気づくこと」に頼った対策です。「なるほど。今は本番のDBにアクセスしているから気をつけなくては」などとは、頭がぐるぐるしている時は考えません。多分表示も見ていません。

第2段階: 仕組みで止める

そこで、仕組みで止める方向に寄せました。

  • コメントアウトでの切り替えをやめ、環境変数 DB_TARGET で接続先を選ぶ
  • 未指定ならテストDB。本番DBは DB_TARGET=production を明示したときだけ
  • 想定外の値(prod や Production など)はエラーで起動を止める
  • 本番接続時は目立つ警告を出す
  • DB_TARGET は .env に書かない
local_database.py
from django.core.exceptions import ImproperlyConfigured

DB_TARGET_TEST = "test"
DB_TARGET_PRODUCTION = "production"

# DB_TARGET ごとの (データベース名, ユーザー名)
_LOCAL_DB_ACCOUNTS = {
    DB_TARGET_TEST: ("test_db", "test_user"),
    DB_TARGET_PRODUCTION: ("prod_db", "prod_user"),
}


def resolve_db_target(raw_value: str | None) -> str:
    """未指定・空文字はテストDB。想定外の値は起動を止める。"""
    if raw_value is None or raw_value.strip() == "":
        return DB_TARGET_TEST
    value = raw_value.strip()
    if value not in _LOCAL_DB_ACCOUNTS:
        raise ImproperlyConfigured(f"DB_TARGET の値 '{value}' は不正です。")
    return value
settings.py
if ENV == "local":
    DB_TARGET = resolve_db_target(os.getenv("DB_TARGET"))
    print(connection_banner(DB_TARGET))  # 本番なら派手に警告する
    DATABASES = build_local_databases(DB_TARGET, DATABASE_PASSWORD)

本番に接続したいときは、作業するシェルでだけ環境変数を設定します。

$env:DB_TARGET = "production"   # このターミナルだけ本番
# ...作業...
Remove-Item Env:DB_TARGET       # 終わったらすぐ解除

.env に書かない理由と、標準からあえて外れた点

DBの接続先の扱いについては、標準的なやり方がいくつもあります。

  • 設定は環境変数に置く。dev / staging / production のような名前付きの「環境」に設定を束ねず、デプロイごとに独立して管理する(The Twelve-Factor App の Config)
  • settings/base.py・dev.py・prod.py のように設定モジュールを分けて、DJANGO_SETTINGS_MODULE で切り替える
  • 接続情報は DATABASE_URL 1本にまとめて、django-environ の env.db() で読む

ただ、このプロジェクトは settings.py の先頭で .env を自動で読み込んでいます。標準どおりに本番の DATABASE_URL を .env に書くと、その瞬間にすべてのターミナルで本番が既定になり、むしろ今より危険になります。

そのため、「接続先の名前とDBの対応はコードに固定し、本番は作業するシェルで環境変数を明示したときだけ」という、ある意味で標準から外れた形を選んでいます。対応がコードにあれば、レビューとテストで守れる、という理由もあります。

本題: 「戻されたら落ちる」テスト

作業の工程自体は多分ありふれたもので、

  • ChatGPT+Claudeと相談
  • 固まったところでIssueを起票し、スコープを確定させる
  • Claudeに実装してもらい、全てを解説してもらいながら確認
  • Codexにクロスレビューをお願いする

みたいな形でした。

例のごとく、私が修正することもほぼなかったのですが、QAとしての修行中である以上、テストをはいそうですか、と通すのもなんだかなぁと思いました。

最初のテストの抜け

最初に追加されたテストは、resolve_db_target() などの部品を単体で確認するものでした。既定値はテストDBか、production で本番になるか、不正な値で止まるか。

これ自体は正しいのですが、よく考えると、誰かが settings.py に昔のような直書きを戻してしまっても、部品は正しいままなので全部通ってしまいます。今回の安全策は「settings.py が部品を経由すること」で初めて意味を持つのに、そこが守られていません。

配線テスト

そこで提案したのが、もし今後、私だったり、私の雑な指示を聞いて作業したAIだったりが、誤って元の接続方式に切り替えてしまったときに検知できるテストです。

test_local_database.py
import io
import os
import runpy
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch

from django.test import SimpleTestCase

SETTINGS_PATH = Path(__file__).resolve().parent / "settings.py"


class LocalSettingsWiringTests(SimpleTestCase):
    """settings.py が実際に DB_TARGET を経由して DATABASES を決めていることを確認する。"""

    def _run_local_settings(self, db_target):
        with patch.dict(os.environ, {"ENV": "local"}):
            if db_target is None:
                os.environ.pop("DB_TARGET", None)
            else:
                os.environ["DB_TARGET"] = db_target
            stdout = io.StringIO()
            with redirect_stdout(stdout):
                # settings.py を別の名前空間で実行する(DBには接続しない)
                namespace = runpy.run_path(str(SETTINGS_PATH))
        return namespace, stdout.getvalue()

    def test_settings_without_db_target_connects_to_test_db(self):
        """
        DB_TARGET が未設定のまま settings.py を読み込んだ場合に、テストDBになることを確認する。
        settings.py に本番DBが直接書き戻された場合の回帰検知を兼ねる。
        """
        namespace, output = self._run_local_settings(None)

        self.assertEqual(namespace["DATABASES"]["default"]["NAME"], "test_db")
        self.assertNotIn("PRODUCT DB", output)

実際には、このほかに「production なら本番DBと警告」「不正な値なら ImproperlyConfigured で止まる」の2件も、同じ形で確認しています。

ポイントは3つです。

  • 呼ばれたかではなく、結果を見る: settings.py は起動時に一度だけ読み込まれるので、mock で assert_called を確かめるのは難しいうえ、呼んだ後で DATABASES を上書きされるとすり抜けます。runpy.run_path() で settings.py を実行し直して、出来上がった DATABASES を見れば、上書きも検知できます
  • DBには接続しない: settings を読み込むだけなので、本番DBに触れることはありません
  • 環境変数を汚さない: patch.dict(os.environ, ...) を抜けると元に戻るので、ほかのテストに影響しません。DB_TARGET が未設定のケースでは明示的に pop しているので、テストを流すシェルで DB_TARGET が設定されていても結果は変わりません

実際に settings.py を一時的に昔の直書きに戻してみたところ、ちゃんと 'prod_db' != 'test_db' で落ちることを確認しました。テストは「修正前(壊れた状態)で落ちる」ことを確かめてから信用する、というやつですね。

AI向けの指示にも書いておく

ついでに、AI向けの指示ファイル(CLAUDE.md)にも、次のように書き足しました。

  • DB_TARGET=production は、人間がそのコマンドについて明示的に指示したときだけ使う。.env には書かない
  • このガードを戻したり緩めたりしない(DATABASES の直書き、コメントアウト切り替えの復活、prod などの別名の許容、既定値の変更)。テストを通す目的でも。テストが落ちたら、ガードやテストを直さずに報告する

「戻さない」と抽象的に書くより、ありがちな戻し方を具体的に並べて、代わりに取る行動(報告する)まで書いておくほうが、AIは守りやすいようです。

考察: AIは「人がやらかす」前提でテストを書いてくれない

この作業自体は問題なく完了して、既にマージまで終わっています。ただ、毎回作業ごとに学びを抽出していると、この手の指摘、つまり「人間がうっかりやらかす」ことを前提にした指摘は、私から出すと割と通ることが多いなと思ったのです。過去のIssueやdocsを見返しても、

  • 人間側の誤読と、AIのハルシネーションが重なるケースが怖い
  • 今後、個人情報を誤ってログに渡した場合に、仕組み側(テストやフィルタ)が検知できること
  • メソッド名が複数形のままだと、名前だけを見た将来の読み手(人間・AIいずれも)が誤解する

といった観点を、私の側から足した覚えがあります。

今回もそうで、Claudeは最初、Issueで決めた範囲どおりの部品のテストだけを書きました。そこで「将来誰かが戻してしまったら検知できる?」と聞くと、すぐに抜けている点を具体的に説明して、価値が高いと答えてきます。

つまり、知識がなかったわけではない。「この人(や後任)は将来うっかり戻すかもしれない」という前提が、何も言われない状態では優先されなかった、ということなのだと思います。もちろん、AIの中身から理由を説明できるわけではないので、あくまで振る舞いとしてそう見えた、という話です。サンプルもこの1件だけです。

ポカヨケやスイスチーズモデルのように、「人はミスをするものとして設計する」という考え方は工学では昔からあります。AIも知識としては当然持っているはずですが、文脈がそれを呼び起こさない限り、既定では使わないように見えます。対話型のサービスとして、「あなたはいずれやらかします」という前提の提案は、頼まれてもいないのに踏み込みづらい、というのもあるのかもしれません。

一方で、こちら側も「私はアホなので止めて下さい」とは指示しません。仮に言ったとしても、何を忘れて、どう壊すのかまでは指定できません。わかっていない人間は、何がわかっていないかを説明できないので。

なので、実務的に効いたのは次の2つでした。

  1. 未来の誤用を問いの形で投げる: 「将来、事情を知らない誰か(自分やAI)が雑な指示で戻したら、検知できますか?」。これは割とどこでも使える聞き方な気がします。聞けば、AIは即座に答えてくれます。さすがAI。賢い。

  2. 失敗は、事実と解釈を分けて記録する: 先ほどの通り、私は自分の事故の原因を正確に覚えていませんでした。いつ・何のコマンドを・どのDBに対して実行したか、という事実と、なぜやってしまったか、という解釈を分けておくと、後から記憶が組み変わっても事実は残ります。今回は当時のコミットに事実が残っていたおかげで、記憶とのズレに気づけました

そして、そうやって溜めた失敗の記録は、AIにとってはその人固有の前提になります。今回はそれを CLAUDE.md のルールとして書き残しました。AIは一般的な優れたものは出してくれるけれど、私がアホだということは、言われない限り前提にしない。だったら、私のアホさを記録して、前提として渡しておく。そのくらいの距離感が良さそうです。

この「失敗談はなぜ語られないのか」「自分のミスを自分で説明できないのでは」あたりの話は、もう少し掘り下げて別の記事に書く予定です。

まとめ

今回学んだのは、箇条書きにするなら以下のような感じです。

  • コメントアウト1つで本番DBに向く構成は、便利だけれど、頭が回っていない時に事故る
  • 表示(print)による注意は、人が気づくことに頼るので、疲れている時には効かない。仕組みで止める
  • 接続先は環境変数 DB_TARGET で明示し、未指定はテストDB、不正な値は起動を止める。.env には書かない
  • 部品の単体テストだけでは、settings.py に直書きを戻されても気づけない。runpy で settings.py を実行した結果を見る配線テストを足す

ただ、もっと上の、曖昧なレイヤー単位でいうなら、自分で自分のミスを予測して問いかけることの重要性を学んだと思います。AIは、非常に本当にベリーベリーベリーで知識豊富です。人類である我々からみれば、ほぼ無限です。

ただし、全てを想定することはしませんし、多分できません。「人類って愚かなので、ここも想定していた方がいいですよ」を一般論として語れません。人間の愚かさは千差万別であり、多様性に満ち満ちているからです。

だからこそ、曖昧かつ定性的で申し訳無いのですが、「私のメンタルモデルの穴」というのを自己申告すべきなんだと、最近は特に思うことが増えました。私はこれがわからんのだと。あんたの提案したこれが意味不明なんだと。これを声高に主張するのが大事なのかなぁと。

と、今回は以上です。お読みくださりありがとうございました。次回もよろしくお願い致します。

1
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
1
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?