CLOVER🍀

That was when it all began.

pytestを使って、Pythonのテストコードを書く

これは、なにをしたくて書いたもの?

Pythonでテストコードを書く時には、pytestを使うのが良いという話を聞きまして。1度、自分でも押さえておこうかな、と。

https://docs.pytest.org/en/latest/

以前、unittestで記載した内容のpytest版です。

unittestライブラリで、Pythonのテストコードを書いて実行する - CLOVER🍀

pytestとは

いわゆる、テスティングフレームワークです。

以下が特徴だそうです。

  • アサーションに失敗した時の詳細情報表示
  • テストモジュールおよび関数のオートディスカバリー
  • 小さなテストや、パラメタライズ化された長期間のテストのためのテストリソースを管理する、モジュール化されたfixture
  • unittestおよびnoseのテストケースを実行可能
  • Python 3.5以上、PyPy3上で動作
  • リッチなプラグインアーキテクチャによる、315以上の外部プラグインとコミュニティ

下位互換性や、Python 2.7および3.4に関するサポートについては、こちら。

Backwards Compatibility Policy — pytest documentation

Python 2.7 and 3.4 support — pytest documentation

APIドキュメントについては、こちらです。

API Reference — pytest documentation

まあ、とりあえず使っていってみましょう。

環境

今回の環境は、こちらです。

$ python3 -V
Python 3.6.9


$ pip3 -V
pip 9.0.1 from /path/to/venv/lib/python3.6/site-packages (python 3.6)

インストール

まずは、インストールしましょう。

Installation and Getting Started — pytest documentation

pip3でインストール。

$ pip3 install pytest

今回使用するpytestのバージョンは、5.3.5です。

$ pip3 freeze | grep pytest
pytest==5.3.5

ちなみに、このページにサポートするPythonのバージョンとプラットフォームがハッキリと書かれていますね。

この環境上に、テスト対象およびテストコードを書いていきましょう。

お題とプロジェクト構成

お題とプロジェクト構成も、基本的にはunittestの時に書いたものに合わせます。

unittestライブラリで、Pythonのテストコードを書いて実行する - CLOVER🍀

まずはディレクトリレイアウト。

Choosing a test layout / import rules

テストコードをテスト対象(アプリケーションコード)側に置くスタイルと、そうでないスタイルがあるようですが、今回は
アプリケーションコードとテストコードをハッキリ分けて置きたいと思います。

Tests outside application code

アプリケーションコードを置くディレクトリを「sample」、テストコードを置くディレクトリを「tests」とします。

$ mkdir sample tests

アプリケーションコード側を、1度「src」ディレクトリに置くという話もあるようですが、これはまた別の機会に見てみましょう。

Packaging a python library | ionel's codelog

アプリケーションコードおよび、テストを作成して実行してみる

では、まずアプリケーションコードを作成。
sample/calc.py

class Calc:
    def add(self, x, y):
        return x + y

    def minus(self, x, y):
        return x - y

    def multiply(self, x, y):
        return x * y

    def divide(self, x, y):
        return x / y

続いて、テストコードを書いてみます。
tests/test_calc.py

from sample.calc import Calc

def test_add():
    calc = Calc()
    assert calc.add(1, 3) == 4

def test_minus():
    calc = Calc()
    assert calc.minus(5, 3) == 2

def test_multiply():
    calc = Calc()
    assert calc.multiply(2, 3) == 6

def test_divide():
    calc = Calc()
    assert calc.divide(10, 2) == 5

pytestがどのようにテストコードを検出するかは、こちらに記載があります。

Conventions for Python test discovery

「test*.py」または「*test.py」ファイルを探索し、関数名に「test」が接頭辞として付与された関数およびクラス名に「Test」が
接頭辞として付与されたテスト関数を対象とするようです。

要するに、「test」(クラスの場合は「Test」)を付けてください、と。

また、テストコードを認識してもらうには「init.py」が必要なようなので

Choosing a test layout / import rules

You can use Python3 namespace packages (PEP420) for your application but pytest will still perform test package name discovery based on the presence of init.py files.

作成しておきます。

$ touch tests/__init__.py

では、pytestを実行してみます。

まずはpytestコマンドを引数なしで実行。

$ pytest
========================================================================== test session starts ===========================================================================
platform linux -- Python 3.6.9, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /path/to/pytest
collected 4 items                                                                                                                                                        

tests/test_calc.py ....                                                                                                                                            [100%]

=========================================================================== 4 passed in 0.01s ============================================================================

テストコードを探してきて、実行してくれました。

テストコードを指定。

$ pytest tests/test_calc.py 
========================================================================== test session starts ===========================================================================
platform linux -- Python 3.6.9, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /path/to/pytest
collected 4 items                                                                                                                                                        

tests/test_calc.py ....                                                                                                                                            [100%]

=========================================================================== 4 passed in 0.01s ============================================================================

「-v」オプションを付けて実行すると、もっと詳細に情報を出力してくれます。

$ pytest -v
========================================================================== test session starts ===========================================================================
platform linux -- Python 3.6.9, pytest-5.3.5, py-1.8.1, pluggy-0.13.1 -- /path/to/venv/bin/python3
cachedir: .pytest_cache
rootdir: /path/to/pytest
collected 4 items                                                                                                                                                        

tests/test_calc.py::test_add PASSED                                                                                                                                [ 25%]
tests/test_calc.py::test_minus PASSED                                                                                                                              [ 50%]
tests/test_calc.py::test_multiply PASSED                                                                                                                           [ 75%]
tests/test_calc.py::test_divide PASSED                                                                                                                             [100%]

=========================================================================== 4 passed in 0.01s ============================================================================

ちょっと、テストを1度失敗させてみましょう。

テストコードをこのように変更して

def test_add():
    calc = Calc()
    assert calc.add(1, 2) == 4
#    assert calc.add(1, 3) == 4

実行。

$ pytest
=========================================================================== test session starts ===========================================================================
platform linux -- Python 3.6.9, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /path/to/pytest
collected 4 items                                                                                                                                                         

tests/test_calc.py F...                                                                                                                                             [100%]

================================================================================ FAILURES =================================================================================
________________________________________________________________________________ test_add _________________________________________________________________________________

    def test_add():
        calc = Calc()
>       assert calc.add(1, 2) == 4
E       assert 3 == 4
E        +  where 3 = <bound method Calc.add of <sample.calc.Calc object at 0x7febb711b128>>(1, 2)
E        +    where <bound method Calc.add of <sample.calc.Calc object at 0x7febb711b128>> = <sample.calc.Calc object at 0x7febb711b128>.add

tests/test_calc.py:5: AssertionError
======================================================================= 1 failed, 3 passed in 0.03s =======================================================================

どういう状態で失敗したかがわかるんですね。

>       assert calc.add(1, 2) == 4
E       assert 3 == 4
E        +  where 3 = <bound method Calc.add of <sample.calc.Calc object at 0x7febb711b128>>(1, 2)
E        +    where <bound method Calc.add of <sample.calc.Calc object at 0x7febb711b128>> = <sample.calc.Calc object at 0x7febb711b128>.add

The writing and reporting of assertions in tests — pytest documentation

例外をアサーションしたい場合は、こちら。

Assertions about expected exceptions

確認できたので、テストコードは失敗しないように戻しておきます。

前処理・後処理を入れる

よくある、テストの実行前後に前処理・後処理を入れてみたいと思います。

Fixtureを使うみたいです。

Fixture finalization / executing teardown code

先ほど作ったテストコードをベースにして

$ cp tests/test_calc.py tests/test_calc_seup_teardown.py

こんな感じに作成。
tests/test_calc_seup_teardown.py

from sample.calc import Calc

import pytest

@pytest.fixture
def setup_and_teardown():
    print("Setup.")
    yield
    print("Teardown.")

def test_add(setup_and_teardown):
    calc = Calc()
    print("call add")
    assert calc.add(1, 3) == 4

def test_minus(setup_and_teardown):
    calc = Calc()
    print("call minus")
    assert calc.minus(5, 3) == 2

def test_multiply():
    calc = Calc()
    print("call multiply")
    assert calc.multiply(2, 3) == 6

def test_divide():
    calc = Calc()
    print("call divide")
    assert calc.divide(10, 2) == 5

@pytest.fixtureでデコレートした関数を定義して

@pytest.fixture
def setup_and_teardown():
    print("Setup.")
    yield
    print("Teardown.")

前後処理を挟み込みたいテスト関数の引数に設定します。

def test_add(setup_and_teardown):
    calc = Calc()
    print("call add")
    assert calc.add(1, 3) == 4

def test_minus(setup_and_teardown):
    calc = Calc()
    print("call minus")
    assert calc.minus(5, 3) == 2

確認。

$ pytest tests/test_calc_seup_teardown.py 
=========================================================================== test session starts ===========================================================================
platform linux -- Python 3.6.9, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /path/to/pytest
collected 4 items                                                                                                                                                         

tests/test_calc_seup_teardown.py ....                                                                                                                               [100%]

============================================================================ 4 passed in 0.01s ============================================================================

どうなったか、まったくわかりません。

これは、今回printでデバッグ的に確認しようとしているのですが、stdin/stdoutがpytestでキャプチャされているからです。

Capturing of the stdout/stderr output — pytest documentation

今回の確認目的では、キャプチャをオフにすればprintの様子を観ることができます。

$ pytest --capture=no tests/test_calc_seup_teardown.py

## もしくは
$ pytest -s tests/test_calc_seup_teardown.py
=========================================================================== test session starts ===========================================================================
platform linux -- Python 3.6.9, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /path/to/pytest
collected 4 items                                                                                                                                                         

tests/test_calc_seup_teardown.py Setup.
call add
.Teardown.
Setup.
call minus
.Teardown.
call multiply
.call divide
.

============================================================================ 4 passed in 0.01s ============================================================================

テスト関数の引数にFixtureを設定した、add、minusに関してはSetupとTeardownのメッセージが見えていますね。

ちなみに、キャプチャをオフにしなくてもテストに失敗した場合は

def test_add(setup_and_teardown):
    calc = Calc()
    print("call add")
    assert calc.add(1, 4) == 4

その関数でキャプチャされた情報などが出力されます。

$ pytest tests/test_calc_seup_teardown.py 
=========================================================================== test session starts ===========================================================================
platform linux -- Python 3.6.9, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /path/to/pytest
collected 4 items                                                                                                                                                         

tests/test_calc_seup_teardown.py F...                                                                                                                               [100%]

================================================================================ FAILURES =================================================================================
________________________________________________________________________________ test_add _________________________________________________________________________________

setup_and_teardown = None

    def test_add(setup_and_teardown):
        calc = Calc()
        print("call add")
>       assert calc.add(1, 4) == 4
E       assert 5 == 4
E        +  where 5 = <bound method Calc.add of <sample.calc.Calc object at 0x7fddc8d5fcc0>>(1, 4)
E        +    where <bound method Calc.add of <sample.calc.Calc object at 0x7fddc8d5fcc0>> = <sample.calc.Calc object at 0x7fddc8d5fcc0>.add

tests/test_calc_seup_teardown.py:14: AssertionError
-------------------------------------------------------------------------- Captured stdout setup --------------------------------------------------------------------------
Setup.
-------------------------------------------------------------------------- Captured stdout call ---------------------------------------------------------------------------
call add
------------------------------------------------------------------------ Captured stdout teardown -------------------------------------------------------------------------
Teardown.
======================================================================= 1 failed, 3 passed in 0.03s =======================================================================

また、Fixtureを使うことでテスト関数の引数を設定したり、テスト間でデータを共有できたりします。

Fixtures as Function arguments

Sharing test data

このあたりも、覚えておきましょう。

まとめ

Pythonのテスティングフレームワーク、pytestを試してみました。

とりあえず、初歩的なことは少しは押さえられたのではないでしょうか。これから、使っていってみましょう。