レガシコードの改善に役立つ pytest の便利なフィクスチャ

2020/04/25 7:47pm

最近、業務でレガシーコード(要するにテストが一切ないコード1)の改善を担当する機会があった。だいたいのステップとして以下のような改善を行なった。

  1. パッケージ管理として Poetry の導入
  2. pytest によるテストの追加
  3. CI によるテストの自動化
  4. 必要なコードの改修とリファクタリング

特に、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():
        ...

tmpdirpy.path.local オブジェクトを返すので、上記のように直感的な書き方で、

  1. テンポラリなディレクトリを作成
  2. そこに work というディレクトリを作成
  3. 作業ディレクトリを work に変更

することができる。

最後に

この他にも pytest にはテストをパラメータ化する機能もあり、それもフィクスチャの仕組みに乗っかっているので、別に Parameterized testing のライブラリを採用するよりも使い勝手がよい。テスト関数の引数にフィクスチャがパラメーター・インジェクションされるスタイルには若干の慣れが必要かもしれないが、一度慣れてしまえばコードの改善に役立つ心強い味方になる。


  1. 参考「レガシーコード改善ガイド↩︎