Python
pytest

pytestに入門してみたメモ

pytestとは?

Pythonで書いたプログラムをテストするためのフレームワーク。

The pytest framework makes it easy to write small tests, yet scales to support complex functional testing for applications and libraries.

(参照: https://docs.pytest.org/en/latest/)

まず何から始めればいい?

pipか何かでpytestを入れましょう。

terminal
$ pip install pytest

次にこういうプログラムを書いてみましょう。

test_app.py
#!/usr/bin/python
# -*- coding: utf-8 -*-

def test_1():
    a = 1
    b = 2
    assert a == b

def test_2():
    a = 1
    b = 2
    assert a != b

スクリプトのtestで始まる名前の関数が自動的にテスト対象になります。
各関数の中で、成立すべき式(Trueになるべき式)をassert文で記述します。

そして、引数にスクリプトファイル名を指定してpytestコマンドを実行します。引数を省略すると、カレントディレクトリにあるtest_*.pyという名前のスクリプトが自動的にテスト対象になります。

terminal
$ pytest test_app.py
======================================= test session starts ========================================
platform cygwin -- Python 2.7.14, pytest-3.3.2, py-1.5.2, pluggy-0.6.0
rootdir: /cygdrive/c/Users/foo/bar/pytest, inifile:
collected 2 items

test_app.py F.                                                                               [100%]

============================================= FAILURES =============================================
______________________________________________ test_1 ______________________________________________

    def test_1():
        a = 1
        b = 2
>       assert a == b
E       assert 1 == 2

test_app.py:7: AssertionError
================================ 1 failed, 1 passed in 0.19 seconds ================================

test_1が失敗したことがわかりますね。
しかも、左辺値と右辺値を表示してくれます。上のプログラムだと直前で定数を代入しているので自明ですが、いろいろ処理した後の比較だと、何がどうなって失敗しているのかわからなくなりますよね。

自分が使ってみたい機能いろいろ

1時間くらいドキュメントなり先人たちの資料なりを読んだ範囲で、自分が直近で使いそうだな、使えたら便利だなと思った機能を4つだけまとめます。

処理時間を測定したい

テストごとに処理時間を測ってくれる機能があるので、デグレ(今まで動いていたプログラムが動かなくなる)してないか確認しながら処理の高速化を目指すことができそうです。

terminal
$ pytest --durations=0 test_app.py
======================================= test session starts ========================================
platform cygwin -- Python 2.7.14, pytest-3.3.2, py-1.5.2, pluggy-0.6.0
rootdir: /cygdrive/c/Users/foo/bar/pytest, inifile:
collected 2 items

test_app.py F.                                                                               [100%]

====================================== slowest test durations ======================================
0.00s call     test_app.py::test_1
0.00s setup    test_app.py::test_1
0.00s teardown test_app.py::test_1
0.00s setup    test_app.py::test_2
0.00s call     test_app.py::test_2
0.00s teardown test_app.py::test_2
============================================= FAILURES =============================================
(以下略)

callの数字が実際の処理の秒数です。さすがにこの中身だと0.00sになっちゃいますが…。
setup, teardownは、それぞれ前処理と後処理を表します(この後で出ます)。
ちなみにdurations=の後の数字を1以上にすると、処理時間が長かった方からN個分だけ表示してくれます。

前処理と後処理をさせたい

何かのライブラリやフレームワークを使って処理する場合など、事前に前処理・初期化が必要で、かつ終わったら後処理しないといけない、というケースがあると思います。
初期化すると何かオブジェクトが生成されて、それを使って処理をする、というケースが多いですよね。
例えばこういう感じで。

test_app2.py
#!/usr/bin/python
# -*- coding: utf-8 -*-

import StringIO

def test_1():
    sio = StringIO.StringIO("12345")
    assert sio.getvalue() == "2345"
    sio.close()
    print "closed!"

但しこの場合、テストが失敗するとcloseが呼ばれないようです。closed!が出ずに終わります。
少なくともこういう時にはwith文とcontextlib.closing()を使ったり、try, finallyを使ったりするのが正当かと思いますが、それもテストがtest_1しかない場合の話であって、実際問題だとこういうケースが多いでしょう。

  • いろんなテストがあって、テストごとにオブジェクトを作るのではなく、最初に一度だけ作ってテスト間で使い回したい
  • 全部のテストが終わったら後始末したい

こういうときのために「fixture」ってのがあります。
同じことを「funcarg」というものでやっている資料もよく見かけますが、もう古いらしいです→参照。今から覚えるならfixtureで行きましょう。

test_app3.py
#!/usr/bin/python
# -*- coding: utf-8 -*-

import pytest
import time
import StringIO

@pytest.fixture(scope="module", autouse=True)
def sio_aaa():
    sio = StringIO.StringIO("12345")
    time.sleep(1)
    print "created!"
    yield sio
    time.sleep(1)
    sio.close()
    print "closed!"

def test_1(sio_aaa):
    assert sio_aaa.getvalue() == "2345"

def test_2(sio_aaa):
    assert sio_aaa.getvalue() == "3456"

前処理と後処理の実行タイミングをわかりやすくするため、あえてsleepを入れて時間が掛かるようにしてみました。

ここでのポイントは

  • 1つの関数内に前処理と後処理を定義し、両者をyieldで挟む。作成したオブジェクトをyield文に渡す。
  • 前処理と後処理を定義した関数に @pytest.fixture デコレータをつける。
  • テストケースを定義する関数に、先ほどの関数名と同名の引数を追加しておくと、そのオブジェクトが渡ってくる。

という感じです。scopeには、初期化・後処理がどの単位(テスト関数ごととか、スクリプトファイルごととか)で行われるかを指定します。何が指定できるかについては、こちらのページなどを見るとわかりやすいです。

terminal
$ pytest --durations=0 test_app3.py
======================================= test session starts ========================================
platform cygwin -- Python 2.7.14, pytest-3.3.2, py-1.5.2, pluggy-0.6.0
rootdir: /cygdrive/c/Users/foo/bar/pytest, inifile:
collected 2 items

test_app3.py FF                                                                              [100%]

====================================== slowest test durations ======================================
1.01s setup    test_app3.py::test_1
1.00s teardown test_app3.py::test_2
0.01s call     test_app3.py::test_1
0.00s call     test_app3.py::test_2
0.00s setup    test_app3.py::test_2
0.00s teardown test_app3.py::test_1
============================================= FAILURES =============================================
______________________________________________ test_1 ______________________________________________

sio_aaa = <StringIO.StringIO instance at 0x6ffff16b248>

    def test_1(sio_aaa):
>       assert sio_aaa.getvalue() == "2345"
E       AssertionError: assert '12345' == '2345'
E         - 12345
E         ? -
E         + 2345

test_app3.py:19: AssertionError
-------------------------------------- Captured stdout setup ---------------------------------------
created!
______________________________________________ test_2 ______________________________________________

sio_aaa = <StringIO.StringIO instance at 0x6ffff16b248>

    def test_2(sio_aaa):
>       assert sio_aaa.getvalue() == "3456"
E       AssertionError: assert '12345' == '3456'
E         - 12345
E         + 3456

test_app3.py:22: AssertionError
------------------------------------- Captured stdout teardown -------------------------------------
closed!
===================================== 2 failed in 2.25 seconds =====================================
  • test_1, test_2で、sio_aaaにオブジェクトが渡されて使えている
  • test_1が実行される時にだけcreated!が表示され、test_2が実行された時にだけclosed!が表示される(デフォルトだと、失敗したテストの時にprintされたものしか画面に出ないっぽい?)
  • 処理時間が1秒掛かっているのはtest_1のsetup(前処理)とtest_2のteardown(後処理)のみ
  • sio_aaa =で始まる行が2つあるが、そこに表示されたインスタンスのアドレス (0x6ffff16b248) が同じになっている

複数のテストケースを試したい

同じ関数に、いろいろな値の組み合わせを入れてテストしたいという場合も多いと思います。

test_app4.py
#!/usr/bin/python
# -*- coding: utf-8 -*-

import pytest

@pytest.mark.parametrize(
    "x, y", [
        ("aaa", "bbb"),
        ("aaa", "aaa"),
        ("bbb", "bbb")
    ]
)
def test_1(x, y):
    assert x == y

今度はtest_で始まる関数の方にデコレータ@pytest.mark.parametrizeが付きます。

  • 引数リストのカンマ区切り文字列と、対応する値のタプルのリストを記述する
  • test_1に同名の引数を書いておくと、指定した値が渡されて実行される

今回の場合、test_1

  • x == "aaa", y == "bbb"
  • x == "aaa", y == "aaa"
  • x == "bbb", y == "bbb"

と、都合3回実行されるということになります。

terminal
$ pytest test_app4.py
======================================= test session starts ========================================
platform cygwin -- Python 2.7.14, pytest-3.3.2, py-1.5.2, pluggy-0.6.0
rootdir: /cygdrive/c/Users/foo/bar/pytest, inifile:
collected 3 items

test_app4.py F..                                                                             [100%]

============================================= FAILURES =============================================
_________________________________________ test_1[aaa-bbb] __________________________________________

x = 'aaa', y = 'bbb'

    @pytest.mark.parametrize(
        "x, y", [
            ("aaa", "bbb"),
            ("aaa", "aaa"),
            ("bbb", "bbb")
        ]
    )
    def test_1(x, y):
>       assert x == y
E       AssertionError: assert 'aaa' == 'bbb'
E         - aaa
E         + bbb

test_app4.py:14: AssertionError
================================ 1 failed, 2 passed in 0.20 seconds ================================

ご覧のように、どのテストケースで失敗したか教えてくれます。
ここでは紹介しませんが、例によって --durations=0 を付けてコマンドを実行してやると、テストケースごとに掛かった時間がちゃんと出てきます。

特定のグループの処理だけを実行したい

  • 小さいテストデータと大きいテストデータがあって、小さいテストデータだけ試したい
  • 処理の種類がいくつかあって、特定の種類の処理に関するテストだけ実行したい

とかあると思います。
今回は、軽めのテストに「small」、重めのテストに「large」という名前を付けてみます。

test_app5.py
#!/usr/bin/python
# -*- coding: utf-8 -*-

import pytest
import time

@pytest.mark.small
def test_1():
    time.sleep(0.1)
    assert "aaa" == "bbb"

@pytest.mark.small
def test_2():
    time.sleep(0.1)
    assert "bbb" == "bbb"

@pytest.mark.large
def test_3():
    time.sleep(10)
    assert "aaa" == "bbb"

テストごとに @pytest.mark.xxxx というデコレータを付けます。このxxxxに具体的な名前を付けます。
そして軽めのテストだけやりたかったらこうです。

terminal
$ pytest -m small test_app5.py
======================================= test session starts ========================================
platform cygwin -- Python 2.7.14, pytest-3.3.2, py-1.5.2, pluggy-0.6.0
rootdir: /cygdrive/c/Users/foo/bar/pytest, inifile:
collected 3 items

test_app5.py F.                                                                              [100%]

============================================= FAILURES =============================================
______________________________________________ test_1 ______________________________________________

    @pytest.mark.small
    def test_1():
        time.sleep(0.1)
>       assert "aaa" == "bbb"
E       AssertionError: assert 'aaa' == 'bbb'
E         - aaa
E         + bbb

test_app5.py:10: AssertionError
======================================== 1 tests deselected ========================================
========================= 1 failed, 1 passed, 1 deselected in 0.51 seconds =========================

-m small という引数を付けてpytestを実行することで、@pytest.mark.smallが付いているテストだけが実行されます。つまり、test_3は除外されて実行されず、そのことが1 tests deselectedに表れています(1 test s …)。
-mを付けないと、今まで通り全部のテストが実行されます。

最後に

terminal
$ pytest

結果は長くなるうえに今までの繰り返しになるだけなので省略。

あとは使いながら調べて覚えていけばいいかなと思っています。ドキュメントもありますし。英語ですけど。
前処理と後処理はconftest.pyに書けとか、docstringをちゃんと書けとか、いろいろお作法的なものもあるのでしょうが、それはそれとして…。