DEVGRU

プログラミングと競馬予想について書きます

pytest と unittest.mock を使って標準出力のテストを書けなかった話

今回は、標準出力に文字列を出力する実装に対して pytest でテストを書く必要があり、 unittest.mock と Python のライブラリリファレンスにかかれていた方法を組み合わせたら見事にハマったお話です。

広告

シンプルにするとこんな感じに、標準出力への文字列が唯一の副作用となる関数があります。

def hello(name=None):
    if name:
        print(f"Hello, {name}")
    else:
        print("Hello")

if __name__ == "__main__":
    import sys
    
    if len(sys.argv) >= 2:
       name = sys.argv[1]
       hello(name)
    else:
       hello()

コマンドラインから実行するとこのように引数の有無で出力が異なる内容になります。

$ python hello.py
Hello

$ python hello.py John
Hello, John

テストのお作法としては、文字列と出力を分けることでテストしやすくするのが筋ですが、それができないこともたまにあります 1

def generate_message(name=None):
    if name:
        return f"Hello, {name}"
    else:
        return "Hello"

def hello(name):
      print(generate_message(name))

また、上記の例なら単純に doctest で良いのですが、もう少し複雑だったりするとそうも言っていられません。

Python のリファレンスマニュアルに標準出力のモック方法が書いてあったので、それにならって pytest でテストを書いてみました。

from hello import hello
import pytest
from unittest.mock import patch
from io import StringIO

@pytest.fixture()
def mock_stdout():
    with patch("sys.stdout", new_callable=StringIO) as m:
        yield m

def test_hello_without_name(mock_stdout):
    hello()
    assert mock_stdout.getvalue() == "Hello\n"

def test_hello_with_name(mock_stdout):
    hello("John")
    assert mock_stdout.getvalue()  == "Hello, John\n"

テストのデバッグをするためにいつもの -s をつけて実行してみると、きちんと通ります。

$ pytest -s test_hello.py
=========================================================================================================== test session starts ===========================================================================================================
platform darwin -- Python 3.8.3, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: /Users/devgru/work/blog-sandbox/2021-05-05T21:20:52
collected 2 items

test_hello.py ..

============================================================================================================ 2 passed in 0.10s ============================================================================================================

しかし、 PR を作って CI でも動作することを確認したら、何故か落ちます。

$ pytest test_hello.py
=========================================================================================================== test session starts ===========================================================================================================
platform darwin -- Python 3.8.3, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: /Users/devgru/work/blog-sandbox/2021-05-05T21:20:52
collected 2 items

test_hello.py FF                                                                                                                                                                                                                    [100%]

================================================================================================================ FAILURES =================================================================================================================
_________________________________________________________________________________________________________ test_hello_without_name _________________________________________________________________________________________________________

mock_stdout = <_io.StringIO object at 0x11104f9d0>

    def test_hello_without_name(mock_stdout):
        hello()
>       assert mock_stdout.getvalue() == "Hello\n"
E       AssertionError: assert '' == 'Hello\n'
E         - Hello

test_hello.py:13: AssertionError
---------------------------------------------------------------------------------------------------------- Captured stdout call -----------------------------------------------------------------------------------------------------------
Hello
__________________________________________________________________________________________________________ test_hello_with_name ___________________________________________________________________________________________________________

mock_stdout = <_io.StringIO object at 0x110b205e0>

    def test_hello_with_name(mock_stdout):
        hello("John")
>       assert mock_stdout.getvalue()  == "Hello, John\n"
E       AssertionError: assert '' == 'Hello, John\n'
E         - Hello, John

test_hello.py:17: AssertionError
---------------------------------------------------------------------------------------------------------- Captured stdout call -----------------------------------------------------------------------------------------------------------
Hello, John
========================================================================================================= short test summary info =========================================================================================================
FAILED test_hello.py::test_hello_without_name - AssertionError: assert '' == 'Hello\n'
FAILED test_hello.py::test_hello_with_name - AssertionError: assert '' == 'Hello, John\n'
============================================================================================================ 2 failed in 0.16s ============================================================================================================

どうやら、unittest.mock.patch() で差し替えた StringIO() に値が書き込まれていないようです。

-s オプションの有無で挙動が変わるということは、おそらく pytest 自体がモックをしているために、その後に patch しても届かないと予想されます。

散々悩んだ挙げ句、 python pytest mock stdout でググったところ、テックブログ界の雄こと Developers IO 2記事が見つかりました。

曰く、 capfd を用いるのが良いとのこと。

from hello import hello

def test_hello_without_name(capfd):
    hello()
    captured = capfd.readouterr()
    assert captured.out == "Hello\n"

def test_hello_with_name(capfd):
    hello("John")
    captured = capfd.readouterr()
    assert captured.out == "Hello, John\n"  
pytest test_hello2.py
=========================================================================================================== test session starts ===========================================================================================================
platform darwin -- Python 3.8.3, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: /Users/devgru/work/blog-sandbox/2021-05-05T21:20:52
collected 2 items

test_hello2.py ..                                                                                                                                                                                                                   [100%]

============================================================================================================ 2 passed in 0.03s ============================================================================================================

今度は無事通りました。

テスト駆動Python

テスト駆動Python

  • 作者:BrianOkken
  • 発売日: 2018/08/29
  • メディア: Kindle版

テスト駆動開発

テスト駆動開発


  1. 納期とか、他人の書いたコードだったりとか。

  2. 最近は何を検索してもこのブログの記事が出てくるような気がしている。