[Pytest] pytest 入門、テストコードを書く方法

[Pytest] pytest 入門、テストコードを書く方法

pytest とは

pytest: helps you write better programs — pytest documentation

pytest は Python のテストツールの1種です。Python には unittest とという標準ライブラリのテストツールがありますが、より Python らしくテストコードを書くことができるのが pytest の特徴です。

pytest は小規模なテストコードの作成から複雑なテストコードの拡張まで対応できる優秀なツールであり、Python のデファクトスタンダードとなっているテストツールでもあります。

pytest のインストール

pip 経由で pytest をインストールします。

$ pip install pytest

pytest テストコードの基本

pytesttest_*.py もしくは *_test.py という名前のファイルをテスト用のコードとして自動的に収集してくれます。

assert文で検証する

test_my_code.py という名前のファイルを作成して、以下のようなコードを実装してテストしてみます。

pytest では unittest と違って assert に条件式を指定し、実際の結果が期待する値と一致するかどうかを検証します。

# テスト対象の関数
def add(x, y):
    return x + y


# テストコード
def test_add():
    res = add(1, 2)
    assert res == 3

ファイルを保存したら pytest を実行します。

$ pytest
============================= test session starts ==============================
platform linux -- Python 3.8.3, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
rootdir: /mnt/c/work/pytest-sample
collected 1 item

test_my_code.py .                                                        [100%]

============================== 1 passed in 0.02s ===============================

pytest を実行すると自動的にテストコードが収集され、実行されます。結果が表示されテストにパスしたかどうかがわかります。

失敗すればどのテストコードのどのケースで失敗したかが確認できます。

$ pytest test_my_code.py
============================= test session starts ==============================
platform linux -- Python 3.8.3, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
rootdir: /mnt/c/work/pytest-sample
collected 1 item

test_my_code.py F                                                        [100%]

=================================== FAILURES ===================================
___________________________________ test_add ___________________________________

    def test_add():
        res = add(1, 2)
>       assert res == 0
E       assert 3 == 0

test_my_code.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_my_code.py::test_add - assert 3 == 0
============================== 1 failed in 0.05s ===============================

例外が送出されることを期待するテスト

テスト中に例外が発生すると、テストに失敗してしまいます。例外が発生することを期待するようなテストコードには pytest.raises を使います。

期待する例外クラスの型を引数で指定すると、その例外が発生した場合のみテストに成功します。例外が発生しなかった場合にはテストに失敗します。

import sys
import pytest

def div(x, y):
    return x / y

def exit(status):
    sys.exit(status)


def test_exception():
    with pytest.raises(ZeroDivisionError):
        # ゼロ除算のエラーを期待する
        res = div(1, 0)


def test_exit_status():
    # sys.exit が実行されると SystemExit が送出される
    with pytest.raises(SystemExit) as e:
        exit(1)
    # e.value で SystemExit を参照できる
    assert e.value.code == 1

発生した例外を参照したい場合は as で参照できます。例えば上の例では SystemExit.code が期待する値かどうかをテストしています。

標準出力, 標準エラーの出力内容をテストする

標準出力, 標準エラーに出力された値をテストすることもできます。テストには capfd という pytest が提供する組み込みのフィクスチャを使用します。

import sys


def test_std_out_err(capfd):
    # print, sys.stderr.write で標準出力と標準エラーに出力する
    print('test stdout')
    sys.stderr.write('test stderr')
    
    # 検証
    out, err = capfd.readouterr()
    assert out == 'test stdout\n'
    assert err == 'test stderr'

pytest で print の標準出力を確認する方法

pytest を単純に実行しては標準出力の内容を確認できません。以下のように実行時に出力するためのパラメータを指定すると標準出力を確認できます。

$ pytest --capture=no

Capturing of the stdout/stderr output — pytest documentation

指定の詳細は上記URLを参考にしてください。

テスト失敗した時にメッセージを表示する

# テスト対象の関数
def add(x, y):
    return x + y


# テストコード
def test_add():
    res = add(1, 2)
    assert res == 5, f'足し算のテストに失敗しました。res: {res}'

assert の後ろに条件式、その次にメッセージを指定します。メッセージは失敗した時に確認できます。

$ pytest
============================= test session starts ==============================
platform linux -- Python 3.8.3, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
rootdir: /mnt/c/work/pytest-sample
collected 1 item

test_my_code.py F                                                        [100%]

=================================== FAILURES ===================================
___________________________________ test_add ___________________________________

    def test_add():
        res = add(1, 2)
>       assert res == 5, f'足し算のテストに失敗しました。res: {res}'
E       AssertionError: 足し算のテストに失敗しました。res: 3
E       assert 3 == 5

test_my_code.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_my_code.py::test_add - AssertionError: 足し算のテストに失敗しまし...
============================== 1 failed in 0.04s ===============================

parametrize 1つのテストメソッドで複数のテストケースを実行する

例えば1つのテストメソッドを使って複数のテストケースを実施したい場合、以下のように繰り返し同じようなコードを書いたり、for文で繰り返してもいいのですが、テストコード全体の見通しが悪くなります。

# テスト対象の関数
def add(x, y):
    return x + y


# 繰り返し同じようなコードを書くと見通しが悪い
def test_add():
    # 繰り返し同じようなテストコードを書く
    res = add(0, 0)
    assert res == 0
    res = add(1, 2)
    assert res == 3
    res = add(100, 200)
    assert res == 300

    # リストでテストパターンを定義して繰り返しテスト実行
    for (x, y, expected) in [(0, 1, 1), (1, 2, 3)]:
        res = add(x, y)
        assert res == expected

このような場面では pytest.mark.parametrize を使えばテストケースをまとめて定義して引数で受け取ることができるようになります。こうすることで DRY になっていい感じです。

import pytest


# テスト対象の関数
def add(x, y):
    return x + y


@pytest.mark.parametrize(('x', 'y', 'expected'), [
    (0, 0, 0),
    (0, 1, 1),
    (1, 0, 1),
    (1, 1, 2),
])
def test_add(x, y, expected):
    res = add(x, y)
    assert res == expected

@pytest.mark.parametrize デコレーターの第一引数でパラメータとして受け取る変数名をタプルで指定し、第二引数で各変数名で受け取る値を指定します。

上の例ではテストケースの値と期待値を指定しています。テストコードを実行すると指定したテストケースの数だけ test_add のテストが実行されます。上の例だと4ケース実行されます。以下のテスト実行時の内容を見てみると4ケースパスしていることがわかります。

$ pytest
============================= test session starts ==============================
platform linux -- Python 3.8.3, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
rootdir: /mnt/c/work/pytest-sample
collected 4 items

test_my_code.py ....                                                     [100%]

============================== 4 passed in 0.03s ===============================

@pytest.mark.parametrize を複数指定することで指定されたパラメータについてすべての組み合わせを試すことができます。

import pytest


# テスト対象の関数
def add(x, y):
    return x + y


@pytest.mark.parametrize(('x'), [1, 2])
@pytest.mark.parametrize(('y'), [1, 2, 3])
def test_add(x, y):
    res = add(x, y)
    assert res == x + y

上記コードは x について2パターン、y について3パターンの組み合わせで合計6パターンのパラメータを使ってテストします。

fixture で前処理・後処理を実行できるようにする

pytest fixtures: explicit, modular, scalable — pytest documentation

fixture(フィクスチャ)はテストの事前準備や後処理に必要なコードを実装するための機能です。一か所のメソッドで実装しておくことでテストメソッド実行時に自動的に実行されその結果を各テストメソッドから利用することができるようになります。

テスト前に準備用のコードを実行する

テスト実行前になにがしかの準備コードを実行したいケースがあるかもしれません。例えばデータベースの接続設定をしたり、テストデータやモックを準備したります。

そのような場合には fixture を使います。

import os
import pytest


@pytest.fixture
def test_file():
    # 一時ファイルを作成
    file_path = './test.txt'
    with open(file_path, 'w') as f:
        # 作成したファイルパスを渡す
        return file_path


def test_file_exist(test_file):
    # このテストメソッドが実行される前に
    # test_file() が実行され戻り値が引数で渡される
    assert os.path.isfile(test_file)

@pytest.fixture がつけられたメソッドがテスト実行前に前もって実行されます。フィクスチャメソッドの戻り値はテストメソッドで受け取ることが可能です。

テストメソッドの引数の名前をフィクスチャメソッドと同名にするとフィクスチャメソッドの戻り値が自動的に引き渡されます。

上のコードではフィクスチャメソッドでファイルを作成し、そのパスをテストメソッドに引き渡しています。

テスト後に後処理のコードを実行する

初期化処理を fixture で実行できますが後処理も同じメソッド内で実行できます。例えば上の例を拡張し、実行したファイルをテストメソッド実行が終わり次第都度削除したい場合は以下のようにします。

import os
import pytest


@pytest.fixture
def test_file():
    print('1. test_file start..')

    # 一時ファイルを作成
    file_path = './test.txt'
    with open(file_path, 'w') as f:
        # 作成したファイルパスを渡す
        yield file_path
    
    # 一時ファイルを削除
    os.remove(file_path)
    print('4. test_file end..')


def test_file_exist(test_file):
    print(f'2. test_file_exist start.. [{test_file}]')
    assert os.path.isfile(test_file)
    print('3. test_file_exist end..')

fixturereturn をしていた個所を yeild にして、後処理をその後続で実装すればよいです。上の例で実行すると以下のように前処理、テスト、後処理の順で実行されることがわかります。

$ pytest --capture=no
============================= test session starts ==============================
platform linux -- Python 3.8.3, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
rootdir: /mnt/c/work/pytest-sample
collected 1 item

test_my_code.py
1. test_file start..
2. test_file_exist start.. [./test.txt]
3. test_file_exist end..
.4. test_file end..
============================== 1 passed in 0.03s ===============================

複数のフィクスチャを実行する

複数のフィクスチャを定義することももちろん可能です。定義したフィクスチャのうち、使用する値を引数でテストメソッドに渡してやればよいです。

ただし、フィクスチャと同名の引数が定義されているテストメソッドでのみ実行されます。

import pytest


@pytest.fixture
def fixture_1():
    print('fixture_1 called.')
    return True


@pytest.fixture
def fixture_2():
    print('fixture_2 called.')
    return True


def test_case_1():
    print('called test_case_1')


def test_case_2(fixture1):
    print('called test_case_2')


def test_case_3(fixture1, fixture2):
    print('called test_case_3')

この上のように複数のフィクスチャを定義し、それぞれを使用するテストメソッドを定義してみました。このテストを実行すると、test_case_1 ではフィクスチャは実行されません。test_case_2 では fixture_1 のみが、test_case_3 では fixture_1fixture_2 の両方が実行されることが確認できます。

常に実行されるフィクスチャを定義する

引数に指定されない場合はフィクスチャが実行されなかったですが、単純にセットアップのみを行う場合などで、特に引数などと関係なく常に実行してほしい処理がある場合は @pytest.fixture(autouse=True) と指定すると必ずテストメソッド実行前にフィクスチャが実行されるようになります。

import pytest


@pytest.fixture(autouse=True)
def fixture():
    print('fixture called.')
    return True


def test_case_1():
    print('called test_case_1')

conftest.py で複数ファイル間で共通処理を実装する

fixture は基本的にモジュール内に適応される共通処理を実装します。複数のファイルをまたいだセットアップ処理を実装したい場合は conftest.py に処理を実装します。

conftest.py にフィクスチャを定義する

conftest.py という名前のファイルは自動的に pytest に収集され、同一ディレクトリ配下にあるテストファイルから参照できるようになります。別ディレクトリのテストファイルからは参照できません。

フィクスチャを conftest.py に実装すると、同一ディレクトリ配下のテストメソッドでのみ参照できます。

.
├── a
│   ├── conftest.py
│   └── test_a.py
├── conftest.py
└── test_my_code.py

上記のようなディレクトリ構造でテストを実装します。

conftest.py

import pytest


@pytest.fixture()
def top_level_fixture():
    return lambda: 'top_level_fixture'

a/conftest.py

import pytest


@pytest.fixture()
def a_fixture():
    return lambda: 'a_fixture'

conftest.py のファイルにそれぞれフィクスチャを定義します。適当な文字列を返すラムダ式を返しています。

top_level_fixture は最上位のディレクトリで定義されているためその配下のディレクトリ内のテストファイルから参照できます。test_my_code.py でも test_a.py でも参照可能です。

逆に a_fixturetest_my_code.py から参照できません。

使用可能なフィクスチャを一覧する方法

特定のテストファイルから参照できるフィクスチャを確認することもできます。

$ pytest --fixtures test_my_code.py

実行してみると自分で定義したフィクスチャ以外に、pytest が提供する組み込みのフィクスチャとその説明も確認できます。

ちなみに diff をとってみると a_fixture について差分が確認できます。

$ diff <(pytest test_my_code.py --fixtures) <(pytest a/test_a.py --fixtures)
195a196,200
> ------------------------ fixtures defined from conftest ------------------------
> a_fixture
>     a/conftest.py:5: no docstring available
>
>

フィクスチャのスコープ

フィクスチャには実行結果をどの範囲まで使いまわすかというスコープを設定できます。

デフォルトだとテスト関数ごとに実行されるようなスコープになっており、スコープには以下のような種類があります。

  • function: テスト関数ごとに実行(デフォルト値)
  • class: テストクラスごとに実行
  • module: モジュール(ファイル)ごとに実行
  • package: パッケージごとに実行
  • session: pytest実行ごとに実行(1回だけ、グローバル)
import pytest

@pytest.fixture(scope='function')
def function_fixture():
    print('function_fixture')
    return 'function_fixture'

@pytest.fixture(scope='class')
def class_fixture():
    print('class_fixture')
    return 'class_fixture'

@pytest.fixture(scope='module')
def module_fixture():
    print('module_fixture')
    return 'module_fixture'

@pytest.fixture(scope='session')
def sesion_fixture():
    print('sesion_fixture')
    return 'sesion_fixture'

def test_function_1(function_fixture, class_fixture, module_fixture, sesion_fixture):
    print('test_function1')

def test_function_2(function_fixture, class_fixture, module_fixture, sesion_fixture):
    print('test_function_2')

class TestCode():
    def test_class_1(function_fixture, class_fixture, module_fixture, sesion_fixture):
        print('test_class_1')

    def test_class_2(function_fixture, class_fixture, module_fixture, sesion_fixture):
        print('test_class_2')

上のように各スコープのフィクスチャを定義してテストを実行するとその実行結果からどの範囲でフィクスチャが呼び出されるかわかります。

出力内容を成形すると以下のような順序で呼び出されることがわかります。

sesion_fixture    # <- 一度だけ
module_fixture    # <- モジュールで1度だけ
class_fixture     # <- クラスなしのテストだと毎回呼ばれる
function_fixture  # <- テストメソッドごとに呼ばれる
test_function1
class_fixture     # <- クラスなしのテストだと毎回呼ばれる
function_fixture
test_function_2   # <- テストメソッドごとに呼ばれる
class_fixture     # <- テストクラスのテスト実行前に1度呼ばれる
test_class_1
test_class_2

scope='function' だとテストクラスにしたテストメソッドの実行前には呼び出されないのには注意が必要です。また scope='class' だとテストクラスに定義していないテストメソッドについてはそれぞれで呼び出されるようです。

ただし、あくまでテストファイルないで定義したフィクスチャはそのモジュール内のテストメソッドでしか参照できません。別のテストファイルからも参照したいフィクスチャを定義したい場合は conftest.py を使用しましょう。

conftest.pyfixture、フィクスチャのスコープを使って任意のタイミングで事前処理や共通処理を定義することが可能です。

pytest の実行直後に1度だけ実行する fixture を定義する

例として pytest 実行ごとに1回だけ実行されるフィクスチャをテストコード全体で使いまわせるようにするにはプロジェクト配下トップレベルに conftest.py を用意し、そこに scope='session' のフィクスチャを定義すればよいです。

そうするとテストが実行される前に1度呼び出され、それをテストコード全体で使いまわせます。

pytest開始前、終了後に1度だけ実行する

pytest の実行前、すべてのテストが開始される前に1度だけ処理を実行したいといった場合には、sessionstart を使います。pytest のセッション開始時に1度だけ実行される処理を定義できます。

同じくすべてのテスト終了後に実行するには pytest_sessionfinish を使います。

conftest.py に以下のように実装して pytest を実行してみます。

def pytest_sessionstart():
    print('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!')
    print('pytest_sessionstart!')
    print('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!')

def pytest_sessionfinish():
    print()
    print('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!')
    print('pytest_sessionfinish!')
    print('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!')

実行結果は以下の通りです。テスト前とテスト後に1度ずつ実行されていることがわかります。

$ pytest --capture=no
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
pytest_sessionstart!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
============================= test session starts ==============================
platform linux -- Python 3.8.3, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
rootdir: /mnt/c/work/pytest-sample
plugins: mock-3.5.1
collected 1 item

tests/test_my_code.py .
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
pytest_sessionfinish!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!


============================== 1 passed in 0.03s ===============================

もし、テスト全体を通しての初期化や後処理が必要であればこのようなイベントをフックする形で実装できます。

API Reference — pytest documentation

ほかにもいくつかの種類のフックが用意されています。上のページを参考にしてみてください。

以上、pytest の基本でした。

参考URL

Pythonカテゴリの最新記事