レガシコードの改善に役立つ pytest の便利なフィクスチャ
最近、業務でレガシーコード(要するにテストが一切ないコード1)の改善を担当する機会があった。だいたいのステップとして以下のような改善を行なった。
特に、2 のステップでは pytest のフィクスチャ機能が非常に便利で、ダミーデータの生成から外部リソースアクセスのモックまで幅広く利用させてもらった。たとえば、Web API アクセスのモックには requests-mock を利用したのだが、
import pytest
import requests_mock as requests_mock_module
@pytest.fixture
def requests_mock():
with requests_mock_module.Mocker() as m:
yield m
@pytest.fixture
def user_api_response():
def response(user_id):
return {
"id": user_id,
"name": "dummy",
"updated_at": fake_iso8601(),
"created_at": fake_iso8601()
}
return response
このような Python コードを conftest.py
として保存しておけば、任意のユニットテストで以下のように Web API アクセスをモックできる。
def test_web_api(requests_mock, user_api_response):
# Web API アクセスのモック
requests_mock.get(
f'https://api.example.com/users/{user_id}',
json=user_api_response(user_id))
# テスト対象の関数を呼び出す
get_user()
また、pytest ではユーザー独自のフィクスチャを定義できるだけでなく、組み込みのフィクスチャも提供されている、ということをはじめて知った。いままで、あまり真面目にドキュメントを読んでいなかったので…。
この記事では、その中でも利用頻度の高かったふたつの組み込みフィクスチャについて紹介したい。
monkeypatch
まずは monkeypatch だ。名前のとおり、メソッドや属性を差し替えることができるフィクスチャを提供する。
今回のプロジェクトでは、テスト対象のモジュールが多くの環境変数に依存していたり、subprocess.run()
や os.system()
で外部コマンドを実行していたので、このフィクスチャが非常に役立った。
たとえば、環境変数を差し替えるためには unittest.mock
だと以下のようになる。
from unittest.mock import patch
@patch.dict('os.environ', {'FOO': 'value'})
def test_sample():
...
個人的に unittest.mock
は複雑であまり好きではないということもあるが、monkeypatch.setenv()
の方が分かりやすいと感じる。
def test_sample(monkeypatch):
monkeypatch.setenv('FOO', 'value')
...
更に、subprocess.run()
による外部コマンド実行を、一部コマンド以外を除いてモックするのも簡単だった。
import subprocess
def test_sample(monkeypatch, subprocess_completion_process_factory):
# Mock: subprocess.run
subprocess_run = subprocess.run
def mock_subprocess_run(command):
# pass through invocation of some commands to the original function.
if command[0] in ['gunzip', 'unzip']:
return subprocess_run(command)
return subprocess_completion_process_factory(returncode=0)
monkeypatch.setattr(subprocess, 'run', mock_subprocess_run)
subprocess_completion_process_factory
はユーザー定義のフィクスチャで、subprocess.CompletedProcess
がもつプロパティのうち、必要なものだけを実装した namedtuple
を返す。
from collections import namedtuple
FakeCompletionProcess = namedtuple('FakeCompletionProcess', ['returncode'])
@pytest.fixture
def subprocess_completion_process_factory():
def factory(returncode):
return FakeCompletionProcess(returncode=returncode)
return factory
もちろん、この「特定のコマンド以外をモックする」フィクスチャを独自に定義することも可能だ。pytest のフィクスチャは他のフィクスチャを引数で受け取れるので、「引数で与えられたコマンドの配列にマッチしたコマンド以外をモックする関数を返す」フィクスチャは以下のように書けるだろう。
import subprocess
import pytest
import re
@pytest.fixture
def monkeypatch_subprocess_run(monkeypatch, subprocess_completion_process_factory):
subprocess_run = subprocess.run
def patch(allowed_commands):
if command[0] in allowed_commands:
return subprocess_run(command)
return subprocess_completion_process_factory(returncode=0)
return patch
...
def test_sample(monkeypatch_subprocess_run):
monkeypatch_subprocess_run(['gunzip', 'unzip'])
...
tmpdir
対象のモジュールは、他のスクリプトがファイルシステム上に出力したファイルにも依存していたので、テストをするためには適切なパスにダミーのファイルを配置してやる必要があった。もちろん、これらのファイルはテスト終了後には残しておきたくないので、テンポラリなディレクトリで作業したいところだ。
もちろん、Python 組み込みの tempfile
モジュールでも実現できるのだが、ここでも pytest の tmpdir
フィクスチャが使いやすい。
def test_sample(tmpdir):
with tmpdir.mkdir('work').as_cwd():
...
tmpdir
は py.path.local
オブジェクトを返すので、上記のように直感的な書き方で、
- テンポラリなディレクトリを作成
- そこに
work
というディレクトリを作成 - 作業ディレクトリを
work
に変更
することができる。
最後に
この他にも pytest にはテストをパラメータ化する機能もあり、それもフィクスチャの仕組みに乗っかっているので、別に Parameterized testing のライブラリを採用するよりも使い勝手がよい。テスト関数の引数にフィクスチャがパラメーター・インジェクションされるスタイルには若干の慣れが必要かもしれないが、一度慣れてしまえばコードの改善に役立つ心強い味方になる。
-
参考「レガシーコード改善ガイド」 ↩︎