1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

pytest(主に pytes-mock)のモックについて、ややこしいと思ったこと

Last updated at Posted at 2022-01-29

はじめに

pythonとユニットテストについて学び始めたのでメモを残す。
誤りや意見などあれば、是非お願いします。

今回は、osモジュールを例にモックするパターンを考えていこうと思う。
大きく2つに分けて、下記パターンを考える。

  1. mocker.patch()でモジュールの対象メソッドをモックする
  2. mocker.patch.object()でモジュールの対象メソッドをモックする
  3. (オプション)monkeypatch.setattr()でモックする

また、from ~ importを利用する場合の考慮点も書いてみる。
boto3 モジュールのモックについても余力があれば書きたい。

学習する中で思ったこと

pythonのユニットテストは、そこそこ種類(名前が違うだけで動作はほぼ同じはず)があり混乱した。
このstackoverflowの内容に共感した。

テスト関係のモジュール

  • pytest
  • pytest-mock

確認したこと

とりあえず、実装パターンを下記に示す。
どれでもos.path.joinをモックできているはず。。。
モジュールをモックする場合の使い分けが理解できていない。(違いについて教えてくれると喜びます。

mocker.patch()でモジュールの対象メソッドをモックする

テストの意味は考えず、適当なコードで確認する。

  • hoge.py: テスト対象ファイル
  • test_hoge.py: テストコードファイル
# test_hoge.py
import pytest
import hoge

@pytest.fixture
def mock_os_path_join(mocker):
    # joinをパッチ
    mocker.patch('os.path.join', return_value='/x/y/z') # mocker.patchを使用

def test_hoge(mock_os_path_join):
    path = hoge.func('a/b')
    assert path == '/x/y/z'
# hoge.py
import os

def func(dir):
    path = os.path.join(dir, 'b/d')
    return path

mocker.patch.object()でモジュールの対象メソッドをモックする

# test_hoge.py
import pytest
import hoge
import os

@pytest.fixture
def mock_os_path_join(mocker):
    # joinをパッチ
    mocker.patch.object(os.path, 'join', return_value='/z/y/x') # mocker.patch.objectを使用

def test_hoge(mock_os_path_join):
    path = hoge.func('a/b')
    assert path == '/z/y/x'

(オプション)monkeypatch.setattr()でモックする

# test_hoge.py
import pytest
from pytest_mock import mocker
import hoge
import os


@pytest.fixture
def mock_os_path_join(monkeypatch):
    def mock_join(*args, **kwargs):
        return '/z/y/x'
    # monkeypatch.setattrを使用
    monkeypatch.setattr(os.path, 'join', mock_join)
    
    # monkeypatch.setattr(os.path, 'join', mocker.Mock(return_value='/z/y/x'))
    # monkeypatchにMockオブジェクトを使うことで、return_valueを動的にセット可能
    # mocker単体で上記仕組みは実装できるので、今回だとmonkeypatchを使用する意味はほぼないと思う

def test_hoge(mock_os_path_join):
    path = hoge.func('a/b')
    assert path == '/z/y/x'

Mockオブジェクトを使うと、return_valueside_effectで色々できるのが便利だと思う。

from ~ importを使用したモジュールをモックする場合

個人的にこのパターンは面倒くさいと思うのでメモする。
from os.path import joinこんな感じでインポートしたとき、テストコード側も変わる。
対象のモジュールがオブジェクトとしてインポートされていることを意識することが大事。

引用

from import によって利用可能になったモジュール変数はもとのモジュールから切り離されるため、 もとのモジュールをパッチしても書き換えられません。

# hoge2.py
from os.path import join
print(f'joinオブジェクトが属するモジュール名: {__name__}')

def func(dir):
    path = join(dir, 'b/d')
    # print(path)
    return path

mocker.patchを使用

# test_hoge2.py 
import pytest
import hoge2

@pytest.fixture
def mock_os_path_join(mocker):
    # hoge2.pyでインポートしたjoinオブジェクトが属するモジュールを指定する必要がある
    mocker.patch('hoge2.join', return_value='/x/y/z')

def test_hoge(mock_os_path_join):
    path = hoge2.func('a/b')
    assert path == '/x/y/z'

mocker.patch.objectを使用。
fixtureでhoge3をimportする必要はなかったと思う。

# test_hoge3.py
import pytest

@pytest.fixture
def hoge3():
    import hoge3
    return hoge3

@pytest.fixture
def mock_os_path_join(mocker, hoge3):
    mocker.patch.object(hoge3.path, 'join', return_value='/z/y/x')

def test_hoge(mock_os_path_join, hoge3):
    path = hoge3.func('a/b')
    assert path == '/z/y/x'
# hoge3.py
from os import path

def func(dir):
    ret = path.join(dir, 'b/d')
    return ret

boto3モジュールをモックする場合

そもそもmotoモジュールがモック対象をサポートしているなら、motoを使うほうがよいはず。
今回は、mocker.patch系統を使う場合について考える。
どのレベルでモックするべきか迷ったので、メモする。

boto3.client()clientをモックする

この単位でモックするのは好ましくないと思う。client関数全体をモックすると、他への影響が大きそう。

# app.py
import boto3

translate_cliet = boto3.client('translate')

def main():
    print(translate_cliet)
    res = translate_cliet.translate_text(
        Text='おはよう',
        SourceLanguageCode='ja',
        TargetLanguageCode='en',
    )
    print(res)
    return res
# test_app.py
import boto3
import pytest

@pytest.fixture
def app():
    import app
    return app

@pytest.fixture
def mock_boto3_client(mocker):
    mock_client = mocker.Mock()
    mock_client().translate_text.return_value = '翻訳結果のオブジェクト'
    mocker.patch('boto3.client', mock_client)

def test_app(mock_boto3_client, app):
    assert app.main() == '翻訳結果のオブジェクト'

boto3.client()から生成したオブジェクトをモックする

app.pyのtranslate_client変数をモックしているので、範囲が最小限。

import boto3
import pytest


@pytest.fixture
def app():
    import app
    return app


@pytest.fixture
def mock_boto3_client(mocker, app):
    translate_client = boto3.client('translate')
    mock_client = mocker.Mock(wraps=translate_client)
    mock_client.translate_text.return_value = '翻訳結果のオブジェクト'
    mocker.patch.object(app, 'translate_cliet', mock_client)


def test_app(mock_boto3_client, app):
    assert app.main() == '翻訳結果のオブジェクト'

おわりに

単語の理解が曖昧だと思った。「モックする」と「パッチする」のどっちが正しいのか、書きながらわからなくなったので後で整理する。
MockMagicMockの違いや、キーワード引数の動作についても調べる必要があると感じる。
最後に、pytestを学習する際は一緒にunittest.mockを調べるのが大事だと思った。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?