Masayan tech blog.

  1. ブログ記事一覧>
  2. 実践的な視点から学ぶ: Pytestを使った自動テストの効率的な書き方

実践的な視点から学ぶ: Pytestを使った自動テストの効率的な書き方

公開日

環境

  • Python 3.9
  • VSCode

pytestとは何か

pythonで単体テストを始めるとなった際、以下の2種類が代表的なライブラリ

unittest

Python標準テストライブラリ。APIが少し冗長...

pytest

外部のテストライブラリ。unittestよりよりシンプルで直感的なAPIを提供。現在はこちらが主流となっている

インストール方法

pip等でインストールできる

pip install pytest

テスト検出のルール

特段、設定をしない限り以下のように決まる。ルールに沿っていないといくらテストケースを作成しても実行されないので注意

テスト対象のディレクトリ

  • ディレクトリ名に縛りはなし。testsでもtestでもそれ以外でもOK
  • 下記のテスト対象のファイルおよび関数、メソッドの命名規則が守られていればテストファイルとして認識される

テスト対象のファイル

test_*.pyないし、*_test.pyに一致するファイル名を持つファイルが対象

  • tests/func_a_test.py
  • tests/test_func_a.py

テスト対象のファイルのうち、testプレフィックス付きの関数、メソッド(サフィックスは不可)が最終的なテスト対象のケースとなる

以下は対象

def test_func_a():

以下は対象でない

def func_a_test():

より詳細についてはこちらを参考にされたし

テストの書き方

  • クラス形式、関数形式の2つの書き方がある
  • テスト対象がクラスの場合はクラス形式のテストで、テスト対象が関数の場合は関数形式のテストで作成すると理解しやすく読みやすい
  • 以下にそれぞれの記述方法の違いを示す

クラス形式

テスト対象

something/something_factory.py

from something.something import Something

class SomethingFactory:
    def create(self):
        return Something()

テスト

tests/something/test_something_factory.py

from something.something import Something
from something.something_factory import SomethingFactory

class TestSomethingFactory:
    def test_Somethingクラスのインスタンスを生成できる(self):
        assert SomethingFactory().create(id="") == Something()

関数形式

テスト対象

something/create_something.py

from something.something import Something

def create_something():
    return Something()

テスト

tests/something/test_create_something.py

from something.create_something import create_something
from something.something import Something

def test_Somethingクラスのインスタンスを生成できる():
    assert create_something() == Something()

アサート

一般的なアサート

assertでexpectとactualをアサートする

from something.something import Something
from something.something_factory import SomethingFactory


def test_Somethingクラスのインスタンスを生成できる():
    assert SomethingFactory().create() == Something()

例外が送出されることをアサート

withブロックの中でpytest.raisesを使用して期待する例外が実際に送出されることをアサートする

テスト対象

something/something_factory.py

from something.something import Something


class SomethingFactory:
    # idが空文字列の場合、例外を送出する
    def create(self, id: str):
        if id == "":
            raise ValueError("Id is empty")
        return Something()

テスト

tests/something/test_something_factory.py

import pytest
from something.something import Something
from something.something_factory import SomethingFactory


class TestSomethingFactory:
    def test_Somethingクラスのインスタンスを生成できる(self):
        with pytest.raises(ValueError):
            assert SomethingFactory().create(id="") == Something()

実際の例外にアクセスしたい場合は以下の通りにexcinfoで行う。

def test_my_function():
    with pytest.raises(ValueError) as excinfo:
        my_function()  # This function raises ValueError

    assert str(excinfo.value) == "Expected error message"

テストの実行方法

pytestコマンドを引数なしで実行した場合、現在のディレクトリ(コマンドを実行したディレクトリ)とそのサブディレクトリがテストの対象となる

pytest

特定のディレクトリ

pytest tests/

特定のファイル

pytest tests/test_sample.py

便利なオプション

標準出力、標準エラーを表示

-sをつける

pytest -s test_func.py

テスト実行時のヘッダー情報(pytestのバージョン、Pythonのバージョン、プラグインの情報など)を非表示にしたい

--no-headerをつける

pytest --no-header test_func.py

効率的にテストを書くうえで知っておくべきこと

ここまでで、簡単なテスト作成、実行方法までの流れを簡単に説明した。ここからは効率的/効果的にテストを書いていく上で必須のテクニックや知識について紹介する

  • フィクスチャを利用したsetup, teardowon
  • 一時的なディレクトリを利用したテスト
  • parametrizeテスト

フィクスチャを利用したsetup, teardowon

setup

テストの可読性を上げるために、テストケース内での記述をできる限り最小限に抑えたい。極論を言えば、以下のように期待値と実際の値、それをアサートする記述くらいでまとめることができるとテストの内容がパッとわかりやすい。

from something.create_something import create_something
from something.something import Something

def test_Somethingクラスのインスタンスを生成できる():
    expected = Something()
    actual = create_something()

    assert expected == actual

ただ、現実としてはあるメソッドや関数を実行するためにはそれを実行するために必要なオブジェクトや値があるわけで、それらの依存関係の準備等をテスト実行前に行う必要がある。

これらを担ってくれるのがsetup。pytestではpytest.fixtureを使用することでsetup相当の処理を実現可能にし、一律でテストの準備作業をテスト開始前に実行することができるようになる

import pytest
from something.calculator import Calculator

class TestCalculator:
    # テストケースで使用するデータを事前に作成
    @pytest.fixture
    def numbers(self):
        return 1, 2

    # テストケースからは、引数にfixtureの変数を指定するだけ
    def test_数値の配列から合計値を算出できる(self, numbers):
        expected = 3
        actual = Calculator().total(numbers)

        assert expected == actual

クラスの形式で記述していれば、selfを使って以下のように記述することも可能

import pytest
from something.calculator import Calculator


class TestCalculator:
    numbers = []

    @pytest.fixture
    def setup_numbers(self):
        self.numbers = [1, 2]

    def test_数値の配列から合計値を算出できる(self, setup_numbers):
        expected = 3
        actual = Calculator().total(self.numbers)

        assert expected == actual

autouseをTrueにすると、テストケースに引数としてfixtureを指定しなくても自動的に適用できる

import pytest


class TestSomething:
    # 同じテストクラス内の全てのテストケースに自動的に適用
    @pytest.fixture(autouse=True)

    def setup_something(self):
        # 何かのセットアップを行います。
        print("Setting up something.")
        yield
        # テスト後のクリーンアップを行います。
        print("Cleaning up something.")

    def test_something(self):
        print("Testing something.")
        assert True

teardown

fixtureの中で yield を呼ぶと、その段階でいったん実行が中断され、テストメソッド本体が実行された後、yield 以降の処理が実行されることになる。これはteardown相当の処理となりテストの実行後に特定の処理を挟むことができる。なお、yield は1度しか使えないので注意

import pytest

@pytest.fixture
def my_fixture():
    print("Setup")
    yield
    print("Teardown")

def test_my_function(my_fixture):
    print("Test")

複数のテストにまとめてfixtureを設定したい場合

クラスに自体に@pytest.mark.usefixturesを設定する。複数のテスト関数またはテストクラスで同じfixtureを使用する場合に便利

import pytest

@pytest.fixture
def my_fixture():
    print("Setup")
    yield
    print("Teardown")

@pytest.mark.usefixtures("my_fixture")
def test_my_function1():
    print("Test 1")

@pytest.mark.usefixtures("my_fixture")
def test_my_function2():
    print("Test 2")

テストファイル間でfixtureを共有

  • conftest.pyを使えば、ファイル間でfixtureを共有できる
  • conftest.pyは置かれたディレクトリ以下のすべてのテストで有効になる
  • テストファイルの中で明示的にconftest.pyをimportする必要はなく、自動的にpytestによって検知される

fixtureの実行タイミングを指定する方法

scopeで指定できる。デフォルトは"function"

スコープ名

実行されるタイミング

function

テストケースごとに1回実行(デフォルト)

class

テストクラス全体で1回実行

module

テストファイル全体で1回実行

session

テスト全体で1回だけ実行

import pytest
from something.calculator import Calculator


class TestCalculator:
    numbers = []

    @pytest.fixture(scope="function")
    def setup_numbers(self):
        self.numbers = [1, 2]

    def test_数値の配列から合計値を算出できる(self, setup_numbers):
        actual = Calculator().total(self.numbers)

        assert 3 == actual

一時的なディレクトリを利用したテスト

pytestのtmpdir fixtureを使用して一時的なディレクトリを利用したテストの例を以下に示す。この例では、一時的なディレクトリにファイルを作成し、その内容を読み取るクラスをテストする。

テスト対象のクラスをMyClassとして定義し、read_fileというメソッドを持つとする

# my_module.py
import os

class MyClass:
    def read_file(self, filepath):
        with open(filepath, 'r') as file:
            return file.read()

次に、このクラスのread_fileメソッドをテストするテストケースを作成する。

# test_my_module.py
import pytest
from my_module import MyClass  # MyClassを含むモジュールをインポートします

def test_read_file(tmpdir):
    # 一時的なディレクトリにテスト用のファイルを作成します。
    p = tmpdir.mkdir("sub").join("hello.txt")
    p.write("content")

    # MyClassのインスタンスを作成し、read_fileメソッドをテストします。
    my_instance = MyClass()
    assert my_instance.read_file(p) == "content"

このテストケースでは、pytestのtmpdir fixtureを使用して一時的なディレクトリとファイルを作成している。そして、MyClassのread_fileメソッドを使用してそのファイルの内容を読み取り、期待通りの内容が読み取られていることをアサートとしている。

このテストケースを実行すると、read_fileメソッドが正しくファイルの内容を読み取る場合にのみテストはパスする。

parametrizeテスト

  • @pytest.mark.parametrizeを使用する
  • 同じテストケース内で異なる引数の組み合わせを複数回実行したい場合にとても有効
  • 特に境界値テストなどを記述する際に重宝する。テストケース内でパラメーターを定義する必要がないので可読性が向上する
  • なお、テストケースごとに個別に定義する必要があるので注意(複数のテスト間で共有不可)
import pytest
from something.calculator import Calculator


class TestCalculator:
    numbers = []

    @pytest.fixture
    def setup_numbers(self):
        self.numbers = [1, 2]

    @pytest.mark.parametrize(
        "value, expected",
        [
            ([1, 10], 11),
            ([2, 2], 4),
            ([0, 0], 0),
        ],
    )
    def test_数値の配列から合計値を算出できる(self, value, expected):
        actual = Calculator().total(value)

        assert expected == actual

モック

モックを多用しすぎるとテストの信頼性が下がってしまうが、モックがないとテストケースが作成できない場面も多い。以下ではモックをうまく活用する際に押さえておくべきポイントを紹介する

モックの必要性

  • 外部APIへのアクセスを行うクラスや関数をテストする際に、テスト時には実際にリクエストを送信したくない。(requests)
  • ランダム値や日時系の処理を扱うクラスや関数の処理を固定化したい(datetime)

具体例

例えば、以下はrequestsライブラリのpostメソッドをモックにする例。実際にhttpリクエストは送信せずモックに置き換える

テスト対象

wp/post_publish_use_case.py

import requests

class PostPublishUseCase:
    def publish(self):
        # 記事公開処理
        res = requests.post(url="wp/hoge/publish")

        return res.json()

テスト

test_post_publish_use_case.py

  • @patch.object(対象のライブラリ, 対象のメソッド)を使用する
  • きちんとモックに置き換えることが成功しているかどうかを試す方法として、モックしたpostメソッドの呼び出し回数をアサートする
import requests
from wp.post_publish_use_case import PostPublishUseCase
from unittest.mock import patch

class TestPostPublishUseCase:
    @patch.object(requests, "post")
    def test_記事を公開できる(self, mock_requests):
        PostPublishUseCase().publish()

        # モックしたpostメソッドの呼び出し回数をアサート
        assert mock_requests.call_count == 1

モック用ライブラリ

unittest.mock

unittestの標準で使用できるモックライブラリ

from unittest.mock import MagicMock

pytest-mock

一括でモックのリセットが行える等メリットがあるが、個人的にはunittest.mockで足りると感じている

モックオブジェクト

  • MockクラスもしくはMagicMockクラスを使用してモックオブジェクトを作成することが可能
  • MagicMockはMockの上位互換で、マジックメソッド(getitem、setitem、iter、len、__call__など)をモック化する機能を備えており、リストや辞書、関数やクラスなど、さまざまな種類のオブジェクトを模倣することができる
  • MagicMockは、呼び出しをキャプチャする機能を持っており、モックがどのように呼び出されたか、何回呼び出されたか、どのような引数で呼び出されたかなどを記録しており、後からアサートすることができる
from unittest import TestCase
from unittest.mock import MagicMock


class TestStaticPropertyMock(TestCase):
    def test_MagicMockのプロパティの設定の仕方(self):
        m = MagicMock(property_a=1, property_b=2)

        # 存在しない属性にアクセスしても、AttributeErrorにならない
        m.property_c

        assert m.property_a == 1
        assert m.property_b == 2

    def test_MagicMockのプロパティの設定の仕方_深いプロパティ(self):
        m = MagicMock(**{"property_a.property_b.property_c": 1})

        assert m.property_a.property_b.property_c == 1

    def test_MagicMockはcallableなのでメソッドや関数のように振る舞うことができる_return_value(self):
        m = MagicMock(return_value=30)
        assert m() == 30

    def test_MagicMockはcallableなのでメソッドや関数のように振る舞うことができる_side_effect(self):
        m = MagicMock(side_effect=[1, 2, 3])
        assert m() == 1
        assert m() == 2
        assert m() == 3

パッチ

Pythonのunittest.mockモジュールには、モックを作成し、オブジェクトを置き換えるためのいくつかのpatchメソッドが用意されており、MagicMockとpatchメソッドを組み合わせることで自由自在な振る舞いを行わせることができる。

  • patch
    • 指定したオブジェクトを新しいMockまたはMagicMockオブジェクトで置き換える
  • patch.object
    • 指定したオブジェクトの特定の属性を新しいMockまたはMagicMockオブジェクトで置き換える
  • patch.dict
    • 辞書の特定のエントリを一時的に置き換えまたは削除する

主なモック対象

モックのユースケースをざっと列挙する。これを押さえておけば、大抵のケースはなんとかなる

関数のモック

  • mocker.patchでfunction_bの返り値を固定化

テスト対象

# module_b.py
def function_b():
    return "Original function B"

# module_a.py
from module_b import function_b

def function_a():
    return function_b()

テスト

# test_module_a.py
import pytest
from module_a import function_a

def test_function_a_with_mocked_function_b(mocker):
    mocker.patch('module_a.function_b', return_value="Mocked function B")
    result = function_a()
    assert result == "Mocked function B"

クラス丸ごとモック

ClassAがClassBに依存

テスト対象

class ClassB:
    def do_something(self):
        return "original value"

class ClassA:
    def __init__(self):
        self.class_b = ClassB()

    def use_class_b(self):
        return self.class_b.do_something()

テスト

MagicMockでClassBをモックに置き換える

# test_my_module.py
import pytest
from unittest.mock import MagicMock
from my_module import ClassA, ClassB  # ClassAとClassBを含むモジュールをインポートします

class TestClassA:
    def setup_method(self, method):
        self.mock_class_b = MagicMock(spec=ClassB)
        self.mock_class_b.do_something.return_value = "mocked value"
        self.class_a = ClassA()
        self.class_a.class_b = self.mock_class_b

    def test_use_class_b(self):
        result = self.class_a.use_class_b()
        self.mock_class_b.do_something.assert_called_once()
        assert result == "mocked value"

クラス変数

  • mocker.patchでClassBのクラス変数であるclass_varを固定化

テスト対象

# module_b.py
class ClassB:
    class_var = "Original class variable"

# module_a.py
from module_b import ClassB

class ClassA:
    def method_a(self):
        return ClassB.class_var

テスト

# test_module_a.py
import pytest
from module_a import ClassA

def test_method_a_with_mocked_class_var(mocker):
    mocker.patch('module_a.ClassB.class_var', new="Mocked class variable")
    instance_a = ClassA()
    result = instance_a.method_a()
    assert result == "Mocked class variable"

クラスのstaticメソッド

  • mocker.patchでClassBのstaticメソッドであるstatic_method()を固定化

テスト対象

# module_b.py
class ClassB:
    @staticmethod
    def static_method():
        return "Original static method"

# module_a.py
from module_b import ClassB

class ClassA:
    def method_a(self):
        return ClassB.static_method()

テスト

# test_module_a.py
import pytest
from module_a import ClassA

def test_method_a_with_mocked_static_method(mocker):
    mocker.patch('module_a.ClassB.static_method', return_value="Mocked static method")
    instance_a = ClassA()
    result = instance_a.method_a()
    assert result == "Mocked static method"

クラスのクラスメソッド

  • mocker.patchでClassBのクラスメソッドであるclass_method()を固定化

テスト対象

# module_b.py
class ClassB:
    @classmethod
    def class_method(cls):
        return "Original class method"

# module_a.py
from module_b import ClassB

class ClassA:
    def method_a(self):
        return ClassB.class_method()

テスト

# test_module_a.py
import pytest
from module_a import ClassA

def test_method_a_with_mocked_class_method(mocker):
    mocker.patch('module_a.ClassB.class_method', return_value="Mocked class method")
    instance_a = ClassA()
    result = instance_a.method_a()
    assert result == "Mocked class method"

ライブラリ

  • mocker.patchでClassBのmethod_bで使用されているrequests.getを固定化

テスト対象

# module_b.py
import requests

class ClassB:
    def method_b(self):
        response = requests.get('http://example.com')
        return response.status_code

# module_a.py
from module_b import ClassB

class ClassA:
    def method_a(self):
        instance_b = ClassB()
        return instance_b.method_b()

テスト

# test_module_a.py
import pytest
from unittest.mock import Mock
from module_a import ClassA

def test_method_a_with_mocked_requests_get(mocker):
    mock_response = Mock()
    mock_response.status_code = 200
    mocker.patch('requests.get', return_value=mock_response)
    instance_a = ClassA()
    result = instance_a.method_a()
    assert result == 200
  • mocker.patchでClassBのmethod_bで使用されているdatetime.datetime.nowを固定化
  • get_current_timeを使用するクラスをテストする際は、このラッパー関数(get_current_time)自体をモックにして返り値を固定化すれば良い

テスト対象

import datetime

def get_current_time():
    return datetime.datetime.now()

テスト

import datetime
import pytest
from unittest.mock import Mock
from your_module import get_current_time  # your_moduleは上記の関数が定義されているモジュール名に置き換えてください

def test_get_current_time(mocker):
    mock_now = datetime.datetime(2020, 5, 4, 3, 2, 1)
    mocker.patch('datetime.datetime.now', return_value=mock_now)
    assert get_current_time() == mock_now

インスタンス変数

  • mocker.patchでClassBのインスタンス変数であるvariable_bを固定化

テスト対象

# module_b.py
class ClassB:
    def __init__(self):
        self.variable_b = "original value"

# module_a.py
from module_b import ClassB

class ClassA:
    def method_a(self):
        instance_b = ClassB()
        return instance_b.variable_b

テスト

# test_module_a.py
import pytest
from module_a import ClassA

def test_method_a_with_mocked_variable_b(mocker):
    mocker.patch('module_b.ClassB.variable_b', new_callable=mocker.PropertyMock, return_value="mocked value")
    instance_a = ClassA()
    result = instance_a.method_a()
    assert result == "mocked value"

クラスのプロパティ

  • mocker.patchでClassBのプロパティ(@Property)であるproperty_bを固定化
  • new_callable=mocker.PropertyMockを指定する必要がある

テスト対象

# module_b.py
class ClassB:
    @property
    def property_b(self):
        return "original value"

# module_a.py
from module_b import ClassB

class ClassA:
    def method_a(self):
        instance_b = ClassB()
        return instance_b.property_b

テスト

# test_module_a.py
import pytest
from module_a import ClassA

def test_method_a_with_mocked_property_b(mocker):
    mocker.patch('module_b.ClassB.property_b', new_callable=mocker.PropertyMock, return_value="mocked value")
    instance_a = ClassA()
    result = instance_a.method_a()
    assert result == "mocked value"

クラスのインスタンス生成(__init__メソッド)

テスト対象

class MyClass:
    def __init__(self, value):
        self.value = value

テスト

# デコレータを使用する場合
from unittest import TestCase, mock
from my_module import MyClass  # MyClassを含むモジュールをインポートします

class TestMyClass(TestCase):
    @mock.patch.object(MyClass, '__init__', return_value=None)
    def test_my_class(self, mock_init):
        MyClass('value')
        mock_init.assert_called_once_with('value')

# withステートメントを使用する場合
from unittest import TestCase, mock
from my_module import MyClass  # MyClassを含むモジュールをインポートします

class TestMyClass(TestCase):
    def test_my_class(self):
        with mock.patch.object(MyClass, '__init__', return_value=None) as mock_init:
            MyClass('value')
            mock_init.assert_called_once_with('value')

クラスのインスタンスメソッド

  • mocker.patchでClassBのインスタンスメソッドであるmethod_bを固定化

テスト対象

# module_b.py
class ClassB:
    def method_b(self):
        return "original value"

# module_a.py
from module_b import ClassB

class ClassA:
    def method_a(self):
        instance_b = ClassB()
        return instance_b.method_b()

テスト

# test_module_a.py
import pytest
from module_a import ClassA

def test_method_a_with_mocked_method_b(mocker):
    mocker.patch('module_b.ClassB.method_b', return_value="mocked value")
    instance_a = ClassA()
    result = instance_a.method_a()
    assert result == "mocked value"

環境変数

  • mocker.patch.dictを使用してos.environの特定のキーの環境変数をモック

テスト対象

import os

def func_main():
    return os.environ.get('NEW_KEY')

テスト

import pytest


def test_func_main(mocker):
    with mocker.patch.dict(os.environ, {'NEW_KEY': 'newvalue'}, clear=True):
        ret = func_main()
        assert ret == "newvalue"

モックの返り値の指定

  • 基本的にはreturn_valueを使う。return_valueを使用した場合、モックが何度呼び出されても同じ値が返る
  • 配列や例外を指定したい場合はside_effectを使う
  • return_value、side_effectの両方設定する場合、side_effectが優先される

return_value

テスト対象

# module_b.py
class ClassB:
    def method_b(self):
        return "original value"

# module_a.py
from module_b import ClassB

class ClassA:
    def method_a(self):
        instance_b = ClassB()
        return instance_b.method_b()

テスト

# test_module_a.py
import pytest
from module_a import ClassA

def test_method_a_with_mocked_method_b_return_value(mocker):
    mocker.patch('module_b.ClassB.method_b', return_value="mocked value")
    instance_a = ClassA()
    result = instance_a.method_a()
    assert result == "mocked value"

side_effect

テスト対象

# module_b.py
class ClassB:
    def method_b(self):
        return "original value"

# module_a.py
from module_b import ClassB

class ClassA:
    def method_a(self):
        instance_b = ClassB()
        return instance_b.method_b()
side_effectに例外を指定する

テスト

# test_module_a.py
import pytest
from module_a import ClassA

def test_method_a_with_mocked_method_b_side_effect_exception(mocker):
    mocker.patch('module_b.ClassB.method_b', side_effect=Exception("mocked exception"))
    instance_a = ClassA()
    try:
        result = instance_a.method_a()
    except Exception as e:
        assert str(e) == "mocked exception"
side_effectに配列を指定する
  • 呼び出し回数ごとに配列のインデックス0、1、2・・・と返り値が変えることが可能

テスト

# test_module_a.py
import pytest
from module_a import ClassA

def test_method_a_with_mocked_method_b_side_effect_list(mocker):
    mocker.patch('module_b.ClassB.method_b', side_effect=["mocked value 1", "mocked value 2"])
    instance_a = ClassA()
    result1 = instance_a.method_a()
    result2 = instance_a.method_a()
    assert result1 == "mocked value 1"
    assert result2 == "mocked value 2"

デコレーターとwithの違い

  • デコレータを使うと、そのデコレータが適用された関数またはメソッド全体でモックが有効になる。デコレータは関数やメソッドの定義時に適用され、そのスコープ全体でモックが有効
  • withステートメントを使うと、そのwithブロック内でのみモックが有効。withブロックを抜けると、モックは自動的に無効になり、元の値に戻る。

デコレーターを使う場合

from unittest import TestCase, mock

from something.static_property_mock import StaticPropertyMock


class TestStaticPropertyMock(TestCase):
    @mock.patch.object(StaticPropertyMock, "my_static_var", new="mocked value")
    def test_my_static_var(self):
        self.assertEqual(StaticPropertyMock.my_static_var, "mocked value")

withを使う場合

from unittest import TestCase, mock

from something.static_property_mock import StaticPropertyMock


class TestStaticPropertyMock(TestCase):
    def test_my_static_var(self):
        with mock.patch.object(StaticPropertyMock, "my_static_var", new="mocked value"):
            self.assertEqual(StaticPropertyMock.my_static_var, "mocked value")

デコレーターもwithも使わない

import requests
from wp.post_publish_use_case import PostPublishUseCase
import pytest

class TestPostPublishUseCase:
    def test_記事を公開できる(self, mocker):
        mock_requests = mocker.patch.object(requests, "post")
        PostPublishUseCase().publish()

        # モックしたpostメソッドの呼び出し回数をアサート
        assert mock_requests.call_count == 1

mocker.patchとmocker.patch.objectの違い

  • 同じような機能だが、記述方法に違いがある
  • mocker.patchはモックの対象を文字列で指定し、mocker.patch.objectはモックの対象を直接オブジェクトとして指定する
  • いずれもテスト関数が完了すると、モック解除される

単一のテストケース内で複数モックする

@patchデコレーターを使わない

テスト対象

class B:
    def method_b(self):
        return "B"

class C:
    def method_c(self):
        return "C"


# sample_module.py
class A:
    def __init__(self):
        self.b = B()
        self.c = C()

    def method_a(self):
        return self.b.method_b() + self.c.method_c()

テスト

import pytest
from unittest.mock import MagicMock
from sample_module import A  # your_moduleは上記のクラスが定義されているモジュール名に置き換えてください

def test_method_a(mocker):
    mock_b = MagicMock()
    mock_b.method_b.return_value = "Mocked B"
    mocker.patch('sample_module.B', return_value=mock_b)

    mock_c = MagicMock()
    mock_c.method_c.return_value = "Mocked C"
    mocker.patch('sample_module.C', return_value=mock_c)

    a = A()
    result = a.method_a()

    assert result == "Mocked BMocked C"
    mock_b.method_b.assert_called_once()
    mock_c.method_c.assert_called_once()

@patchデコレーターを使う

  • @patchデコレーターを使う場合は順番に注意。patchデコレータは内側から外側へと適用されるので、テスト関数に近い関数ほど引数の順番が先頭に近くなる
import pytest
from unittest.mock import MagicMock, patch
from sample_module import A  # your_moduleは上記のクラスが定義されているモジュール名に置き換えてください

@patch('sample_module.C', return_value=MagicMock(method_c=MagicMock(return_value="Mocked C")))
@patch('sample_module.B', return_value=MagicMock(method_b=MagicMock(return_value="Mocked B")))
def test_method_a(mock_b, mock_c):
    a = A()
    result = a.method_a()

    assert result == "Mocked BMocked C"
    mock_b.method_b.assert_called_once()
    mock_c.method_c.assert_called_once()

@patchデコレータとfixtureが同時に定義されている場合は、テスト関数の引数には先頭にfixtureを指定する必要があり、その後にモックを指定する

from unittest.mock import patch
import pytest

@pytest.fixture
def some_fixture():
    return "fixture"

@patch('module.Class1')
def test_function(some_fixture, mock_class1):
    ...

@patchデコレータとparametrizeが同時に定義されている場合は、テスト関数の引数には先頭にモックを指定し、その後にparametrizeの値を指定する

from unittest.mock import patch
import pytest

@pytest.mark.parametrize("a, b ,expected", [(1, 2, 2), (2, 2, 4)])
@patch('module.Class1')
def test_function(mock_class1, a, b, expected):
    assert a*b == expect
    ...

withで複数パッチする場合は特に順番を気にしなくていい

from unittest.mock import patch

def test_function():
    with patch('module_to_test.ClassToMock1') as mock1, patch('module_to_test.ClassToMock2') as mock2:
        # ここでmock1とmock2を使用してテストを行う

        # mock1とmock2は、それぞれmodule_to_test.ClassToMock1とmodule_to_test.ClassToMock2をモック化します

mocker.patchの第一引数に指定するモジュールのパス

テスト対象クラスがそのモジュールをどのようにインポートしているかによって指定するパスが変わる(importしているモジュールがパスの起点になる)

requestsモジュールを直接インポートして使用している場合はrequestsが起点になる

# my_module.py
import requests

def my_function():
    return requests.get('https://example.com')

テスト

def test_my_function(mocker):
    mock_get = mocker.patch('requests.get')
    ...

requests.getを直接インポートして使用している場合、このmy_moduleが起点になる

# my_module.py
from requests import get

def my_function():
    return get('https://example.com')

テスト

def test_my_function(mocker):
    mock_get = mocker.patch('my_module.get')
    ...

mocker.patch.objectを使用する際のreturn_valueとnewの違い

  • return_valueはメソッドをモックする際に使用する。モックしたメソッドが呼び出されたときに返す値を設定する
  • new: プロパティ(属性)をモックする際に使用する。モックした属性の新しい値を設定する
mocker.patch.object(Calculator, 'total', return_value=10)

mocker.patch.object(Calculator, 'some_attribute', new='mocked value')

アサート

メソッドの種類が多いが、以下を押さえておけば基本的にはなんとかなる。

  • モックの呼び出し回数をアサート(call_count)
  • モックが一度だけ呼び出されたことをアサート(assert_called_once)
  • モックが特定の引数で呼び出されたことをアサート(assert_called_with)
  • モックが一度だけ特定の引数で呼び出されたことをアサート(assert_called_once_with)
  • モックが複数回呼び出されている場合に呼び出し回数と呼び出された時の引数をアサート(call_countとassert_has_calls)

モックの呼び出し回数をアサート(call_count)

mock = mocker.Mock()
mock()
mock()
assert mock.call_count == 2

モックが一度だけ呼び出されたことをアサート(assert_called_once)

mock = mocker.Mock()
mock()
mock.assert_called_once()

モックが特定の引数で呼び出されたこと(assert_called_with)

mock = mocker.Mock()
mock('arg1', 'arg2')
mock.assert_called_with('arg1', 'arg2')

モックが一度だけ特定の引数で呼び出されたこと(assert_called_once_with)

mock = mocker.Mock()
mock('arg1', 'arg2')
mock.assert_called_once_with('arg1', 'arg2')

モックが複数回呼び出されている場合に呼び出し回数と呼び出された時の引数をアサート(call_countとassert_has_calls)

  • assert_has_callsはモックが呼び出された際に期待する順番で、期待する引数を持っていることをアサートできるが、テスト全体としての呼び出し回数を保証するものでないので、call_countと組み合わせる
  • ちなみに、assert_has_callsの引数にany_order=Trueを指定すると、呼び出し順番はチェックしなくなる。
from unittest.mock import call, Mock
import pytest

def test_assert_has_calls_and_call_count(mocker):
    mock = mocker.Mock()

    # モックを複数回呼び出す
    mock('arg1', 'arg2')
    mock('arg3', 'arg4')

    # assert_has_callsを使用して、特定の呼び出しがあったことを確認する
    calls = [call('arg1', 'arg2'), call('arg3', 'arg4')]
    mock.assert_has_calls(calls)

    # call_countを使用して、モックが全体として呼び出された回数を確認する
    assert mock.call_count == 2

テストカバレッジの測定

サポートされている網羅率

  • C0(Statement coverage: 命令網羅)、C1(Branch coverage: 分岐網羅)のみサポートされている
  • 分岐網羅が100%であれば必然的に命令網羅も100%になるので、基本的にはまずはC1が100%となることを目標にすればいい

カバレッジの詳細についてはこちらがわかりやすい

設定手順

インストール

pip install pytest-cov

テスト実行時にカバレッジを出力する

pytest --cov-report html --cov=src

オプションを毎回指定するのも面倒なので、pytest.iniに定義する

[pytest]
addopts = --cov-report html --cov=src
pythonpath = "src"
testpaths = "tests"

実行するとhtmlcovディレクトリ以下にファイルが生成されるので、htmlcov/index.htmlを開くとVSCode上でカバレッジが確認できる。

ちなみに、VSCodeの拡張機能でLive Previewというものがあるので、これを入れるとエディタ上でhtmlを表示することができるのでおすすめ

https://marketplace.visualstudio.com/items?itemName=ms-vscode.live-server

エディタのGUIでテスト実行する

Coverage Guttersを使用して実現可能。詳細はこちらの記事を参考されたし

テストのグルーピング

@pytest.mark.parametrizeを使ってテストにメタデータを付与することができる

テストをスキップ(ペンディング)したい場合は @pytest.mark.skip

@pytest.mark.skip
def test_skip():
    # ~~~

テストケース名を日本語で書くことについて

個人的には日本語で書きたい派です。(日本語の方がわかりやすいから)。こちらの記事などが参考になる

テストケースをネストさせる

  • pytestではテストケースをネストさせることができない
  • jestではdescribeを使用してテストケースの複数のパターンを各ブロックごとに説明する記述を設けることができる

jestでのdescribeを使用したネストの例

describe('Outer describe block', () => {
  describe('Inner describe block 1', () => 
  }
  describe('Inner describe block 2', () => {
  });
});

pytestで近いことをするには、関数ではなく、クラスの形式でテストケースを記述するか、pytest-describeを別途インストールしてあげる必要がある

まとめ

いかがでしたでしょうか。本記事では、pytestでPythonの単体テストを行う際に最低限押さえておきたいポイントについて紹介しています。本気でpytestで自動テストを書きたいと方向けに、単なるチュートリアルではなく、実用的な観点での説明をしています。ぜひ参考にしてみてください