🐍

Python(pytest)でテスト書くならfixture,conftest,parametrizeを理解すると世界が一気に変わる

2023/08/11に公開

概要

Pythonのテストライブラリといえばpytestが一般的です。
Python標準のuniitestとは異なり、クラスベースではなく関数ベースでテストコードを記述することが一般的ですが、fixture,conftest,parametrizeを理解すると一気に世界が変わり、テスト体験が圧倒的に向上するため、これらの実装方法を紹介します。

リポジトリ

本記事の説明に使用しているサンプルのテスト実装は、以下のリポジトリです。

https://github.com/takashi-yoneya/python-test-template

想定読者

PythonやGitの基本的な使い方を理解している方を想定しているため、基本的な用語説明は省略しています。

環境

エンジニアの利用率の高いmacOSを前提として説明していますので、その他の環境の方は随時読み替えてください。
開発環境はVSCODEの前提で説明しています。

使用するパッケージ

  • pytest
  • pytest_mock: mockを簡単に管理できるパッケージ

fixture

テストを作る際に大きなウェイトを占めるのが、テストデータの準備等の事前作業です。
これらをテスト関数内で毎回定義することもできますが、テストの焦点がぶれてしまうため得策ではありません。
そこで、fixture(治具の意味)を使用することで、テスト関数に依存したテストデータやAPI連携用のHTTPクライアント,DBクライアントなどを簡単に定義することができます。

任意の関数にデコレーターとして以下のようにfixtureをセットすると、以下のように定義したfixtureをテストコードの関数名に指定することで、テストコードの前処理を定義することができます。

引数にfixtureの関数名を指定するのが、Pytest以外で馴染みが薄くわかりずらい点ですが、fixtureは戻り値を取ることもできるので、引数に指定することで、戻り値もテスト関数内で使用することができます。

以下の例では、test_func1を実行した場合に、any_func_name関数がまず実行された後に、test_func1の中身が実行されるようになります。

# any_func_nameというfixtureが定義されます
@pytest.fixture
def any_func_name():
  ...
  return value


# テスト関数の引数にfixtureを指定して使用できます
def test_func1(any_func_name):
  value = any_func_value

一般的によく使われるパターンはテスト用のDBクライアントを作成する処理をfixtureで定義して、必要なテスト関数で呼び出す方法です。
以下の例では、test_func_with_db関数を実行すると、db関数(fixture)が前処理として呼び出されてテスト用のDBClientを定義できます。

@pytest.fixture
def db():
  # 使用するDBクライアントの定義を行う
  db_client = ...
  return db_client

def test_func_with_db(db):
  # fixtureとして定義したdb関数はテスト関数の引数に指定して呼び出すことができる
  res = db.query.fetchall()
  assert len(res) == 5 

以下のようにautouse=Trueを指定した場合は、テスト関数にfixtureを指定せずとも、全てのテスト関数で自動的に実行されるため、常に実行したい初期処理などを定義する場合に使用します。
便利ではありますが、どのfixtureが実行されているか、テスト関数から辿るのが難しくなるため、注意して使用する必要があります。

# autouse=Trueを付与すると、全てのテスト関数の前処理として自動的に実行されます。
@pytest.fixture(autouse=True)
def setup():
  ...


def test_func():
  ...

テストの後処理を行いたい場合は、以下のようにyieldを使用すると簡単です。
yieldはreturnとは違い、呼び出し元の処理が終わった後にまたyieldの場所に戻ってくることが出来る制御です。

@pytest.fixture
def db():
  # 使用するDBクライアントの定義を行う
  db_client = ...
  # 任意の後処理を定義したい場合は、yieldを使うことで、テスト関数実行後に、yieldより下の行の処理を実行できます。
  yield db_client
  db_clinet.close()

conftest.py

fixtureはテストモジュール内で定義しても良いですが、複数のモジュールを横断して使用したいような場合は、conftest.pyにfixtureを定義することで、複数のモジュールを横断してfixtureを使用することができます。

conftest.pyはimportでの読み込みは不要で、自動的に読み込まれます。
conftest.pyは階層構造で処理され「conftest.pyが存在する階層を含むそれ以下の階層にしか適用されない」という特性があります。

以下図のような構成を例とすると、test/(テストルート)直下に配置したconftest.py(a)はテスト全体に適用され、dir/直下に配置したconftest.py(b)はdir1/配下のみに適用されます。
test/di1/dir1-1/test_1-1.py のテストコードには、図内の(a),(b),(c)のconftest.pyが適用されます。
conftest_tree

上位階層のconftest.pyには、DBClientの定義が必須データの投入などのfixtureを記述しておき、各ディレクトリ直下のconftest.pyで必要なfixtureを定義していくと、fixtureの共通化と個別化が効果的に行えます。

parametrize

1つのテスト関数で色々なパターンのデータでテストをしたい場合は多いですが、parametrizeを使用すると、簡単に実装できます。

ただし過度な共通化を行うと、わかりずらいテストになってしまう場合もあるので、注意が必要です。

以下の例では、1つのテスト関数で2種類のパタメータでテストを定義しています。
テスト関数にpytest.mark.parametrizeデコーレータを記述し、第一引数に変数名、第二引数にパラメータを複数指定できます。
パラメータの指定はpytest.paramを使用すると可読性があがります。pytest.paramにはpytest.mark.parametrizeの第一引数で指定した変数名の順番で、テストで使用したいパラメータを指定できます。idを明示的に指定した場合は、テストファイル名::テスト関数[id名]のように実行できます。

import pytest

@pytest.mark.parametrize(
    # ここで指定した変数名が、テスト関数内で使用可能
    [
        "data_in",
        "expected_status",
        "expected_data"
    ],
    # 複数のパラメーターを定義できる
    [
      # 上記の順番(data_in, expected_status, expected_data)で変数を記述する
      pytest.param(
        {"name": "test"},
        200,
        {"name": "test"},
        # idを明示的に指定した場合は、テストファイル名::テスト関数[id名]のように実行できる
        id="success"
      ),
      pytest.param(
        {},
        400,
        {"error": "nameは必須です"},
        id="fail_request_error"
      )
    ],
)
# pytest.mark.parametrizeの第一引数で指定した変数名は、かならずテストコードの引数に指定する必要がある。
def test_func1(data_in, expected_status, expected_data, authed_client):
    # action
    res = authed_client.post("/users/register", json=data_in)
    
    # assert
    assert res.status_code == expected_status
    assert res.json() == expected_data
GitHubで編集を提案

Discussion