🐥

pytestに入門したので備忘録的に基本機能をまとめる

2024/12/06に公開

最近pytestを触る機会があったので、n番煎じではありますが備忘録的にまとめます。

pytestとは

Pythonのテストフレームワークです。
現在、Pythonでテストコードを書くといえば、pytestが主流の印象です。

インストール

pipで簡単にインストールすることができます。

$ pip install pytest

Hello pytest

さっそく簡単なテストを書いていきます。
FizzBuzz変換を行うメソッドを持つFizzBuzzクラスをテスト対象として考えてみます。

fizzbuzz.py
class FizzBuzz:

    def convert(self, n):
        """
        引数がint型でない場合にValueErrorを発生させる
        3の倍数の場合は「Fizz」,
        5の倍数の場合は「Buzz」,
        3の倍数かつ5の倍数の場合(すなわち15の倍数の場合)は「Fizz Buzz」,
        それ以外の場合は「n」を返却する
        # 引数がint型でない場合にValueErrorを発生させる
        """    
        if not isinstance(n, int):
            raise ValueError("引数がint型ではありません。")

        if (n % 3 == 0) and (n % 5 == 0):
            return "FizzBuzz"
        elif n % 3 == 0:
            return "Fizz"
        elif n % 5 == 0:
            return "Buzz"
        else:
            return str(n)

これに対して、テストコードは以下のように書けます。

test_fizzbuzz.py
from fizzbuzz import FizzBuzz

def test_fizzbuzz_3の倍数でFizzを返す():
    fizzbuzz = FizzBuzz()
    assert fizzbuzz.convert(3) == "Fizz"

pytestを実行してみます。

$ pytest
============================================================================ test session starts ============================================================================
platform darwin -- Python 3.11.6, pytest-8.3.4, pluggy-1.5.0
rootdir: /pytest-sandbox
collected 1 item                                                                                                                                                            

tests/test_fizzbuzz.py .                                                                                                                                              [100%]

============================================================================= 1 passed in 0.00s =============================================================================

テストコードが実行され、成功か失敗かが出力されます。
なお、pytest実行時、test_で始まるファイル・関数がテストとみなされて実行されます。

プロジェクト構成

テストコードを書く場合のプロジェクト構成は以下のようにします。

.
├── fizzbuzz.py
└── test_fizzbuzz.py

また、上記は小規模なコードに対する場合であり、大体の場合は次のように構成します。

.
├── src
│   ├── __init__.py
│   └── fizzbuzz.py
└── tests
    ├── __init__.py
    └── test_fizzbuzz.py

上記の場合の注意点として、各フォルダには__init__.pyを置くようにしましょう。
テストコードではテスト対象コード(今回ではFizzBuzzクラス)をimportしてテストコード側で呼び出せるようにする必要があります。__init__.pyがないとFizzBuzzクラスを見つけられないため__init__.pyを置きます。(詳しくはテストコードというよりも__init__.pyの話になるので割愛します)

assert

テストコードの基本の基本です。まずはここから始めます。
assert文を使うと、assertの後ろの式がTrueかFalseかを判定してくれます。

assert

先ほどのテストコードも"3の倍数でFizzを返す"という仕様通りの挙動をするかどうかをassert文を使って判定しています。

test_fizzbuzz.py
from fizzbuzz import FizzBuzz

def test_fizzbuzz_3の倍数でFizzを返す():
    fizzbuzz = FizzBuzz()
    assert fizzbuzz.convert(3) == "Fizz" # fizzbuzz.convert(3)の返却値が"Fizz"であるなら成功、"Fizz"でないなら失敗となる

また、assertで気をつけないといけないことに アサーションルーレット(Assertion Roulette) があります。アサーションルーレットはテストにおける有名なアンチパターンで、以下のようなテストコードを言います。

test_fizzbuzz.py
def test_fizzbuzz():
    fizzbuzz = FizzBuzz()
    assert fizzbuzz.convert(3) == "Fizz"
    assert fizzbuzz.convert(5) == "Buzz"
    assert fizzbuzz.convert(15) == "FizzBuzz"
    assert fizzbuzz.convert(7) == "7"

この例はまだマシですが、このように一つのテストケースにassertを複数書いてしまうと、テストが失敗したときにどのassertで失敗したのかが分かりづらくなり、テストの見通しが悪くなります。
基本的に、テストケース一つにつきassertは一つだけ(もしくは必要最低限)書くようにし、テストの観点が複数入り込まないようにしたいです。

pytest.raises

assert文を使うことで、ある値が期待する値になっているかどうかをテストすることができます。これにより多くのテストケースをカバーできるのですが、一方でもう一つのよくあるテストパターンとして、期待する例外が発生したかどうかをテストしたいというのがあります。
今回でいえば、FizzBuzzの仕様として"引数がint型でない場合にValueErrorを発生させる"というものがあるため、これのテストコードを考えます。

test_fizzbuzz.py
import pytest

def test_fizzbuzz_引数がint型でない場合にValueErrorを発生させる():
    fizzbuzz = FizzBuzz()
    with pytest.raises(ValueError) as e:
        fizzbuzz.convert("a")

    assert str(e.value) == "引数がint型ではありません。"

この例ではfizzbuzz.convert("a")で発生するValueErrorの情報をeとしています。e.valueがエラー文言となるため、それが期待する文言と一致しているかどうかを確認しています。
このようにpytest.raisesを使うことで、fizzbuzz("a")の実行時に発生した例外をキャッチしてテストすることができます。

fixture

テストを書いていくとテスト間で重複する部分ができることがあります。
たとえば、今回のFizzBuzzクラスのテストを書いていくと、以下のようにfizzbuzz = FizzBuzz()が重複することになると思います。

test_fizzbuzz.py
def test_fizzbuzz_3の倍数でFizzを返す():
    fizzbuzz = FizzBuzz()
    assert fizzbuzz.convert(3) == "Fizz"


def test_fizzbuzz_5の倍数でBuzzを返す():
    fizzbuzz = FizzBuzz()
    assert fizzbuzz.convert(5) == "Buzz"


def test_fizzbuzz_3の倍数かつ5の倍数の場合でFizzBuzzを返す():
    fizzbuzz = FizzBuzz()
    assert fizzbuzz.convert(15) == "FizzBuzz"


def test_fizzbuzz_それ以外の場合はnを返す():
    fizzbuzz = FizzBuzz()
    assert fizzbuzz.convert(7) == "7"


def test_fizzbuzz_引数がint型でない場合にValueErrorを発生させる():
    fizzbuzz = FizzBuzz()
    with pytest.raises(ValueError) as e:
        fizzbuzz.convert("a")

    assert str(e.value) == "引数がint型ではありません。"

このような場合は、fixtureを使うことで重複部分を切り出して共通化することができます。

test_fizzbuzz.py
@pytest.fixture
def fizzbuzz():
    fizzbuzz = FizzBuzz()
    return fizzbuzz


def test_fizzbuzz_3の倍数でFizzを返す(fizzbuzz):
    assert fizzbuzz.convert(3) == "Fizz"


def test_fizzbuzz_5の倍数でBuzzを返す(fizzbuzz):
    assert fizzbuzz.convert(5) == "Buzz"


def test_fizzbuzz_3の倍数かつ5の倍数の場合でFizzBuzzを返す(fizzbuzz):
    assert fizzbuzz.convert(15) == "FizzBuzz"


def test_fizzbuzz_それ以外の場合はnを返す(fizzbuzz):
    assert fizzbuzz.convert(7) == "7"


def test_fizzbuzz_引数がint型でない場合にValueErrorを発生させる(fizzbuzz):
    with pytest.raises(ValueError) as e:
        fizzbuzz.convert("a")

    assert str(e.value) == "引数がint型ではありません。"

オプション(-s,-v)

最後に簡単にpytest実行時のオプションについても触れておきます。
pytest実行時、個人的によく使うオプションを2つ記載しておきます。

-s: テスト中のprint文を出力する

オプションなしではテスト実行中にprint文は出力されないので重宝します。

$ pytest -s

-v: テスト結果をより詳しく出力する

出力結果の最後に今回実行したテストをまとめて表示してくれるため便利です。

$ pytest -v

参考文献

Discussion