mock.patchでのテストでwithの多重入れ子になった場合に入れ子から如何に脱却するかを考えてみた

Pythonでmock.patchを使った時にwithの入れ子が酷くなった場合は、テストのやり方を見直してみようと思いました。
2021.02.24

はじめに

Pythonにてmock.patchを使ったテストケースを書いている時に、コンテキストをわかりやすくするためとはいえ以下のような状態に陥ることがあります。

from project import tutorial


def test_a():
    with mock.patch('project.tutorial.env', 'dev'):
        with mock.patch('project.tutorial.get_initial_stage_id', return_value=1):
            with mock.patch('project.tutorial.get_initial_item_set', return_value=[1, 2, 3, 4]):
                with mock.patch('project.tutorial.get_initial_avatar_id', return_value=1):
                    assert tutorial.setup()

書いている時には気にならないのですが、PEP8の文字数指定79に引っかかることにより「酷いな」と気がつく有様です。withを止めるだけで解消できる状態かもしれません。とはいえ考えなしに変更するとテストが通らなくなってしまうことはよくあります。

mock.patchの挙動を確認しつつ、PEP8やテストコードの意図を読み取り易くする対処方法について考えてみました。

decoratorに移動させる

テスト・開発・本番を環境変数にて切り分けている等、テスト内容に左右されず固定化させたい場合にはお薦めです。

変数の場合

from project import tutorial


@mock.patch('project.tutorial.env', 'dev')
def test_a():
    with mock.patch('project.tutorial.get_initial_stage_id', return_value=1):
        with mock.patch('project.tutorial.get_initial_item_set', return_value=[1, 2, 3, 4]):
            with mock.patch('project.tutorial.get_initial_avatar_id', return_value=1):
                assert tutorial.setup()

関数の場合

from unittest import mock
from project import tutorial


@mock.patch('project.tutorial.get_env', return_value='dev')
def test_a():
    with mock.patch('project.tutorial.get_initial_stage_id', return_value=1):
        with mock.patch('project.tutorial.get_initial_item_set', return_value=[1, 2, 3, 4]):
            with mock.patch('project.tutorial.get_initial_avatar_id', return_value=1):
                assert tutorial.setup()

withを使わないようにする

それでもインデントが深いのは変わりません。上記ではreturn_valueが固定値のためdecorator化もできますが、大概は@pytest.mark.parametrizeを利用した、様々なケースをfixture化した上でパラメータとして渡す事が多いでしょう。そこでdecorator化及びwithも使わない方法にて進めます。

テスト対象がClassではなく関数を列挙した構成の場合。

from project import tutorial
from unittest import mock
from unittest.mock import Mock


def test_a():
    tutorial.get_env = Mock(return_value='dev')
    tutorial.get_initial_stage_id = Mock(return_value=1)
    tutorial.get_initial_item_set = Mock(return_value=[1, 2, 3, 4])
    tutorial.get_initial_avatar_id = Mock(return_value=1)
    assert tutorial.setup()

ポイントは実行毎にimportしつつMockで書き換え適用しているところです。インデントも揃いました。

テスト対象がClassの場合

from unittest.mock import Mock


def test_a():
    config = {
      'get_env.return_value': 'dev',
      'get_initial_stage_id.return_value': 1,
      'get_initial_item_set.return_value': [1, 2, 3, 4],
      'get_initial_stage_id.return_value': 1,
    }
    patcher = Mock('project.tutorial', **config)
    mock_thing = patcher.start()
    assert mock_thing.setup()

引数に指定するだけでMock挙動の追加が可能になります。念の為Mock自体の動作を確認しておく必要はあります。

あとがき

Mockの動作はClassベースか関数のみで行っているかにより変わってくるでしょう。Classの利用は内部でセッション管理が必要か、Classを継承した実装のいずれかとなると思います。

withを利用したコードはコンテキストを明確にしたい場合にはとても役に立ちます。ただ、withの入れ子が深くなった時はテスト設計の見直しも検討してみてください。

参考リンク