Python + pytestにて、pytest.raisesを使って例外をアサーションする時の注意点

Python + pytestにて、「pytest.raisesを使って例外をアサーションする」テストコードを作成する機会がありました。

ただ、書き方を誤りうまくアサーションできなかっため、メモを残します。

 
目次

 

環境

  • Python 3.6.0
    • unittest.mock.patchを使用
  • pytest 3.0.7

 

状況

テスト対象のメソッドtarget_method()は、以下とします。

pytest_raises.py

from with_statement_library import Validator

class Target(object):
    def target_method(self):
        """テスト対象のメソッド"""
        validator = Validator()
        validator.run()

 
テスト対象クラスでimportしているValicationクラスは、(今回省略していますが)長い行に渡って検証処理があり、エラーがあったら最後に例外を送出しています。

pytest_raises_library.py

class Validator(object):
    def run(self):
        """長い行検証処理をしていて、エラーがあったら例外を送出するメソッド"""
        raise RuntimeError

 
今回のテストでは、run()メソッドをテストするためのデータを用意するのが難しいと仮定して、Validator.run()をモックに差し替える方法を取ります。

 
モックを使ったテストコードとして

from unittest.mock import patch, MagicMock
import pytest
from with_statement import Target

class Test_Target(object):
    def test_mock_patch(self):
        mock_run = MagicMock()
        with patch('with_statement.Validator.run', mock_run):
            sut = Target()
            actual = sut.target_method()
            # モックが呼ばれている回数は1回か
            assert mock_run.call_count == 1

としたところ、テストをパスしました。

 
続いて、例外を送出するよう、patchとpytest.raisesを使って

    def test_mistake_usage_pytest_raises(self):
        mock_run = MagicMock(side_effect=AssertionError)
        # with文にpytest.raisesを追加
        with patch('with_statement.Validator.run', mock_run), \
                pytest.raises(AssertionError):
            sut = Target()
            actual = sut.target_method()
            assert mock_run.call_count == 1

としたところ、これもテストをパスしました。

 
念のため、失敗するテストとして

    def test_mistake_usage_pytest_raises_but_test_pass(self):
        mock_run = MagicMock(side_effect=AssertionError)
        with patch('with_statement.Validator.run', mock_run), \
                pytest.raises(AssertionError):
            sut = Target()
            actual = sut.target_method()
            # 呼ばれた回数を2回としてアサーションする
            # 実際に呼ばれるのは1回なので、テストは失敗するはず
            assert mock_run.call_count == 2

と書いてみたところ、このテストがパスしてしまいました。

 

原因

pytestの公式ドキュメント(英語版)に記載がありました*1

When using pytest.raises as a context manager, it’s worthwhile to note that normal context manager rules apply and that the exception raised must be the final line in the scope of the context manager. Lines of code after that, within the scope of the context manager will not be executed.

Helpers for assertions about Exceptions/Warnings raises | Pytest API and builtin fixtures — pytest documentation 

例外が発生したメソッド以降の処理は、実行されないようでした。

 
今回の場合、

with patch('with_statement.Validator.run', mock_run), \
        pytest.raises(AssertionError):
    sut = Target()

    # ここで例外が発生
    actual = sut.target_method()
    # このassertは処理されないため、テストがパスする
    assert mock_run.call_count == 2

のため、テストがパスしたと考えられました。

 

対応

with文をネストして、モック用とpytest.raises用に分けます。

def test_correct_usage_pytest_raises_and_test_fail(self):
    mock_run = MagicMock(side_effect=AssertionError)
    # モック用のwith文
    with patch('with_statement.Validator.run', mock_run):

        # pytest.raisesのwith文は別途用意
        with pytest.raises(AssertionError):
            sut = Target()
            # この中の最後に、例外を送出するメソッドを書く
            actual = sut.target_method()

        # 検証は、インデントを一つ上げて書く
        assert mock_run.call_count == 2

 
テストを実行したところ、正しく失敗しました。

$ pytest
==== test session starts ====
platform darwin -- Python 3.6.0, pytest-3.0.6, py-1.4.32, pluggy-0.4.0
rootdir: /Users/kamijoshinya/thinkami/try/pytest_sample, inifile: pytest.ini
collected 4 items

test_pytest_raises.py ...F

==== FAILURES ====
____ Test_Target.test_correct_usage_pytest_raises_and_test_fail ____
...
>           assert mock_run.call_count == 2
E           assert 1 == 2
E            +  where 1 = <MagicMock id='4422285968'>.call_count

test_pytest_raises.py:58: AssertionError

 

ソースコード

GitHubにあげました。e.g._pytest_raisesディレクトリが今回のものです。
thinkAmi-sandbox/python_mock-sample

*1:対象バージョンが古いためか、日本語版には記載がありません