CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: ユニットテストを書いてみよう

ソフトウェアエンジニアにとって、不具合に対抗する最も一般的な方法は自動化されたテストを書くこと。 テストでは、書いたプログラムが誤った振る舞いをしないか確認する。 一口に自動テストといっても、扱うレイヤーによって色々なものがある。 今回は、その中でも最もプリミティブなテストであるユニットテストについて扱う。 ユニットテストでは、関数やクラス、メソッドといった単位の振る舞いについてテストを書いていく。

Python には標準ライブラリとして unittest というパッケージが用意されている。 これは、文字通り Python でユニットテストを書くためのパッケージとなっている。 このエントリでは、最初に unittest パッケージを使ってユニットテストを書く方法について紹介する。 その上で、さらに効率的にテストを記述するためにサードパーティ製のライブラリである pytest を使っていく。

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.14.5
BuildVersion:   18F132
$ python -V        
Python 3.7.3

はじめてのユニットテスト

まずは最も単純な例を使ってユニットテストの書き方を説明していく。

そもそもテストを書くからには、テストする対象が必要になる。 そこで、次のように greet() という関数を用意した。 この関数は、呼び出されると特定の文字列を返すようになっている。

# -*- coding: utf-8 -*-


def greet():
    """挨拶のメッセージを返す関数"""
    return 'Hello, World!'

上記の内容を helloworld.py という名前で保存する。

これで、上記を helloworld モジュールとしてインポートして使えるようになる。

$ python -c "import helloworld; print(helloworld.greet())"
Hello, World!

続いて、上記のモジュールに対応するテストを記述する。 まず、テストを書くには unittest.TestCase クラスを継承したクラスを定義する。 そのクラスの中にテストをメソッドとして記述していく。 テストのメソッドは、必ず名前の先頭が test から始まるようにする。 これは後述するテストランナーが名前を元にテストコードを探すため。 そして、テストではテスト対象から得られる値もしくは状態が期待する内容と一致するかを比較する。 比較する方法として unittest.TestCase クラスには assertEqual()assertTrue() といったメソッドが用意されている。

以下に unittest パッケージを使ったユニットテストのサンプルコードを示す。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import unittest

import helloworld  # テスト対象のモジュールをインポートする


class TestHelloWorld(unittest.TestCase):
    """helloworld モジュールのテストを記述するクラス"""

    def test_greet(self):
        """greet() 関数をテストするメソッド"""
        # テスト対象の関数を呼び出す
        message = helloworld.greet()
        # 関数の返り値が期待した内容と一致するか確認する
        self.assertEqual(message, 'Hello, World!')


if __name__ == '__main__':
    # スクリプトとして実行された場合の処理
    unittest.main(verbosity=2)

上記を test_helloworld.py という名前で保存しよう。 実はこの名前が重要で、後述するテストランナーは test から始まる名前を元にテストコードを探索する。

テストを実行する準備が整ったので、手始めに上記をスクリプトとして実行してみよう。 先ほどのテストコードは、スクリプトとして実行された場合にも unittest.main() 関数が呼ばれるようにしてある。

$ python test_helloworld.py
test_greet (__main__.TestHelloWorld) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

上記から、先ほど記述した test_greet() 関数が実行されて、テストが正しくパスしたことが分かる。

テストはスクリプトとして実行する以外にも、特定のディレクトリ以下から自動的に探して実行する方法もある。 それには、次のように Python のインタプリタで -m unittest として unittest モジュールが実行されるようにする。 その上で discover というコマンドを実行するとカレントディレクトリ以下のテストコードを名前を頼りに自動で探して実行できる。

$ python -m unittest discover -v
test_greet (test_helloworld.TestHelloWorld) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

なお、上記は Python の公式では「テストディスカバリ」という名前の機能として提供されている。 一般的には、テストを探して実行する機能は「テストランナー」と呼ばれる。

もう少し実用的な例を見てみる

先ほどはテストする対象が固定の文字列を返すだけだったので、あまりテストをする意味合いが感じられなかったかもしれない。 続いては、もう少しだけ実用的な例を見ていこう。

以下のサンプルコードでは、有名な FizzBuzz を実装している。 fizzbuzz() 関数では、渡された整数が 3 または 5 で割り切れるかを判定して返す値を切り替える。 3 と 5 の両方で割れるときは 'FizzBuzz' を、3 だけで割れるときは 'Fizz' を、5 だけで割れるときは 'Buzz' を返す。 なお、いずれでも割れないときは単に数字を文字列にして返すこととする。

# -*- coding: utf-8 -*-


def fizzbuzz(n):
    if n % 3 == 0 and n % 5 == 0:
        return 'FizzBuzz'

    if n % 3 == 0:
        return 'Fizz'

    if n % 5 == 0:
        return 'Buzz'

    return str(n)

上記を fizzbuzz.py という名前で保存する。

それでは、先ほどの FizzBuzz が正しく振る舞うかテストコードを書いて確かめてみることにしよう。 要領は先ほどと変わらない。 関数に入力される値と返り値に対して、期待される内容を比較していけば良い。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import unittest

import fizzbuzz


class TestFizzBuzz(unittest.TestCase):

    def test_fizzbuzz(self):
        # 代表的な入力と出力のパターンを列挙する
        expects = {
            1: '1',
            2: '2',
            3: 'Fizz',
            4: '4',
            5: 'Buzz',
            6: 'Fizz',
            7: '7',
            8: '8',
            9: 'Fizz',
            10: 'Buzz',
            11: '11',
            12: 'Fizz',
            13: '13',
            14: '14',
            15: 'FizzBuzz',
            16: '16',
        }
        for n, expect in expects.items():
            # 特定の入力に大して期待される値が返ってくるか確認する
            result = fizzbuzz.fizzbuzz(n)
            self.assertEqual(result, expect)


if __name__ == '__main__':
    unittest.main(verbosity=2)

上記を test_fizzbuzz.py という名前で保存しよう。

先ほどと同じようにテストディスカバリを実行してみよう。 新たに追加されたテストコードが正しくパスすれば上手くいっている。

$ python -m unittest discover -v
test_fizzbuzz (test_fizzbuzz.TestFizzBuzz) ... ok
test_greet (test_helloworld.TestHelloWorld) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

テストしにくい部分をモックと入れ替える

ユニットテストを書いていると、どうしてもテストしにくい部分が出てくる。 典型的な例としては、テスト対象が動作するのに何らかの依存関係があって、それがないと動かないような場合がある。 あるいは、厳密に処理するとあまりにも多くの時間がかかってしまうような場合も考えられる。 そういった場合には、依存している部分をモックと呼ばれる代用部品に入れ替えると良い。

テストしにくい例として以下のサンプルコードを用意した。 このコードの中では do_something() という関数が定義されている。 また、この関数は内部で _take_a_long_time_to_do() という時間のかかる処理を実行している。 なお、実際にやっている処理は最初の greet() 関数と同じで特定の文字列を返すだけとなっている。

# -*- coding: utf-8 -*-

import time


def do_something():
    _take_a_long_time_to_do()
    return 'Hello, World!'


def _take_a_long_time_to_do():
    time.sleep(10)

上記を foobar.py という名前で保存しておこう。

まずは愚直にテストコードを書いてみる

最初は、何も考えずに上記に対応するテストコードを書いてみよう。 やっていることは最初の例と何ら変わらない。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import unittest

import foobar


class TestFooBar(unittest.TestCase):

    def test_do_something(self):
        # do_something() を呼ぶ
        message = foobar.do_something()
        # 返り値を比較する
        self.assertEqual(message, 'Hello, World!')


if __name__ == '__main__':
    unittest.main(verbosity=2)

上記を test_foobar.py という名前で保存しておく。

準備ができたら上記のテストコードを実行してみよう。 このテストが完了するには 10 秒を要する。

$ python test_foobar.py
test_do_something (__main__.TestFooBar) ... ok

----------------------------------------------------------------------
Ran 1 test in 10.001s

OK

内部的に読んでいる関数をモックに置き換えてみる

続いては、テストコードの実行時間を短縮するために内部的に呼んでいる関数をモックに置き換えてみよう。 モックへの置き換えはいくつかのやり方があるものの、今回は @patch デコレータを使うことにする。

なお、標準ライブラリの unittest にモックの機能が入ったのは Python 3 系から。 なので、もし万が一にも 2 系を使っているときは、次のように別途インストールする必要がある。

$ pip install mock

また、インポートするときのパスも unittest.mock ではなく mock に変わる点に注意しよう。

以下のサンプルコードでは foobar モジュールの _take_a_long_time_to_do() 関数をモックに置き換えている。 モックへの置き換えは、テストコードに @patch() デコレータで置き換えたいオブジェクトのパスを指定する。 置き換えられたオブジェクトの振る舞いは、テストコードのメソッドに引数として渡されるモックオブジェクトでカスタマイズできる。 ただし、今回は置き換えるオブジェクトの動作にテスト対象の関数が特に依存していないので特にカスタマイズは必要ない。 本来であれば返り値やプロパティをいじることになる。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import unittest
from unittest.mock import patch

import foobar


class TestFooBar(unittest.TestCase):

    # foobar._take_a_long_time_to_do() をモックに置き換える
    @patch('foobar._take_a_long_time_to_do')
    def test_do_something(self, patched_object):
        # モックに置き換えられた状態で do_something() 関数をテストする
        message = foobar.do_something()
        self.assertEqual(message, 'Hello, World!')
        # モックが呼び出されたことを確認する
        self.assertTrue(patched_object.called)


if __name__ == '__main__':
    unittest.main(verbosity=2)

先ほどと同じようにテストコードを実行してみよう。 今度は 10 秒もかからずにテストが完了する。

$ python test_foobar.py 
test_do_something (__main__.TestFooBar) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

本当に必要な部分をモックに置き換える

先ほどの例では、テスト対象が内部的に呼び出している関数をモックに置き換えることで実行時間を短縮できた。 しかし、実は先ほどのやり方には問題がある。 というのも、モックに置き換えたのがアンダースコアから始まる隠し関数だったため。 これの何が問題かというと、テストコードが実装に依存することを意味している。

テストコードが実装に依存することの最大の問題点は、メンテナンスコストが高くつくこと。 例えば、リファクタリングなどをしただけでもテストが正しくパスしなくなる恐れがある。 そのため、テストコードは外部に公開しているインターフェースに対して記述するのが基本となる。 もし、内部的にしか呼び出されていない関数にテストコードを書いていると感じたなら、それは要注意な状態といえる。 テストを書くのであれば、まずインターフェースは何処なのか、それはどう振る舞うべきなのかを考えた上で書くようにしよう。

例えば先ほどの例であれば、内部的に呼んでいる _take_a_long_time_to_do() 関数よりも time.sleep() 関数をモックに置き換えてしまった方が良いかもしれない。 この先 time.sleep() の挙動が変わるような事態は、ちょっとやそっとでは起こらないだろう。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import unittest
from unittest.mock import patch

import foobar


class TestFooBar(unittest.TestCase):

    # time.sleep() をモックに置き換えてみる
    @patch('time.sleep')
    def test_do_something(self, patched_object):
        message = foobar.do_something()
        self.assertEqual(message, 'Hello, World!')
        self.assertTrue(patched_object.called)


if __name__ == '__main__':
    unittest.main(verbosity=2)

実行結果は先ほどと変わらないけど、変更に対する耐性は先ほどとは段違いなはず。

$ python test_foobar.py 
test_do_something (__main__.TestFooBar) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

標準ライブラリの unittest を使った例は、ここまでで一旦おわりにする。

pytest で、より効率的にテストを書く

ここからは、サードパーティ製のテストフレームワークである pytest について見ていこう。 実際のところ、巷のライブラリなどで標準ライブラリの unittest をそのまま使ってテストを書いている例は少ない。 多くの場合、サードパーティ製のテストフレームワークとして pytest や nose などを使う場合が多い。 その中でも、最近は pytest がデファクトになりつつある。

pytest はサードパーティ製のライブラリなので pip を使ってインストールする必要がある。

$ pip install pytest

実は pytest は unittest と上位互換性がある。 そのため、既存の unittest を使ったプロジェクトにも後から導入しやすい。 試しに pytest のテストランナーで、これまでに書いた unittest のテストコードを実行してみよう。

$ pytest -v         
======================================================================= test session starts ========================================================================
platform darwin -- Python 3.7.3, pytest-4.6.3, py-1.8.0, pluggy-0.12.0 -- /Users/amedama/.virtualenvs/py37/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/amedama/Documents/temporary/ut
collected 3 items                                                                                                                                                  

test_fizzbuzz.py::TestFizzBuzz::test_fizzbuzz PASSED                                                                                                         [ 33%]
test_foobar.py::TestFooBar::test_do_something PASSED                                                                                                         [ 66%]
test_helloworld.py::TestHelloWorld::test_greet PASSED                                                                                                        [100%]

===================================================================== 3 passed in 0.08 seconds =====================================================================

ちゃんとテストが実行できてパスしたことが分かる。

最初の例を pytest 流に書き直してみる

先ほどは unittest で書いたテストコードも pytest から実行できることを示した。 とはいえ、pytest には pytest 流のテストコードの書き方がある。 試しに、最初に書いたテストコードを pytest 流に書き直してみよう。

書き直したサンプルコードが次の通り。 最初の例よりも、だいぶこざっぱりしている。 例えばテストを書くのにクラスを定義する必要はなく、単なる関数で構わない。 また、値を比較するにも専用の関数やメソッドは必要なくて単なる assert 文を使っている。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import pytest

import helloworld


def test_greet():
    """テストコードは単なる関数で良い"""
    message = helloworld.greet()
    # 比較は assert 文を使うだけで良い
    assert message == 'Hello, World!'


if __name__ == '__main__':
    pytest.main(['-v', __file__])

上記を実行してみよう。 こざっぱりした内容でも、ちゃんとテストとして機能していることが分かる。

$ python test_helloworld.py 
======================================================================= test session starts ========================================================================
platform darwin -- Python 3.7.3, pytest-4.6.3, py-1.8.0, pluggy-0.12.0 -- /Users/amedama/.virtualenvs/py37/bin/python
cachedir: .pytest_cache
rootdir: /Users/amedama/Documents/temporary/ut
collected 1 item                                                                                                                                                   

test_helloworld.py::test_greet PASSED                                                                                                                        [100%]

===================================================================== 1 passed in 0.03 seconds =====================================================================

FizzBuzz のテストも書き直してみる

続いては FizzBuzz のテストも書き直してみよう。 こちらは、pytest の parametrize という機能を使うとキレイに書くことができる。

以下が parametrize を使った FizzBuzz のテストコードになる。 この機能ではデコレータを使うことでテストの外側に入力と期待される出力の組を定義できる。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import pytest

import fizzbuzz


# 入力と期待される出力を parametrize で定義する
@pytest.mark.parametrize('n, expect', [
    (1, '1'),
    (2, '2'),
    (3, 'Fizz'),
    (4, '4'),
    (5, 'Buzz'),
    (6, 'Fizz'),
    (7, '7'),
    (8, '8'),
    (9, 'Fizz'),
    (10, 'Buzz'),
    (11, '11'),
    (12, 'Fizz'),
    (13, '13'),
    (14, '14'),
    (15, 'FizzBuzz'),
    (16, '16'),
])
def test_fizzbuzz(n, expect):
    # テストコードがシンプルに保たれる
    assert fizzbuzz.fizzbuzz(n) == expect


if __name__ == '__main__':
    pytest.main(['-v', __file__])

上記を実行すると、各パラメータの組み合わせに応じてテストが走ることが確認できる。

$ python test_fizzbuzz.py 
======================================================================= test session starts ========================================================================
platform darwin -- Python 3.7.3, pytest-4.6.3, py-1.8.0, pluggy-0.12.0 -- /Users/amedama/.virtualenvs/py37/bin/python
cachedir: .pytest_cache
rootdir: /Users/amedama/Documents/temporary/ut
collected 16 items                                                                                                                                                 

test_fizzbuzz.py::test_fizzbuzz[1-1] PASSED                                                                                                                  [  6%]
test_fizzbuzz.py::test_fizzbuzz[2-2] PASSED                                                                                                                  [ 12%]
test_fizzbuzz.py::test_fizzbuzz[3-Fizz] PASSED                                                                                                               [ 18%]
test_fizzbuzz.py::test_fizzbuzz[4-4] PASSED                                                                                                                  [ 25%]
test_fizzbuzz.py::test_fizzbuzz[5-Buzz] PASSED                                                                                                               [ 31%]
test_fizzbuzz.py::test_fizzbuzz[6-Fizz] PASSED                                                                                                               [ 37%]
test_fizzbuzz.py::test_fizzbuzz[7-7] PASSED                                                                                                                  [ 43%]
test_fizzbuzz.py::test_fizzbuzz[8-8] PASSED                                                                                                                  [ 50%]
test_fizzbuzz.py::test_fizzbuzz[9-Fizz] PASSED                                                                                                               [ 56%]
test_fizzbuzz.py::test_fizzbuzz[10-Buzz] PASSED                                                                                                              [ 62%]
test_fizzbuzz.py::test_fizzbuzz[11-11] PASSED                                                                                                                [ 68%]
test_fizzbuzz.py::test_fizzbuzz[12-Fizz] PASSED                                                                                                              [ 75%]
test_fizzbuzz.py::test_fizzbuzz[13-13] PASSED                                                                                                                [ 81%]
test_fizzbuzz.py::test_fizzbuzz[14-14] PASSED                                                                                                                [ 87%]
test_fizzbuzz.py::test_fizzbuzz[15-FizzBuzz] PASSED                                                                                                          [ 93%]
test_fizzbuzz.py::test_fizzbuzz[16-16] PASSED                                                                                                                [100%]

==================================================================== 16 passed in 0.07 seconds =====================================================================

多彩なプラグインを使いこなす

pytest には色々な機能を持ったプラグインが存在することも魅力の一つといえる。

例えばテストを実行するのと一緒に flake8 を実行できる pytest-flake8 は使われることが多い。

$ pip install pytest-flake8

このプラグインを使うと、テストランナーに --flake8 オプションを渡すことで flake8 を実行できるようになる。

$ pytest -v --flake8
======================================================================= test session starts ========================================================================
platform darwin -- Python 3.7.3, pytest-4.6.3, py-1.8.0, pluggy-0.12.0 -- /Users/amedama/.virtualenvs/py37/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/amedama/Documents/temporary/ut
plugins: flake8-1.0.4
collected 24 items                                                                                                                                                 

fizzbuzz.py::FLAKE8 PASSED                                                                                                                                   [  4%]
foobar.py::FLAKE8 PASSED                                                                                                                                     [  8%]
helloworld.py::FLAKE8 PASSED                                                                                                                                 [ 12%]
test_fizzbuzz.py::FLAKE8 PASSED                                                                                                                              [ 16%]
test_fizzbuzz.py::test_fizzbuzz[1-1] PASSED                                                                                                                  [ 20%]
test_fizzbuzz.py::test_fizzbuzz[2-2] PASSED                                                                                                                  [ 25%]
test_fizzbuzz.py::test_fizzbuzz[3-Fizz] PASSED                                                                                                               [ 29%]
test_fizzbuzz.py::test_fizzbuzz[4-4] PASSED                                                                                                                  [ 33%]
test_fizzbuzz.py::test_fizzbuzz[5-Buzz] PASSED                                                                                                               [ 37%]
test_fizzbuzz.py::test_fizzbuzz[6-Fizz] PASSED                                                                                                               [ 41%]
test_fizzbuzz.py::test_fizzbuzz[7-7] PASSED                                                                                                                  [ 45%]
test_fizzbuzz.py::test_fizzbuzz[8-8] PASSED                                                                                                                  [ 50%]
test_fizzbuzz.py::test_fizzbuzz[9-Fizz] PASSED                                                                                                               [ 54%]
test_fizzbuzz.py::test_fizzbuzz[10-Buzz] PASSED                                                                                                              [ 58%]
test_fizzbuzz.py::test_fizzbuzz[11-11] PASSED                                                                                                                [ 62%]
test_fizzbuzz.py::test_fizzbuzz[12-Fizz] PASSED                                                                                                              [ 66%]
test_fizzbuzz.py::test_fizzbuzz[13-13] PASSED                                                                                                                [ 70%]
test_fizzbuzz.py::test_fizzbuzz[14-14] PASSED                                                                                                                [ 75%]
test_fizzbuzz.py::test_fizzbuzz[15-FizzBuzz] PASSED                                                                                                          [ 79%]
test_fizzbuzz.py::test_fizzbuzz[16-16] PASSED                                                                                                                [ 83%]
test_foobar.py::FLAKE8 PASSED                                                                                                                                [ 87%]
test_foobar.py::TestFooBar::test_do_something PASSED                                                                                                         [ 91%]
test_helloworld.py::FLAKE8 PASSED                                                                                                                            [ 95%]
test_helloworld.py::test_greet PASSED                                                                                                                        [100%]

==================================================================== 24 passed in 0.28 seconds =====================================================================

あるいはテストカバレッジを計測するための pytest-cov というプラグインも有名。 これは ptyest と coverage のインテグレーションを提供している。

$ pip install pytest-cov

このプラグインは --cov というオプションをつけることでテストカバレッジの計測ができるようになる。

$ pytest -v --cov=.
======================================================================= test session starts ========================================================================
platform darwin -- Python 3.7.3, pytest-4.6.3, py-1.8.0, pluggy-0.12.0 -- /Users/amedama/.virtualenvs/py37/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/amedama/Documents/temporary/ut
plugins: cov-2.7.1, flake8-1.0.4
collected 18 items                                                                                                                                                 

test_fizzbuzz.py::test_fizzbuzz[1-1] PASSED                                                                                                                  [  5%]
test_fizzbuzz.py::test_fizzbuzz[2-2] PASSED                                                                                                                  [ 11%]
test_fizzbuzz.py::test_fizzbuzz[3-Fizz] PASSED                                                                                                               [ 16%]
test_fizzbuzz.py::test_fizzbuzz[4-4] PASSED                                                                                                                  [ 22%]
test_fizzbuzz.py::test_fizzbuzz[5-Buzz] PASSED                                                                                                               [ 27%]
test_fizzbuzz.py::test_fizzbuzz[6-Fizz] PASSED                                                                                                               [ 33%]
test_fizzbuzz.py::test_fizzbuzz[7-7] PASSED                                                                                                                  [ 38%]
test_fizzbuzz.py::test_fizzbuzz[8-8] PASSED                                                                                                                  [ 44%]
test_fizzbuzz.py::test_fizzbuzz[9-Fizz] PASSED                                                                                                               [ 50%]
test_fizzbuzz.py::test_fizzbuzz[10-Buzz] PASSED                                                                                                              [ 55%]
test_fizzbuzz.py::test_fizzbuzz[11-11] PASSED                                                                                                                [ 61%]
test_fizzbuzz.py::test_fizzbuzz[12-Fizz] PASSED                                                                                                              [ 66%]
test_fizzbuzz.py::test_fizzbuzz[13-13] PASSED                                                                                                                [ 72%]
test_fizzbuzz.py::test_fizzbuzz[14-14] PASSED                                                                                                                [ 77%]
test_fizzbuzz.py::test_fizzbuzz[15-FizzBuzz] PASSED                                                                                                          [ 83%]
test_fizzbuzz.py::test_fizzbuzz[16-16] PASSED                                                                                                                [ 88%]
test_foobar.py::TestFooBar::test_do_something PASSED                                                                                                         [ 94%]
test_helloworld.py::test_greet PASSED                                                                                                                        [100%]

---------- coverage: platform darwin, python 3.7.3-final-0 -----------
Name                 Stmts   Miss  Cover
----------------------------------------
fizzbuzz.py              8      0   100%
foobar.py                6      0   100%
helloworld.py            2      0   100%
test_fizzbuzz.py         6      1    83%
test_foobar.py          10      1    90%
test_helloworld.py       7      1    86%
----------------------------------------
TOTAL                   39      3    92%


==================================================================== 18 passed in 0.13 seconds =====================================================================

一般的な pytest のディレクトリ構成

ところでここまでテスト対象とテストコードを一つのディレクトリに雑然と放り込んできた。 巷のライブラリなどを見ると pytest を使ったプロジェクトでは、次のように tests というディレクトリを専用に用意することが多いように思う。

$ mkdir tests
$ mv test_*.py tests
$ touch tests/__init__.py

テスト対象のモジュール・パッケージについては、tests と同じ階層の別ディレクトリに入れられる場合が多い。

$ mkdir example
$ mv *.py example
$ touch example/__init__.py

ようするに、こんな感じ。 これはつまり example と tests という Python のパッケージを用意していることになる。

$ tree
.
├── example
│   ├── __init__.py
│   ├── fizzbuzz.py
│   ├── foobar.py
│   └── helloworld.py
└── tests
    ├── __init__.py
    ├── test_fizzbuzz.py
    ├── test_foobar.py
    └── test_helloworld.py

2 directories, 8 files

ただ、上記のような変更を加えると、先ほど書いたテストコードは少しだけ修正が必要になる。 というのも helloworldfizzbuzz モジュールが example パッケージ配下に移動しているため。 そこで、次のようにインポート文を修正する。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import pytest

# example 配下にある helloworld モジュールをインポートする
from example import helloworld


def test_greet():
    message = helloworld.greet()
    assert message == 'Hello, World!'


if __name__ == '__main__':
    pytest.main(['-v', __file__])

インポート文を変更したらテストランナーを実行してみよう。 次のように、ちゃんとテストがパスすれば上手くいっている。

$ pytest -v        
======================================================================= test session starts ========================================================================
platform darwin -- Python 3.7.3, pytest-4.6.3, py-1.8.0, pluggy-0.12.0 -- /Users/amedama/.virtualenvs/py37/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/amedama/Documents/temporary/ut
plugins: cov-2.7.1, flake8-1.0.4
collected 18 items                                                                                                                                                 

tests/test_fizzbuzz.py::test_fizzbuzz[1-1] PASSED                                                                                                            [  5%]
tests/test_fizzbuzz.py::test_fizzbuzz[2-2] PASSED                                                                                                            [ 11%]
tests/test_fizzbuzz.py::test_fizzbuzz[3-Fizz] PASSED                                                                                                         [ 16%]
tests/test_fizzbuzz.py::test_fizzbuzz[4-4] PASSED                                                                                                            [ 22%]
tests/test_fizzbuzz.py::test_fizzbuzz[5-Buzz] PASSED                                                                                                         [ 27%]
tests/test_fizzbuzz.py::test_fizzbuzz[6-Fizz] PASSED                                                                                                         [ 33%]
tests/test_fizzbuzz.py::test_fizzbuzz[7-7] PASSED                                                                                                            [ 38%]
tests/test_fizzbuzz.py::test_fizzbuzz[8-8] PASSED                                                                                                            [ 44%]
tests/test_fizzbuzz.py::test_fizzbuzz[9-Fizz] PASSED                                                                                                         [ 50%]
tests/test_fizzbuzz.py::test_fizzbuzz[10-Buzz] PASSED                                                                                                        [ 55%]
tests/test_fizzbuzz.py::test_fizzbuzz[11-11] PASSED                                                                                                          [ 61%]
tests/test_fizzbuzz.py::test_fizzbuzz[12-Fizz] PASSED                                                                                                        [ 66%]
tests/test_fizzbuzz.py::test_fizzbuzz[13-13] PASSED                                                                                                          [ 72%]
tests/test_fizzbuzz.py::test_fizzbuzz[14-14] PASSED                                                                                                          [ 77%]
tests/test_fizzbuzz.py::test_fizzbuzz[15-FizzBuzz] PASSED                                                                                                    [ 83%]
tests/test_fizzbuzz.py::test_fizzbuzz[16-16] PASSED                                                                                                          [ 88%]
tests/test_foobar.py::TestFooBar::test_do_something PASSED                                                                                                   [ 94%]
tests/test_helloworld.py::test_greet PASSED                                                                                                                  [100%]

==================================================================== 18 passed in 0.13 seconds =====================================================================

そんなかんじで。