pytest ヘビー🐍ユーザーへの第一歩 - エムスリーテックブログ

エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

pytest ヘビー🐍ユーザーへの第一歩

KeikyuDaishi 06c4458sx

蛇行区間にはレールの内側に脱線防止ガードが設置される(本文とは関係ありません)。

こんにちは、エムスリー・エンジニアリングG・基盤開発チーム小本です。

pytest は Python のユニットテストのデファクトスタンダードです。エムスリーでも顧客向けレポートや機械学習でPython&pytest をヘビー🐍1に使っています。

ですが、実は pytest は、意外と入門のハードルが高い。と言うのも、pytest の公式ドキュメント が、fixtureのような新概念も登場する上、詳細で分量が多いからです(しかも英語)。初心者にいきなり読ませると挫折する可能性大です 2

そこで、とりあえず使い始めるのに必要そうな情報を日本語でまとめました。

pytest ってどんなライブラリ?

pytest は unittest や nose の上位互換なツールです。

unittest や nose から簡単に移行できる

pytest コマンドでは unittest や nose で書いたテストもそのまま実行できます。

移行するにはテスト実行時コマンド(python -m unittestnosetests)を pytest に入れ替えるだけでOK。

pytest test_written_in_unittest.py test_written_nose.py

書き方がシンプル

unittest は xUnit の伝統を踏襲し、

  1. テストをクラスとして定義し、
  2. assertXxx メソッドで検証する

という書き方をしていました。

class FooTest(TestCase):
    def test_function(self):
        self.assertEquals(f(), 4)

pytest の書き方は、より軽量です。

  1. テストは関数として定義し、
  2. 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]です。一緒に働く仲間を募集中です。お気軽にお問い合わせください。

jobs.m3.com


  1. これはダジャレです。

  2. 実際、私は新卒時代に挫折して、unittestに逃げました。

  3. これは pytest の機能ではなく、Python本体の仕様。

  4. 正確な言い方では「fixture object を生成する fixture functionになります」とするべきでしょう。この記事では単に “fixture” と呼ぶことにします。