はじめに
VS CodeのTest Explorerではpytestが成功するのに、ターミナルから実行すると失敗することがありました。
python -m pytest
結果は次のようなエラーです。
E KeyError: 'API_BASE_URL'
ERROR tests/test_config.py - KeyError: 'API_BASE_URL'
Interrupted: 1 error during collection
原因は、VS CodeのTest Explorerとターミナルで、.envの扱いが異なっていたことでした。
ただし、単にVS Codeの設定を揃えるだけでは、CI環境で同じ問題が再発します。
今回は、設定による解決と、環境に依存しないテストへの修正を行います。
再現用コード
ディレクトリ構成は次のとおりです。
.
├── .env
├── app
│ └── config.py
└── tests
└── test_config.py
.envにはAPIの接続先を定義します。
API_BASE_URL=https://example.test
app/config.pyでは、環境変数を読み込みます。
import os
API_BASE_URL = os.environ["API_BASE_URL"]
テストは次のようにします。
from app.config import API_BASE_URL
def test_api_base_url() -> None:
assert API_BASE_URL == "https://example.test"
VS CodeのTest Explorerでは成功しました。
1 passed
しかし、ターミナルでは失敗しました。
python -m pytest -q
ERROR collecting tests/test_config.py
E KeyError: 'API_BASE_URL'
1 error during collection
テスト関数が失敗したのではなく、テストの収集中にエラーが発生しています。
なぜTest Explorerだけ成功したのか
VS CodeのPython拡張機能には、環境変数ファイルを指定する設定があります。
{
"python.envFile": "${workspaceFolder}/.env",
"python.testing.pytestEnabled": true,
"python.testing.pytestArgs": [
"tests"
]
}
python.envFileのデフォルト値は、次のパスです。
${workspaceFolder}/.env
Python拡張機能は、このファイルの環境変数をテストの検出・実行時に読み込みます。
一方、VS Codeの統合ターミナルへ.envの内容を注入する設定は、別に存在します。
{
"python.terminal.useEnvFile": true
}
この設定のデフォルト値はfalseです。
つまり、初期設定では次のような差が生まれます。
Test Explorer
→ .envを読み込む
統合ターミナル
→ .envを自動では読み込まない
そのため、同じVS Code上でも実行方法によって結果が変わります。
ターミナル側の設定を揃える
Test Explorerと統合ターミナルの環境を揃えるには、次のように設定します。
{
"python.envFile": "${workspaceFolder}/.env",
"python.terminal.useEnvFile": true,
"python.testing.pytestEnabled": true,
"python.testing.pytestArgs": [
"tests"
]
}
設定後、新しいターミナルを開きます。
echo $API_BASE_URL
https://example.test
pytestも成功します。
python -m pytest -q
1 passed
ただし、これで問題が完全に解決したわけではありません。
import時に環境変数を読む問題
元のコードでは、モジュールのimport時に環境変数を読み込んでいます。
API_BASE_URL = os.environ["API_BASE_URL"]
pytestは、テストを収集するためにテストモジュールと、その中で参照されるモジュールをimportします。
そのため、環境変数が存在しない場合、次の順序で失敗します。
pytestを起動
↓
test_config.pyを収集
↓
app.configをimport
↓
API_BASE_URLを読み込む
↓
KeyError
↓
テスト関数まで到達しない
テスト関数の中で環境変数を設定しようとしても、importが先に実行されるため間に合いません。
def test_api_base_url(monkeypatch):
monkeypatch.setenv("API_BASE_URL", "https://example.test")
# この前にapp.configのimportが失敗している
環境変数を必要なときに読む
環境変数をモジュールのimport時ではなく、関数が呼ばれたときに読むように変更します。
import os
def get_api_base_url() -> str:
try:
return os.environ["API_BASE_URL"]
except KeyError as exc:
raise RuntimeError(
"API_BASE_URL is required"
) from exc
これなら、app.configをimportしただけでは環境変数を読みません。
monkeypatchで環境依存をなくす
テストでは、pytestのmonkeypatchを使って環境変数を明示します。
import pytest
from app.config import get_api_base_url
def test_get_api_base_url(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv(
"API_BASE_URL",
"https://example.test",
)
assert get_api_base_url() == "https://example.test"
環境変数が存在しない場合もテストします。
def test_get_api_base_url_requires_environment_variable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv(
"API_BASE_URL",
raising=False,
)
with pytest.raises(
RuntimeError,
match="API_BASE_URL is required",
):
get_api_base_url()
monkeypatch.setenv()とmonkeypatch.delenv()で変更した環境変数は、各テストの終了後に元へ戻されます。
実行結果は次のとおりです。
python -m pytest -q
.. [100%]
2 passed
このテストは、.envが存在しなくても成功します。
mv .env .env.backup
python -m pytest -q
.. [100%]
2 passed
VS Code、通常のターミナル、CIのどこで実行しても、同じ条件でテストできるようになりました。
.envは実行用、monkeypatchはテスト用
今回の整理は次のとおりです。
.env
→ ローカルでアプリを実行するための設定
python.envFile
→ VS CodeのPython拡張機能が読むファイル
python.terminal.useEnvFile
→ 統合ターミナルにも.envを注入する設定
monkeypatch
→ テスト条件をコード内で固定する仕組み
.envをテストの前提にすると、開発者ごとの環境やCI設定によって結果が変わります。
単体テストで必要な値は、可能な限りテストコード側で明示した方が再現性を高められます。
.envをGitへ追加しない
APIキーなどを含む.envは、通常Gitへコミットしません。
.env
代わりに、変数名だけを書いた.env.exampleを置きます。
API_BASE_URL=
これで、必要な環境変数を共有しつつ、実際の値は各環境で管理できます。
まとめ
VS CodeのTest Explorerだけpytestが成功するときは、テストコード以外に.envの読み込み差も確認する必要があります。
{
"python.envFile": "${workspaceFolder}/.env",
"python.terminal.useEnvFile": true
}
ただし、設定を揃えるだけではCIとの環境差は残ります。
より重要だったのは、次の2点です。
- 環境変数をimport時に固定しない
- テスト条件は
monkeypatchで明示する
Test Explorerでテストが通ることだけでなく、どこで実行しても同じ結果になることが、テストの再現性では重要だと感じました。