Atsushi2022の日記

データエンジニアリングに関連する記事を投稿してます

Pytestを試してみる

Pytestの概要

pytestはPython用のソフトウェアフレームワーク。記述したテストを文字列で発見して、自動的にテストを行ってくれる。Circle CIなどのCIツールと組み合わせることで、テスト自動化ができる。

pytestはpip install pytestでインストールする。

is_odd.pyというPythonコードがあるとする。それに対応するテストをtest_is_odd.pyに記述する。pytestはpytestというコマンドを実行するだけで実行される。ファイル名はtest_*.py または *_test.py とすると、pytestが検索してテストを実行してくれる。テストメソッドやテスト関数の名前はtest_*という形式にする。テストクラスの名前はTest*という形式にする。assert文でわかりやすくテストを記述できる。エラーをraiseする場合は、pytest.raises()を使用して想定通りエラーとなることを確認できる。

is_odd.py

def is_odd(x):
  if x <= 0:
    raise TypeError
  elif type(x) != int:
    raise TypeError
  elif x%2 == 1:
    return True
  else:
    return False

test_is_odd.py

import pytest
from is_odd import is_odd


def test_is_odd():
    with pytest.raises(TypeError):
        is_odd(-1)
    with pytest.raises(TypeError):
        is_odd(0)
    with pytest.raises(TypeError):
        is_odd(0.1)
    assert is_odd(2) == False
    assert is_odd(3) == True

Pytestの実行結果は次の通り。ピリオド.がテストの成功を表す。

> pytest
============ test session starts ===========
platform win32 -- Python 3.10.7, pytest-7.2.0, pluggy-1.0.0
rootdir: C:\Users\bluen\Documents\Python\pytest_test
plugins: anyio-4.0.0, cov-4.0.0
collected 1 item                                                                                                                                                                                        

test_is_odd.py .                                                                                                                                                                                 [100%]

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

試しにassert文を変更してわざと失敗させてみると、実行結果は次のようになる。Fはテストの失敗を意味しており、さらにエラーとなったテストの該当箇所を表示してくれる。

> pytest        
================================= test session starts ===============================
platform win32 -- Python 3.10.7, pytest-7.2.0, pluggy-1.0.0
rootdir: C:\Users\bluen\Documents\Python\pytest_test       
plugins: anyio-4.0.0, cov-4.0.0
collected 1 item

test_is_odd.py F                                                                                                                                                                                                     [100%]

======================FAILURES ===============================
_____________________ test_is_odd ____________________________

    def test_is_odd():
        with pytest.raises(TypeError):
            is_odd(-1)
        with pytest.raises(TypeError):
            is_odd(0)
        with pytest.raises(TypeError):
            is_odd(0.1)
>       assert is_odd(2) == True
E       assert False == True
E        +  where False = is_odd(2)

test_is_odd.py:12: AssertionError
===================== short test summary info =================
FAILED test_is_odd.py::test_is_odd - assert False == True
===================== 1 failed in 0.15s =======================
PS C:\Users\bluen\Documents\Python\pytest_test> 

エラーの詳細が不要な場合は--tb=no オプションをつけるとトレースバックをオフにできる。

> pytest --tb=no
========================== test session starts ==========================
platform win32 -- Python 3.10.7, pytest-7.2.0, pluggy-1.0.0
rootdir: C:\Users\bluen\Documents\Python\pytest_test
plugins: anyio-4.0.0, cov-4.0.0
collected 1 item                                                                                                                                                                                                            

test_is_odd.py F                                                                                                                                                                                                     [100%]

========================== short test summary info ========================== 
FAILED test_is_odd.py::test_is_odd - assert False == True
========================== 1 failed in 0.04s ========================== 

以上が超基本的なpytestの使用方法。後述するフィクスチャやパラメータ化、マーカー、モックを使用することでさらに簡単にテストを記述できるようになる。

テストディスカバリ

pytestを実行するときにファイルやディレクトリを指定しないと、現在のディレクトリとサブディレクトリをPytestが検索する。

名前が test_*.py または *_test.py となっているファイルを検索する。

テストメソッドやテスト関数の名前はtest_*という形式にする。

テストクラスの名前はTest*という形式にする。

ルール に従わない名前が付いたファイルや関数、クラスがたくさんある場合はテストディスカバリの設定を変更する手もある。

特定のテストのみ実施したい場合は、<ファイル名>::test_とすることで、テストファイル内の特定の関数のみを実行できる。

pytest test_foo.py::test_func

テスト結果

テスト結果は次の通り。よく見かけるのはピリオド.Fだと思われる。

  • PASSED(.)
  • FAILED(F)
  • SKIPPED(s)
  • XFAIL(x):想定通り失敗
  • XPASS(X):想定に反して成功
  • ERROR(E):テスト以外の部分でエラーが発生

包括的なテスト

pytestを使用したからと言って網羅的にテストできるわけではない。包括的にテストを行うには次のようなケースを想定したテストを記述することが重要。

  • エッジケース
  • エラーケース
  • データ構造の破壊
  • 負の数字
  • ばかでかい文字列

テストの構造化

テストを構造化することでわかりやすくする。

Given -> When -> Then (与えられたデータ ⇒ 振る舞いのテスト ⇒ 結果)となるよう構造化する。

テストをクラスにまとめる

以下のようにテスト関数をクラスにまとめることで、まとめてテストを実行できる。但し、クラスは基本的には複数のテストをまとめることを目的として使用すること。そして、継承といった凝ったことはおこわず、控えめに使用することが重要。

class TestEvenOdd():
  def test_is_odd(self):
      with pytest.raises(TypeError):
          is_odd(-1)
      with pytest.raises(TypeError):
          is_odd(0)
      with pytest.raises(TypeError):
          is_odd(0.1)
      assert is_odd(2) == False
      assert is_odd(3) == True

  def test_is_even(self):
      with pytest.raises(TypeError):
          is_even(-1)
      with pytest.raises(TypeError):
          is_even(0)
      with pytest.raises(TypeError):
          is_even(0.1)
      assert is_even(2) == True
      assert is_even(3) == False

フィクスチャ

  • テストの前処理と後処理をテスト関数から切り離すことができる
  • テストで使うデータを取得したり、テストの初期状態をつくりあげるのに使う
  • @pytest.fixture()でフィクスチャ
  • yieldの前が前処理(セットアップ)で、yieldの後ろが後処理(ティアダウン)。yieldのタイミングでテストが実行される。
@pytest.fixture()
def api_client():
  client = ApiClient()
  yield api_client
  client.close()

def test_api_client(api_client):
  assert api_client.timeout == 240

--setup-showオプションでセットアップ/ティアダウンの処理順序を表示できる。

> pytest test_api_client.py --no-header --setup-show
======== test session starts ==============
collected 1 item                                                                                                                                                                                          

test_api_client.py
        SETUP    F api_client
        test_api_client.py::test_api_client (fixtures used: api_client).
        TEARDOWN F api_client

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

フィクスチャのスコープ

フィクスチャにスコープを設定することで、クラスやモジュール(ファイル)単位でフィクスチャを1回のみ実行し、繰り返しフィクスチャが実行されないようにできる。

@pytest.fixture(scope="class")
  • @pytest.fixture(scope='function)
  • @pytest.fixture(scope='class)
  • @pytest.fixture(scope='module)
    • モジュール(.pyファイル)ごとに1回実行される
  • @pytest.fixture(scope='package)
    • パッケージ(モジュールをまとめたもの)ごとに1回実行される
  • @pytest.fixture(scope='session)
    • pytestコマンドを1回実行するのが、1セッション
    • セッションに対して1回フィクスチャが実行される

複数のファイルでフィクスチャを共有する場合、つまりスコープがパッケージ以上の範囲の場合は、confest.pyを作成し、そこにフィクスチャを記述する必要がある。

confest.py

import pytest

@pytest.fixture(scope='package')
def api_client():
  client = ApiClient()
  yield api_client
  client.close()

confest.pyはPytestにより自動的に読み込まれるため、インポート不要。

フィクスチャは、テストモジュール内、またはテストのルートまでのディレクトリ上のconfest.pyファイルに存在する可能性があるので、どこにあるか分からなくなったりする。

そういった場合は、フィクスチャの場所をpytest --fixturesまたはpytest --fixtures-per-testで表示できる。

--fixtures-per-testはテスト毎に使用しているフィクスチャを表示してくれるので、--fixturesより分かりやすいと思う。

テスト test_api_client で使用している api_client というフィクスチャは conftest.py に存在していることがわかる。

> pytest --fixtures-per-test
======================== test session starts ========================
platform win32 -- Python 3.10.7, pytest-7.2.0, pluggy-1.0.0
rootdir: C:\Users\bluen\Documents\Python\pytest_test
plugins: anyio-4.0.0, cov-4.0.0
collected 2 items

------------------------- fixtures used by test_api_client ------------------------- 
------------------------- (test_api_client.py:7) ------------------------- 
api_client -- conftest.py:5
    no docstring available

======================== no tests ran in 0.03s ======================== 

nameパラメータを使用することで、フィクスチャの名前を変更することができる。但し、フィクスチャ名を変更しないことが望ましい。

@pytest.fixture(name ="tableau_api_client")
def api_client():

一時ディレクトリの作成

tmp_pathという組み込みのフィクスチャを使用することで、一時的なディレクトリを用意してくれる。ディレクトリはテスト終了しても残っているので、ファイルを確認したりできる(デフォルトだと直近のテスト3回分を保持)。

一時ディレクトリにC:/Users/xxxxx/AppData/Local/Temp/pytest-of-xxxx/pytest-62/test_tmp_file_path0/tmp_test_file.txtにファイルが置かれていることがわかる。

def test_tmp_file_path(tmp_path):
    file = tmp_path / "tmp_test_file.txt"
    print('File type is', type(file)) # `tmp_path`はpathlib.Pathオブジェクトを返す
    print('File location is ', file.as_posix())
    file.write_text('Mr.Bean went to Paris')
    assert file.read_text() == 'Mr.Bean went to Paris'
> pytest -s --no-header
======================== test session starts ========================
collected 3 items                                                                                                                                                                                                           

test_api_client.py Hello World!!
.
test_is_odd.py .
test_tmp.py File type is <class 'pathlib.WindowsPath'>
File location is  C:/Users/xxxxx/AppData/Local/Temp/pytest-of-xxxx/pytest-62/test_tmp_file_path0/tmp_test_file.txt
.

======================== 3 passed in 0.04s ======================== 

似たような組込みフィクスチャにtmp_path_factoryがある。tmp_pathが関数スコープのフィクスチャなのに対し、tmp_path_factoryはセッションスコープであるため、関数をまたいで同じ一時ディレクトリを使用する場合はtmp_path_factoryを使用することになる。

標準出力をテストする

アプリケーションの標準出力、標準エラー出力をテストしたい場合がある。組込みフィクスチャのcapsysなら、簡単に標準出力・標準エラー出力を取得できる。

from api import ApiClient
def test_api_version(capsys):
    api_client = ApiClient()
    api_client.version()
    stdout = capsys.readouterr().out.rstrip()
    stderr = capsys.readouterr().err.rstrip()
    assert stdout == '1.0'
    assert stderr == ''

モンキーパッチで属性を変更する

組込みフィクスチャのmonkeypatchを使用することで、テスト内で簡単に属性値を変更することができる。下例では、テスト内でタイムアウト値をでデフォルトから変更している。

from api import ApiClient

def test_api_timeout(monkeypatch):
    api_client = ApiClient()
    print('Timeout value is ', api_client.timeout) # デフォルトのタイムアウト値である「240」が表示される
    monkeypatch.setattr(api_client, 'timeout', 100) # 属性timeoutの値を「100」に変更
    print('Timeout value is ', api_client.timeout) # 変更後のタイムアウト値「100」が表示される
    assert api_client.timeout == 100

monkeypatchsetattr()メソッドによる属性設定以外にも、用意されたメソッドを使用して属性削除や、ディクショナリのエントリを設定・削除、環境変数の設定・削除、作業ディレクトリの変更ができる。

いったんここまで。次回以降、パラメータ化、マーカー、モック、カバレッジ、pytest設定ファイルをやっていく。