雑記: pytest の monkeypatch の話

pytest でテストをするとき monkeypatch でモジュールや環境変数をモックすると思います。一昨日の記事のように pytest が利用可能な pipenv 環境を構築すると以下の(無意味な)テストが実行できると思います。

import os

def test_fuga(monkeypatch):
    monkeypatch.setenv("USER", "TestingUser")
    assert os.environ["USER"] == "TestingUser"
$ pipenv run pytest test_hoge.py


なので pytest というコマンドを実行すると「monkeypatch という何か便利なものをどこかからもってきて」「関数 test_fuga を実行する」ということを勝手にやってくれるのですがなんか怖いのでどうなっているか確認したいと思います。pytest というコマンドを実行すると以下のように処理が進んでいくと思います。
https://github.com/pytest-dev/pytest/blob/6.2.5/setup.cfg#L64
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/config/__init__.py#L178
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/config/__init__.py#L130
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/config/__init__.py#L297
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/config/__init__.py#L263
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/config/__init__.py#L678
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/config/__init__.py#L703
この行でちゃんと以下のモジュール(monkeypatch が実装されている)がインポートされていて安心すると思います(monkeypatch 以外にも組み込みフィクスチャが色々インポートされていますが)。

_pytest.monkeypatch


テストの実行は上の L130 の関数まで戻って以下らへんになると思います。面倒になってきました。
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/config/__init__.py#L162
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/main.py#L319
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/main.py#L336


それで肝心の monkeypatch の実装は以下であり、MonkeyPatch クラスのインスタンスを1回だけ取り出せるジェネレータ関数であることがわかると思います。2回目の取り出しをしようとすると(取り出せないが)インスタンスの undo というメソッドがよばれるようです。明らかにモックしたモジュールや環境変数を元に戻しているのでしょう。
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/monkeypatch.py#L29-L51
しかし、冒頭の test_hoge.py 内の monkeypatch はジェネレータ関数としてふるまっているようにみえません。monkeypatch ジェネレータ関数にはよくみると fixture というデコレータが付いています。その実装は以下にあります。
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/fixtures.py#L1263-L1335
fixture デコレータは関数に _pytestfixturefunction というアトリビュートを付けるだけのようです。
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/fixtures.py#L1201
そのファイルを読むと、テストに monkeypatch と記述するとジェネレータ関数の monkeypatch が得られるのではなく、「monkeypatch をコールして得たイテレータから MonkeyPatch クラスのインスタンスを取り出したもの」が私たちのもとに届けられているようです(以下)。
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/fixtures.py#L923-L925
つまり、上のファイルを読むと、 _pytestfixturefunction というアトリビュートをつけた関数がテストから取り寄せられたとき、それがジェネレータ関数であるならば(yield の数は1つだけでないと駄目です)、テストのセットアップ時にそれをコールしたイテレータからその1つ目の値が取り出され、テスト後に yield より後の処理がなされるらしいことがわかると思います。ジェネレータ関数でなく通常の関数でも構いません(「イテレータの1つ目の値」が単に「関数の戻り値」になり、後処理がなくなるだけです)。


ちなみに pytest のリポジトリを手元にチェックアウトして以下のように Pipfile をかくと pytest のコードに直接デバッグプリントを仕込みながら pytest を実行できますが(pipenv でなくてもできるが)、このとき単に pipenv run pytest とテスト対象ファイルを指定せずに実行すると手元の pytest ディレクトリ以下までテストが探索され、リポジトリに同梱されているサンプルスクリプトからエラーが出てびっくりすると思います。

...

[packages]
pytest = {path = "./pytest"}

...