Python
pytest

pytestの使い方と便利な機能

pytestはPythonのテストフレームワークの一つ。

unittestなど他のフレームワークと比較して、テストに失敗した原因が分かりやすい。

この記事ではpytestの使い方に関して、公式のドキュメントを参考にメモする。

インストール

pipなどを使用してインストールする。

pip install pytest

基本的な使い方

基本的にはassertで望む結果を書く。

ここではtest_assert1.pyというテスト用のファイルを作成する。test_で始まる関数がテスト対象となる。ディスカバリーのルールに関してはこの記事の最後に明記する。

# content of test_assert1.py
def f():
    return 3

def test_function():
    assert f() == 4

テストの実行はpytestコマンドを利用する。
上記は、f()の結果が4でないため、Failureとなる。

$ pytest test_assert1.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-3.x.y, py-1.x.y, pluggy-0.x.y
rootdir: $REGENDOC_TMPDIR, inifile:
collected 1 item

test_assert1.py F                                                    [100%]

================================= FAILURES =================================
______________________________ test_function _______________________________

    def test_function():
>       assert f() == 4
E       assert 3 == 4
E        +  where 3 = f()

test_assert1.py:5: AssertionError
========================= 1 failed in 0.12 seconds =========================

期待される例外の処理

例外が発生することが期待される場合にはpytest.raisesを利用する。

以下の通りすると、ZeroDivisionErrorが発生すればテスト成功、発生しなければテスト失敗となる。

import pytest

def test_zero_division():
    with pytest.raises(ZeroDivisionError):
        1 / 0

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

import pytest

def test_recursion_depth():
    with pytest.raises(RuntimeError) as excinfo:
        def f():
            f()
        f()
    assert 'maximum recursion' in str(excinfo.value)

fixtures

fixturesはunittestのsetup/teardownのような関数を劇的に改善する関数である。

関数の引数としてのFixtures

テスト関数は引数として名前を指定することでfixtureオブジェクトを受け取ることができる。それぞれの引数名に対して、その名前を持つfixture関数がfixtureオブジェクトを提供する。fixture関数は@pytest.fixtureとともに作成することで登録される。

以下がシンプルな例である。

# content of ./test_smtpsimple.py
import pytest

@pytest.fixture
def smtp_connection():
    import smtplib
    return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)

def test_ehlo(smtp_connection):
    response, msg = smtp_connection.ehlo()
    assert response == 250
    assert 0 # for demo purposes

conftest.pyを利用したfixuture関数の共有

fixture関数を複数のテストファイルから使用したいならば、それをconftest.pyファイルに移動するとよい。テストファイルの中で明示的にconftest.pyをimportする必要はなく、自動的にpytestによって発見される。

fixture関数のディスカバリーはテスト関数から始まり、テストモジュール、conftest.py、最後に組み込み、サードパーティープラグインの順に行われる。

テストデータの共有

もしテストデータをファイルから利用したい場合は、fixtureの中でそれらのデータを読み込むのは良い方法である。これはpytestの自動キャッシュ機構を利用することができる。

他の良いアプローチはtestsフォルダーの中にデータファイルを追加することである。pytest-datadirやpytest-datafilesのようなプラグインを利用すれば、このようなテストを管理することを助ける。

テスト間・クラス間・セッション間のfixutureインスタンスの共有

fixtureのscope引数にmoduleを指定すると、テストモジュールごとに1度fixturesが呼び出される。(デフォルトではテスト関数ごとに1度)

# content of conftest.py
import pytest
import smtplib

@pytest.fixture(scope="module")
def smtp_connection():
    return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)

もし、全てのテストで共有したい場合はsessionを指定する。

@pytest.fixture(scope="session")
def smtp_connection():
    # the returned fixture value will be shared for
    # all tests needing it
    ...

Fixutureの終了処理

returnの代わりにyieldを使用すると、yield文以降の全てのコードは終了処理コードとして扱われる。

# content of conftest.py

import smtplib
import pytest


@pytest.fixture(scope="module")
def smtp_connection():
    smtp_connection = smtplib.SMTP("smtp.gmail.com", 587, timeout=5)
    yield smtp_connection  # provide the fixture value
    print("teardown smtp")
    smtp_connection.close()

with文を使用してシームレスにyieldを使用することも可能である。

# content of test_yield2.py

import smtplib
import pytest


@pytest.fixture(scope="module")
def smtp_connection():
    with smtplib.SMTP("smtp.gmail.com", 587, timeout=5) as smtp_connection:
        yield smtp_connection  # provide the fixture value

ただし、もしyieldの前に例外が発生した場合は、yield以降のコードが実行されない。

終了処理コードを実行するためのもうひとつのオプションは、finalization関数を登録するために、リクエストコンテキストのaddfinalizerメソッドを使用することである。

# content of conftest.py
import smtplib
import pytest


@pytest.fixture(scope="module")
def smtp_connection(request):
    smtp_connection = smtplib.SMTP("smtp.gmail.com", 587, timeout=5)

    def fin():
        print("teardown smtp_connection")
        smtp_connection.close()

    request.addfinalizer(fin)
    return smtp_connection  # provide the fixture value

yieldとaddfinalizerメソッドは似ているが、2つの点でaddfinalizerはyieldとは異なる。

  • 複数のfinalizer関数を登録することが出来る。
  • finalizersはsetupコードが例外を挙げたかどうかによらず、いつも呼ばれる。これはfixtureによって生成された全てのリソースを適切にクローズするのに便利である。
@pytest.fixture
def equipments(request):
    r = []
    for port in ('C1', 'C3', 'C28'):
        equip = connect(port)
        request.addfinalizer(equip.disconnect)
        r.append(equip)
    return r

上記の例で、もしC28が例外を伴って失敗したとしても、C1とC3は適切にクローズされる。もちろん、もしfinalize関数が登録される前に例外が発生した場合には、それが実行されることはない。

fixuture関数をfixture関数から利用する

fixtureはfixtureからも利用できる。これはfixtureをモジュラーデザインにすることに貢献し、多くのプロジェクトに渡ってフレームワーク固有のfixtureを再利用することを可能にする。

# content of test_appsetup.py

import pytest

class App(object):
    def __init__(self, smtp_connection):
        self.smtp_connection = smtp_connection

@pytest.fixture(scope="module")
def app(smtp_connection):
    return App(smtp_connection)

def test_smtp_connection_exists(app):
    assert app.smtp_connection

上記の例では、app fixtureを定義し、それは前もって定義されたsmtp_connection fixtureを受け取り、Appオブジェクトとともにインスタンス化される。

パラメータ化したfixtures

@pytest.mark.parametrizeを利用すれば、テスト関数のための引数をパラメータ化することができる。

# content of test_expectation.py
import pytest
@pytest.mark.parametrize("test_input,expected", [
    ("3+5", 8),
    ("2+4", 6),
    ("6*9", 42),
])
def test_eval(test_input, expected):
    assert eval(test_input) == expected

上記の例では3つの(test_input, expected)というタプルを定義しており、test_eval関数はそれらを使用して3回実行される。

複数のパラメータの全ての組み合わせを試験したい場合は、parametrizeをスタックできる。

import pytest
@pytest.mark.parametrize("x", [0, 1])
@pytest.mark.parametrize("y", [2, 3])
def test_foo(x, y):
    pass

上記の例では、x=0/y=2, x=1/y=2, x=0/y=3, and x=1/y=3の組み合わせでテストされる。

一時的なファイルの利用

tmpdir fixtureを利用すれば、一時的なディレクトリを利用することが出来る。

tmpdirはpy.path.localオブジェクトであり、それはos.pathなどのメソッドを提供する。以下は使用例である。

# content of test_tmpdir.py
import os
def test_create_file(tmpdir):
    p = tmpdir.mkdir("sub").join("hello.txt")
    p.write("content")
    assert p.read() == "content"
    assert len(tmpdir.listdir()) == 1
    assert 0

tmpdir_factory

tmpdir_factoryはsessionのスコープで動作するfixtureであり、どんなfixutureやテストからも利用できる任意の一時的なディレクトリを作るのに使用できる。

例えば、以下の例では、大きなサイズのイメージをテストごとに作成する代わりに、セッションごとに1度生成することで時間を抑えることが出来る。

# contents of conftest.py
import pytest


@pytest.fixture(scope="session")
def image_file(tmpdir_factory):
    img = compute_expensive_image()
    fn = tmpdir_factory.mktemp("data").join("img.png")
    img.save(str(fn))
    return fn


# contents of test_image.py
def test_histogram(image_file):
    img = load_image(image_file)
    # compute and test histogram

monkey patching / mock

テスト対象となるコードの一部を差し替えるために、monkye patchingが使用できる。

monkey patchingを利用すれば、ソースコード内で呼び出される関数やアイテム、環境変数などを設定、削除することができる。

以下はシンプルな例である。以下ではos.path.expanduserの関数がtest_mytest内で定義されるmockreturnに置き換えられる。

# content of test_module.py
import os.path
def getssh(): # pseudo application code
    return os.path.join(os.path.expanduser("~admin"), '.ssh')

def test_mytest(monkeypatch):
    def mockreturn(path):
        return '/abc'
    monkeypatch.setattr(os.path, 'expanduser', mockreturn)
    x = getssh()
    assert x == '/abc/.ssh'

次に以下の例では、request.session.Session.requestメソッドが削除され、http requestが作成されるとテストに失敗するようになることが予想される。

# content of conftest.py
import pytest
@pytest.fixture(autouse=True)
def no_requests(monkeypatch):
    monkeypatch.delattr("requests.sessions.Session.request")

よいアプリケーション構成

標準的なdiscovery

pytestは以下のようなディスカバリーを実装している。

  • 引数が何も指定されていない場合は、testpaths(もし設定されていれば)かカレントディレクトリからテストファイルを捜索する
    • かわりにディレクトリ、ファイル名の組み合わせを引数に利用することも出来る
  • norecursedirsにマッチしない限り、ディレクトリは再帰的に捜索される
  • それぞれのディレクトリではtest_*.pyもしくは*_test.pyに一致するファイルが捜索され、それらのテストパッケージがインポートされる
  • それぞれのファイルからテストアイテムが集められる
    • test_というプリフィックスが付けられたクラス外の関数、もしくはメソッド
    • Testというプリフィックスが付いたクラス内の中で、test_というプリフィックスが付いた関数やメソッド(__init__メソッドは除く)

テストレイアウト/インポートルールの選択

pytestは以下の2つのレイアウトをサポートする。

  • アプリケーションコード外のテスト
  • アプリケーションコードの一部としてのテスト

ここでは「アプリケーションコード外のテスト」のみを扱う。

以下のような構成にすると、インストールされたバージョンのmypkgに対して簡単にテストを実行することが可能である。

setup.py
mypkg/
    __init__.py
    app.py
    view.py
tests/
    test_app.py
    test_view.py
    ...

このようなスキームを使用する場合、テストファイルは必ずユニークな名前にしなければならない。なぜならば、完全なパッケージ名を取得するためのパッケージがないため、pytestはそれらをトップレベルのモジュールとしてインポートするからである。言い換えるならば、sys.pathtests/を追加することにより、上記の例のテストファイルは、トップレベルモジュールのtest_apptest_viewとしてインポートされる。

もし、同じ名前のテストモジュールが必要であるならば、それらをパッケージにするために、__init__.pytestsフォルダーとサブフォルダーに追加するであろう。

setup.py
mypkg/
    ...
tests/
    __init__.py
    foo/
        __init__.py
        test_view.py
    bar/
        __init__.py
        test_view.py

この場合、pytestがtests.foo.test_viewtests.bar.test_viewとしてモジュールをロードして、同じ名前のモジュールを持つことが可能となる。しかし、これだとちょっとした問題を引き起こす。それは、testsディレクトリからモジュールをロードするために、pytestがsys.pathの先頭にレポジトリのルートを追加するが、これがmypkgもインポート可能となるということを引き起こすからである。

もし仮想環境においてパッケージをテストするためにtoxのようなツールを利用しているならば、これは問題となる。なぜなら、テストしたいのはインストールされたバージョンのパッケージであり、レポジトリのローカルコードではないからだ。

このようなシチュエーションではsrcレイアウトを使用することが強く推奨される。その場合、アプリケーションのルートパッケージは、レポジトリのルートのサブディレクトリの中に居座り続ける。

setup.py
src/
    mypkg/
        __init__.py
        app.py
        view.py
tests/
    __init__.py
    foo/
        __init__.py
        test_view.py
    bar/
        __init__.py
        test_view.py

このレイアウトの有効性に関しては以下のブログに記載されている。