こんにちは、エムスリー・エンジニアリングG・基盤開発チーム小本です。
pytest は Python のユニットテストのデファクトスタンダードです。エムスリーでも顧客向けレポートや機械学習でPython&pytest をヘビー🐍1に使っています。
ですが、実は pytest は、意外と入門のハードルが高い。と言うのも、pytest の公式ドキュメント が、fixtureのような新概念も登場する上、詳細で分量が多いからです(しかも英語)。初心者にいきなり読ませると挫折する可能性大です 2。
そこで、とりあえず使い始めるのに必要そうな情報を日本語でまとめました。
- pytest ってどんなライブラリ?
- pytest の使い方
- インストール・実行
- テストを書く
- テストファイルの配置
- 特定のテストのみを実行する
- assert 文に説明文を書く
- 例外を検証する
- doctest も実行する
- pytest コマンドに便利なオプションを追加する
- テストの途中でデバッガを起動する
- テストカバレッジを測定する
- テスト結果をJUnit形式で出力する
- 共通のリソースを定義する(fixture)
- fixture の事後処理
- テストの前後に処理をしたい(setUp / tearDown の代替)
- fixture をファイル間で共有する(conftest.py)
- ヘルパーを定義する
- ヘルパーを定義する(2)
- 使える fixture を確認する
- テスト全体で同じ値を使い回す
- monkeypatch でモックする
- unittest.mock でモックする
- 環境変数を差し替える
- 一時ディレクトリでテストする(tmp_path)
- テストデータをファイルから読み込む
- Parameterized test
- もっと pytest に詳しくなる
- エンジニアを募集しています!
pytest ってどんなライブラリ?
pytest は unittest や nose の上位互換なツールです。
unittest や nose から簡単に移行できる
pytest
コマンドでは unittest や nose で書いたテストもそのまま実行できます。
移行するにはテスト実行時コマンド(python -m unittest
や nosetests
)を pytest
に入れ替えるだけでOK。
pytest test_written_in_unittest.py test_written_nose.py
書き方がシンプル
unittest は xUnit の伝統を踏襲し、
- テストをクラスとして定義し、
- assertXxx メソッドで検証する
という書き方をしていました。
class FooTest(TestCase): def test_function(self): self.assertEquals(f(), 4)
pytest の書き方は、より軽量です。
- テストは関数として定義し、
- assert 文で検証します。
def test_function(): assert f() == 4
それでいて、pytest のランナーは賢く、assertEquals
と同様に失敗した時の値も出力してくれます。
def test_function(): > assert f() == 4 E assert 3 == 4 E + where 3 = f()
fixture
fixture はテストで使用するリソース(DBへのコネクションなど)を定義する仕組みです。テスト関数の引数と同名の fixture が自動的に与えられます(DI)。
import pytest @pytest.fixture def smtp(): import smtplib return smtplib.SMTP("smtp.example.com") @pytest.fixture def imap(): import imaplib return imaplib.IMAP4("imap.example.com") def test_foo(smtp, imap): '''smtp と imap を使ったテスト''' # 略
unittest 時代にはリソース取得(DBへのコネクション取得など)は setUp()
で書いていましたが、テストでリソースの種類が増えるにつれ setUp()
が肥大化し、どのテストでどのリソースを使っているのか分かりにくなりがちでした。fixtureではそんな問題を避けることができます。
モックもできる
pytest では [monkeypatch]https://docs.pytest.org/en/latest/monkeypatch.html という fixture を提供しています。 より複雑なモックには標準ライブラリの unittest.mock を使います。
プラグインは豊富
PyPIで "pytest-" で検索すると様々なプラグインが見つかります。
例えば以下のプラグインがあります。
プラグイン | 機能 |
---|---|
pytest-cov | カバレッジ |
pytest-html | 結果をHTML形式で出力 |
pytest-datadir | テスト用ファイルを管理する |
pytest-dbfixtures | MySQL等の主要なDBの fixture |
pytest の使い方
インストール・実行
普通の方法でインストールできます。特に注意点はありません。
pipenv install --dev pytest pytest-html pytest-cov pytest-datadir pytest-dbfixtures # インストール pipenv run pytest # 実行
なお、以降の説明ではコマンドの pipenv run
の部分は省略します。(pipenv shell
コマンドや、direnv の layout pipenv
を使えば、pipenv run
を省略できます。)
テストを書く
unittest 同様に
- 名前に
test
が付くファイル(test_*.py
or*_test.py
)がテストファイル - 名前に
test
が付く関数がテスト関数
というルールです(詳細)。
すでに書いたように pytest では値のチェックには assert 文を使います。 テスト内容の説明は docstring に書きます。
## test_foo.py def f(): return 3 def test_f(): """f() の戻り値を検証する""" assert f() == 3
テスト関数をクラスにまとめることもできます。このとき、クラスが unittest.TestCase
を継承する必要はありません。
## test_class.py class TestClass: def test_one(self): x = "this" assert "h" in x def test_two(self): x = "hello" assert hasattr(x, "check")
pytest
コマンドでテストを実行します。
$ pytest test_foo.py
テストファイルの配置
テストファイルは、どんな名前でもいいし、何処に置いても認識されます。
しかし、
tests/
ディレクトリに- 本体コードと対応する名前
とするのが無難でしょう。
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
なお、本体コード(mypkg/
)はレポジトリのルート直下ではなく、src/
の下に置くのが強く推奨されています。詳細はこちらの記事を参照:
Packaging a python library | ionel's codelog
特定のテストのみを実行する
pytest
の引数で実行するテストを指定できます。
ファイル | pytest test/test_foo.py |
関数 | pytest test/test_foo.py::test_function |
クラス | pytest test/test_foo.py::TestClass |
クラスのメソッド | test/pytest test_foo.py::TestClass::test_method |
また、pytest -k
でより複雑な指定もできます(詳しくは pytest --help
を参照)。
assert 文に説明文を書く
assert 文の第2引数に検証内容の説明を書けます3。
def test_something(): n = 3 assert len(f(n)) >= n, '返された値が要求数より少ない'
説明は検証失敗時に以下のように表示されます。そのため、説明としては「検証の内容の説明」というよりは「検証の失敗の説明」を書くべきでしょう。
def test_something(): n = 3 > assert len(f(n)) >= n, '返された値が要求数より少ない' E AssertionError: 返された値が要求数より少ない E assert 0 >= 3 E + where 0 = len([]) E + where [] = f(3)
なお、assert の第2引数は、チェック内容が難解・複雑な場合のみ書けばよく、基本的には書かなくてもよいと、私は思います。
例外を検証する
pytest.raises
を使います。
例外のメッセージをチェックするには match=
を使います。詳細な検証をするには as
で例外オブジェクトを変数に代入します。
import pytest def test_raises(): with pytest.raises(ZeroDivisionError): 1 / 0 def test_match(): with pytest.raises(ValueError, match=r".* 123 .*"): raise ValueError("Exception 123 raised") def test_excinfo(): with pytest.raises(RuntimeError) as excinfo: raise RuntimeError("ERROR") assert str(excinfo.value) == "ERROR"
doctest も実行する
doctest は、Pythonには docstring 内のサンプルコードをテストとして実行する仕組みです。
--doctest-modules
を定義すると、pytest
からdoctestを実行できます。
pytest --doctest-modules
pytest コマンドに便利なオプションを追加する
オプションがいくつもありますが、以下のオプションをつけておくと便利です。
--pdb
: テスト失敗時に pdb(デバッガ) を起動--ff
: 前回失敗したテストを先に実行
オプションは環境変数 PYTEST_ADDOPTS
に定義すると、コマンド実行時に省略できます。
export PYTEST_ADDOPTS='-v -s --pdb --ff --doctest-modules' # おすすめ! pytest # PYTEST_ADDOPTS が自動で付加されて実行
テストの途中でデバッガを起動する
--pdb
を使うと、テスト失敗時に pdb(デバッガ) が起動されます。
特定の場所でデバッガを起動したいなら、以下の行をその場所に追加します。
import pdb;pdb.set_trace()
なお、pdb は pytest の機能ではなく標準ライブラリの1つです。
テストカバレッジを測定する
pytest-cov
を使います。--cov=
でカバレッジを測定したいパッケージを指定します。
pipenv install --dev pytest-cov pytest --cov=mypkg --cov-report=html # mypkg のカバレッジを測定する
テスト結果をJUnit形式で出力する
JUnit形式はテスト結果ファイルのデファクトスタンダードであり、JenkinsやGitlabなどのツールで集計できたりします。
pytest --junitxml=path
共通のリソースを定義する(fixture)
@pytest.fixture
でアノテートした関数は fixtureになります4。
テスト関数の引数に fixture 名を指定すると実行寺に値がDIされます。fixture は複数指定できます。
また、fixture は定義したファイル(モジュール)の中だけで有効なので、他のテストと名前が被る心配は不要です。
import pytest @pytest.fixture def smtp(): import smtplib return smtplib.SMTP("smtp.example.com") @pytest.fixture def imap(): import imaplib return imaplib.IMAP4("imap.example.com") def test_foo(smtp, imap): '''smtp と imap を使ったテスト''' # 略
なお、共通の処理を定義したい場合には、単に関数を定義します(無理やりfixture として定義することもできますが)。
def contains_all_letters(subject_string, letters): '''文字列が指定した文字を全て含んでいる時 True''' return all(l in subject_string for l in letters) def test_foo(): assert contains_all_letters(foo(), 'aA1'), '大文字・小文字・数字を全て含まなければならない'
fixture の事後処理
fixture で生成したオブジェクトについて事後処理(「コネクションを閉じる」など)をしたい場合があります。
オブジェクトを return
ではなく yield
で返すと、テスト後(成功寺も失敗寺も)にyield
以降の処理が実行されます。
@pytest.fixture def smtp(): import smtplib conn = smtplib.SMTP("localhost", 1025) yield conn conn.close() # テスト実行後にSMTPセッションを破棄する def test_foo(smtp): '''smtp を使ったテスト''' # 略
なお、SMTPのように with
文に対応している場合は with
を使うべきです。
@pytest.fixture def smtp(): with smtplib.SMTP("localhost", 1025) as conn: yield conn
テストの前後に処理をしたい(setUp / tearDown の代替)
特定のリソースには紐づかないが、事前処理・事後処理をしたい場合も(たまに)あります。
そんな場合は空の yield
をする fixture を定義します(テストの引数には None
が代入されます)。
@pytest.fixture def before_and_after(): print('BEFORE') yield print('AFTER') def test_foo(before_and_after): print(before_and_after)
$ pytest -s test/test_foo.py ============================ test session starts ============================= collected 1 item test/test_foo.py BEFORE None .AFTER ============================= 1 passed in 0.03s ==============================
fixture をファイル間で共有する(conftest.py)
conftest.py
に定義した fixture はファイル間で共有できます。なお、conftest.py
の有効範囲はディレクトリ単位です。一部のテストでしか使わない fixture はサブディレクトリの conftest.py
に定義しましょう。
test/ conftest.py # ここに定義した fixture はどのファイルからも使える sub/ conftest.py # ここに定義した fixture は test_sub.py からのみ使える test_sub.py test_bar.py test_foo.py
ヘルパーを定義する
テストの規模が大きくなってくると、テスト内で使うヘルパー的な関数やクラスを定義したくなってきます。
pytest が推奨するのは「以下のような関数を返す fixture を定義する」という、少しギョッとする?方法です。
## conftest.py import pytest @pytest.fixture def assert_list_match(): '''2つのリストが同じ要素を格納しているかチェックする関数''' def _assert_list_match(list1, list2): assert sorted(list1) == sorted(list2) return _assert_list_match
https://docs.pytest.org/en/latest/fixture.html#factories-as-fixtures
ヘルパーを定義する(2)
従来通り test/
ディレクトリをパッケージとみなして、そこにヘルパーを定義する方法もあります。
ディレクトリ構造:
test/ __init__.py conftest.py helpers.py sub/ test_foo.py
## test/helpers.py def assert_list_match(list1, list2): '''2つのリストが同じ要素を格納しているかチェックする関数''' assert sorted(list1) == sorted(list2)
## test/sub/test_foo.py from test.helpers import assert_list_match def test_foo(): assert_list_match([1, 2, 3], [3, 1, 2, 4])
なお、単に関数を定義しただけだと、モジュール内で assert 文が失敗した時に AssertionError
としか表示されません
def assert_list_match(list1, list2): > assert sorted(list1) == sorted(list2) E AssertionError
もし、(テスト内に直接 assert 文を書いた時のように) assert 文に含まれる変数の値が表示したいなら、
conftest.py
の中で register_assert_rewrite
を呼び出して、パッケージを登録します。詳しくは Assertion Rewriting をご覧ください。
## conftest.py import pytest pytest.register_assert_rewrite('test.helpers')
使える fixture を確認する
現在使える fixture を pytest --fixtures
コマンドで一覧表示できます。
$ pytest --fixtures test/test_foo.py ============================ test session starts ============================= cache Return a cache object that can persist state between testing sessions. cache.get(key, default) cache.set(key, value) Keys must be a ``/`` separated value, where the first part is usually the name of your plugin or application to avoid clashes with other cache users. Values can be any object handled by the json stdlib module. capsys Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``. The captured output is made available via ``capsys.readouterr()`` method calls, which return a ``(out, err)`` namedtuple. ``out`` and ``err`` will be ``text`` objects. (中略) ----------------------- fixtures defined from conftest ----------------------- fix ファイル間で共有したい fixture ----------------------- fixtures defined from test_foo ----------------------- fix2 ファイル内でのみ使用したい fixture
テスト全体で同じ値を使い回す
デフォルト設定では fixture はテスト関数ごとに実行されますが、 実行にコストがかかり、テスト間で値を共有したいケースもあります。
そんな場合は scope=
を指定します。
@pytest.fixture(scope="module") def smtp(): return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)
scope
には以下の値があります。
"function"
: テスト関数ごと(デフォルト値)"class"
: クラスごと"module"
: ファイルごと"package"
: パッケージごと"session"
: pytest コマンドの実行ごと
monkeypatch でモックする
monkeypatch
はメソッドやメンバー変数などを差し替えるための、シンプルな関数群を提供します。
monkeypatch.setattr(obj, name, value, raising=True) monkeypatch.delattr(obj, name, raising=True) monkeypatch.setitem(mapping, name, value) monkeypatch.delitem(obj, name, raising=True) monkeypatch.setenv(name, value, prepend=False) monkeypatch.delenv(name, raising=True) monkeypatch.syspath_prepend(path) monkeypatch.chdir(path)
いずれも、
- 標準ライブラリのメソッド(
setattr
など)を呼び出す - テスト実行後に元に戻す
という動作をします。
なお、monkeypatch.setattr
でメソッドを差し替えるには、以下のように(戻り値ではなく)関数を与えなければなりません(内部で setattr
を使っているので当然ですが)。
class Example: def example_method(self, n): return n * 2 def test_monkeypatch(monkeypatch): e = Example() def mockreturn(self, n): return n * 3 monkeypatch.setattr(Example, "example_method", mockreturn)
unittest.mock でモックする
標準ライブラリの unittest.mock はとても強力で、 monkeypatch のような差し替えだけでなく、差し替えたメソッドが呼び出されたどうかを事後に検証することもできます。
既存の関数やメソッドを差し替えるには patch
を使います。
import mypkg from unittest.mock import patch def test_patch_function(): assert mypkg.example_function(0) == 1 with patch('mypkg.example_function', return_value=42): assert mypkg.example_function(0) == 42 def test_patch_method(): e = mypkg.Example() assert e.example_method(0) == 1 with patch.object(mypkg.Example, 'example_method', return_value=42): assert e.example_method(0) == 42
with文の変数にはモックオブジェクトが代入され、事後に差し替えた関数やメソッドが呼び出されたかどうかをチェックできます。
def test_mock_method_called(): e = mypkg.Example() with patch.object(mypkg.Example, 'example_method', return_value=42) as m: e.example_method(1) e.example_method(2) e.example_method(3) m.assert_any_call(3) assert m.call_count == 3
m.assert_not_called() # 0回 m.assert_called_once() # 1回 m.assert_called() # 1回以上 assert m.call_count == n # n 回 m.assert_any_call(3, 4, 5, key='value') # 特定の引数で呼び出された
環境変数を差し替える
monkeypatch
でも unittest.mock でも差し替えられます。
from unittest.mock import patch import os ## monkeypatch の例 def test_env1(monkeypatch): monkeypatch.setenv('NEW_KEY', 'newvalue') assert os.environ['NEW_KEY'] == 'newvalue' ## unittest.mock の例 def test_env2(): with patch.dict('os.environ', {'NEW_KEY': 'newvalue'}): assert os.environ['NEW_KEY'] == 'newvalue'
一時ディレクトリでテストする(tmp_path)
一時ディレクトリを作る fixture である tmp_path
を使います。
from pathlib import Path def create_file_in_cwd(): with open('foo.txt', 'w') as f: print('hello', file=f) def test_create_file(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) # カレントディレクトリを tmp_path に一時的に変更 assert Path('foo.txt').exists()
なお、tmp_path
に代入される値は一時ディレクトリのパスを表す pathlib.Path
です。
テストデータをファイルから読み込む
pytest-datadir を使います。
pytest-datadirでは2種類の fixture が追加され、shared_datadir
では data/
ディレクトリのパス、datadir
ではテストファイルと同名のディレクトリのパスを取得できます。
tests/ data/ hello.txt # test_foo.py からも test_bar.py からも参照できる test_foo/ spam.txt # test_foo.py からのみ参照できる test_bar.py からも参照できる test_foo.py test_bar.py
## test_foo.py def test_read_global(shared_datadir): contents = (shared_datadir / 'hello.txt').read_text() assert contents == 'Hello World!\n' def test_read_module(datadir): contents = (datadir / 'spam.txt').read_text() assert contents == 'eggs\n'
Parameterized test
同じテストを、値を変えて何度も実行したいときは、
@pytest.mark.parametrize
デコレータを使います。
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
詳しくはドキュメントを参照してください。
もっと pytest に詳しくなる
この記事はあくまでサマリーなので、詳しくは公式ドキュメントをご覧ください。
pytest は2009年からある、割と歴史あるライブラリなので、ヘビーユーザーもドキュメントを読み返すと新たな発見があるかもしれません。ちなみに、私は名前が py.test から pytest に変わっていることに驚きました。
エンジニアを募集しています!
エムスリーは Python のヘビー🐍ユーザー[^1]です。一緒に働く仲間を募集中です。お気軽にお問い合わせください。