見出し画像

pytestを利用して効率的に単体テストを書く

はじめに


はじめまして。 株式会社SHIFT DAAE(ダーエ)テクノロジーグループの佐久間です。最近は主にバックエンドの開発を担当しております。

私は以前、pythonで開発を行う場合に、単体テストを書く際はunittestをメインで利用していました。しかし、最近はよくpytestを利用しています。pytestを初めて利用した際に、unittestと比較してそのテストの書きやすさに感心した覚えがあります。

今回の記事では他のpythonの単体テストツールとの比較も踏まえながら、pytestの使い方を見ていきたいと思います。


Pythonの単体テスト用ツール比較


Pythonの単体テスト用ツールといえば「unittest」「pytest」「doctest」がよく挙げられます。まずはこれらの単体テストツールを簡単に比較していきます。

unittest

unittestは、Java用の単体テストフレームワークであるJUnitをベースとした単体テストフレームワークです。他の言語の主要な単体テストフレームワークと同じような書き味になります。

import unittest

class TestPlus(unittest.TestCase):
    def test_plus(self):
        self.assertEqual(plus(1,2), 3)

unittestはpythonの標準ライブラリに含まれているため、特にサードパーティーのツールを導入せず利用することができます。

pytest

pytestは軽量な構文でテストを書くことができるフレームワークです。 比較的簡単に使うことができ、複雑なテストにスケーリングすることもできます。

import pytest

def test_plus():
    assert plus(1,2) == 3

pytestはpythonの標準ライブラリに含まれないため、python本体とは別に導入する必要があります。

doctest

doctestは、unittestやpytestとは毛並みが異なり、docstringとして記述するドキュメントの中に対話型Pythonセッションのようなテストを書くことで、関数がそこに書かれている通りに振る舞うかをチェックします。

def plus(a, b):
    """ aとbの値の合計値を返します。

    >>> plus(1, 2)
    3
    """
    return a + b

if __name__=="__main__":
    import doctest
    doctest.testmod()

上記のようにdocstringに関数の使用例を記載しつつ、その振る舞いが期待通りかの検証まで行うことができ、ドキュメントとしての側面と、テストとしての側面の双方の役割を持たせることができます。

pytestとunittestの比較

pytestもunittestは異なった特徴を持ち、それぞれにメリット/デメリットがあります。(doctestについてはその利用方法がかなり異なるため、ここでは比較していません。) pytestとunittestを比較した際のpytestのメリット/デメリットとしては以下の点が挙げられます。

  • Pros

    • unittestと比較し、簡潔な文法でテストを記述することができる

    • unittestと比較し、テストエラー時の結果が分かりやすい

    • サードパーティーのツールが充実している

    • pytestでunittestを動かすこともできる

  • Cons

    • fixture周りで少々独特な書き方をする部分があり、初学者には少々取り組みづらい可能性がある

    • Pythonの標準ライブラリではなく、本体とは別に導入する必要がある

採用するプロジェクトの性質にもよりますが、pytestを選択することで得られるメリットは大きいと感じています。 特段の理由がない限り、個人的にはpytestの利用をお勧めします。

pytestの基本的な使い方


ここからはpytestの基本的な使い方を見ていきます。

テストコードの書き方

pytestでは、テスト用の関数(またはクラス)を作成することで単体テストを定義していきます。 テスト用の関数には、関数名の先頭にtestと付けます。また、クラスを宣言する場合にはクラス名の先頭にTestと付けます。 また、テスト用の関数の中では、検証したい項目をassert文で記載します。

test_plus.py

import pytest

def plus(a,b): # テスト対象の関数
    return a + b

def test_plus(): # 関数名の先頭にtestをつける
    assert plus(1,2) == 3

class TestPlus(): # クラスを宣言する場合は先頭にTestをつける
    def test_plus(self):
        assert plus(3,4) == 7

テストを実行する際は、pytestコマンドを実行します。

pytest

pytestはカレントディレクトリ(もし設定ファイルでtestpathsが指定されている場合はそのディレクトリ)とその配下のディレクトリを探索し、test_*.py, *_test.pyというファイル名を探し出します。
その中で「testとプレフィクスされたテスト関数」と、「Testとプレフィクスされたクラス内のtestとプレフィクスされたテストメソッド」を実行します。

また、以下のようにファイルを指定してテストを実行することも可能です。

pytest test_plus.py

また、関数単位、クラス単位でテストを実行することも可能です。

# 関数単位の実行
pytest test_plus.py::test_plus

# クラス単位の実行::TestPlus
pytest test_plus.py::TestPlus

# クラス内のメソッド単位の実行
pytest test_plus.py::TestPlus::test_plus

テスト実行時に、もし差分が発生した場合、pytestはassert文の中身を解析し自動で分かりやすく差分を表示してくれます。

def test_fail():
    assert plus(1,2) == 2

>>>    def test_fail(): # 失敗する
>>> >       assert plus(1,2) == 2
>>> E    assert 3 == 2
>>> E     +  where 3 = plus(1, 2)
>>> 
>>> test_plus.py:5: AssertionError

このように、pytestは比較的簡単にテストを書くことができます。

pytestの便利な機能


pytestには便利な機能が備わっており、それらを利用することで効率的にテストを実施することができます。今回はその中でも特に押さえておきたい機能であるfixtureとparametrizeをご紹介します。

fixture

一般的にソフトウェアテストにおけるフィクスチャは、テストの実行に必要なシステムのセットアップやテストデータの準備を行うものを指します。
これを適切に用意することで、毎回テスト前に同じ状態を再現することができるため、テストを繰り返し実行しても同じ結果を得ることができるようになります。
(非常にざっくりとした理解としては、「テストの前(後)に実行する処理」と考えることができます。) pytestではfixtureの機能を利用することで、このようなテストの前後処理を効率的に管理することができます。

fixtureの作成/利用

pytestでのfixtureは、データの初期化やリソースの作成といったテストに必要な状態や環境を整えるための、テスト前後の処理を実施するための関数です。

pytestでは、@pytest.fixtureデコレータを付けた関数を定義することで、fixtureを作成することができます。@pytest.fixtureを付けた関数には、テストの実行前/実行後に実施するセットアップ処理/ティアダウン処理を定義します。 そして、テスト用の関数の引数にこのフィクスチャを指定することで、テストとfixtureを紐づけることができます。fixtureに紐づいたテスト用の関数を実行すると、その関数の実行前後にfixtureの処理が実行されます。 以下では、一時ディレクトリを作成するフィクスチャの例を示します。

import pytest
from pathlib import Path
import shutil

def create_file(path):
    # 指定されたパスにファイルを作成する関数
    path.touch()

# 一時ディレクトリを作成するフィクスチャ
@pytest.fixture()
def create_tmp_dir():
    # 一時ディレクトリを作成
    tmp_dir = Path("/tmp/test")
    if not tmp_dir.exists():
        tmp_dir.mkdir()
    yield tmp_dir
    # 一時ディレクトリを削除
    shutil.rmtree(tmp_dir)
    return

def test_create_file(create_tmp_dir):
    target_file = create_tmp_dir/"test.txt"
    create_file(target_file)
    assert target_file.exists()

こちらのテストを実行すると、test_create_fileテスト関数の実行前に、一時ディレクトリを作成するcreate_tmp_dirフィクスチャの処理が実行され、その結果作成されたディレクトリのパスがtest_item関数に引数として引き渡されます。

また、上記の例ではfixture内でreturnではなくyieldを用いてtmp_dirを返していますが、このようにyieldを用いることで、テスト前の処理とテスト後の処理を1つのフィクスチャで設定することができます。 イメージとしては以下の順で処理が実行されています。

fixtureのyieldまでの処理
↓
テスト本体
↓
fixtureのyield後、returnまでの処理

これにより、テスト前のセットアップ処理だけでなく、テスト後のティアダウン処理もあわせてセットで管理することができます。

もし、fixtureから渡される値を利用しない場合は、フィクスチャと紐づけたい関数を宣言する際に@pytest.mark.usefixturesデコレータを利用することで、使用するfixtureを紐づけることもできます。

# こちらの実装例はイメージのため動きません。
import pytest

@pytest.fixture()
def connect():
    connect_db()
    yield

@pytest.mark.usefixtures("connect")
def test_fetch():
    assert fetch() == "test"

@pytest.mark.usefixtures("connect") # クラスにつけた場合は、クラス内のメソッドすべてに適用される
class TestDB:
    def test_fetch(self):
        assert fetch() == "test"

また、モジュール内のすべてのテストに対してフィクスチャを適用したい場合は、fixtureの定義時、autouse引数にTrueを設定します。

# こちらの実装例はイメージのため動きません。
import pytest

@pytest.fixture(autouse=True)
def connect():
    connect_db()
    yield


def test_fetch():
    assert fetch() == "test"

上記の例では、connectフィクスチャにautouse=Trueを設定することで、すべてのテスト関数に対し本フィクスチャが適用されるようになるため、test_fetch関数実行前にconnectフィクスチャが実行されます。

fixtureは少々動作が直感的ではないため、最初は少し戸惑ってしまう可能性がありますが、使い慣れてくると非常に頼りになります。

conftest.pyを利用した複数ファイルでのfixtureの利用

先ほどまでの説明では、fixtureをテストと同じファイルに記載していましたが、conftest.pyを利用することで、fixtureを別のファイルに切り出し、複数のモジュールで同じfixtureを使いまわすことができるようになります。 複数のネストされたディレクトリで構成されたテストの場合、あるテストファイルは同じディレクトリか、親ディレクトリに配置されたconftest.pyにおいて定義されたfixtureを利用することができます。

project/
 └src
    ├ conftest.py 
    ├ test_one.py : `project/src/conftest.py`に定義されたフィクスチャを使用可能
    └ tests/
        ├ conftest.py 
        └ test_two.py `project/src/conftest.py``project/src/tests/conftest.py`に定義されたフィクスチャを利用可能

こちらを応用すれば、例えばconftest.pyにautouse=Trueを設定したfixtureを設定することで、テスト用のディレクトリ内で定義されたすべてのテストの実行前にテスト前処理を実施する、といったことも可能です。

ビルトイン、サードパーティーのfixtureの利用

fixtureは自分で作成するだけではなく、ビルトインのfixtureや、サードパーティのfixtureも用意されています。これらを利用することで、より効率的にテストを行うことができます。 ここでは私が普段よく利用しているフィクスチャを簡単にいくつか紹介します。

tmp_path

ビルトインのfixtureです。 tmp_pathは一時ディレクトリを作成し、そのpathlib.Pathオブジェクトを返します。一時ディレクトリはテスト関数ごとに個別に作成されるため、お互いに干渉しません。 ファイルの操作が発生する関数のテストで重宝します。

import pytest 

def create_file(path):
    path.touch()

def test_create_file(tmp_path):
    target = tmp_path/"test.txt"
    create_file(target)
    assert target.exists()

caplog

ビルトインのfixtureです。 テスト中にログの出力をコントロールしたり、キャプチャしたりすることができます。

import pytest
import logging
from logging import INFO  

def output_info():
    logging.info("test")

def test_output_info(caplog):
    # ログの出力レベルをINFOに設定
    caplog.set_level(INFO)
    output_info()
    # 出力されたログがcaplog.record_tuplesに格納されている
    assert ("root", INFO, "test") in caplog.record_tuples

freezer

サードパーティー製のfixtureです。利用するためにはpytest-freezerの導入が必要になります。 現在時間を固定する機能を持ち、現在時刻を参照するような機能のテストを実行する際に重宝します。

import pytest
from datetime import datetime

def test_freezer(freezer):
    freezer.move_to("2024-01-01 12:00:00") # 現在時刻を2024年1月1日の12時に固定する
    assert datetime.now() == datetime(2024,1,1,12,0,0)
    freezer.tick() # 時間を1秒進める
    assert datetime.now() == datetime(2024,1,1,12,0,1)

mocker

サードパーティー製のfixtureです。利用するためにはpytest-mockの導入が必要になります。 mockerはpytestにおいてモックやスタブなどのテストダブルを利用するためのfixtureです。内部的にはunittest.mockをラップしたものであるため、こちらと同じように使用することができます。 以下はrequestsモジュールのgetメソッドをパッチし、必ず一定の値を返すようにした例です。

req.py

import requests

def get_search_result():
    ret = requests.get("https://localhost/some_page")
    return ret.json_data

test_req.py

import pytest
from req import get_search_result

class MockResponse:
    def __init__(self):
        self.json_data = {"test": "test1"}
        self.status_code = 200

def test_get_search_result(mocker):
    # requests.getが毎回同じ結果を返すようにパッチ
    mocker.patch("req.requests.get", return_value = MockResponse())
    assert get_search_result() == {"test":"test1"}

unittest.mockをそのまま利用するのに比べると機能は少々制限されていますが、通常の使用の範囲であれば問題なく使えます。 (今回の例ではmockerを用いてrequestsモジュールをモックしましたが、requests-mockというサードパーティー製のfixtureも存在するため、場合によってはこちらの利用を検討してもよさそうです。)

上記で紹介したもの以外にも様々なfixtureが提供されているため、何か困ったときはとりあえず探してみるのも一つの手かもしれません。

@pytest.mark.parametrizeを用いたパラメタ化

@pytest.mark.parametrizeを用いることでテスト関数の引数をパラメタ化し、複数のパラメータで同じような内容のテストを複数実行することができます。 使い方は、テスト関数に@pytest.mark.parametrizeデコレータを付与し、デコレータの引数としてパラメータの内容を定義します。加えて、テスト関数でデコレータで指定したパラメータ名をもつ引数を設定することで、テスト時にはその引数にパラメータが設定されるようになります。

import pytest

def plus(a,b):
    return a + b

@pytest.mark.parametrize(
    ["input_a", "input_b", "expected"],
    [
        (1, 2, 3),
        (2, 3, 5),
        (10, 20, 31)
    ]
)
def test_plus(input_a, input_b, expected):
    assert plus(input_a, input_b) == expected

>>> collected 3 items 
>>>
>>> test_eight.py ..F
>>> (中略)
>>> input_a = 10, input_b = 20, expected = 31
>>> (中略)
>>> def test_plus(input_a, input_b, expected):
>>>       assert plus(input_a, input_b) == expected
>>> E    assert 30 == 31
>>> E     +  where 30 = plus(10, 20)

今回の例では3つのパラメータのセットを定義しています。 例示したコードを実行すると、3つのテストケースが実行され、最後の1つが失敗します。出力された結果からもその内容を読み取ることができます。

また、各パラメータに対してidを割り振ることで、テストケースに名前を割り振ることも可能です。これにより、失敗したテストケースをより明確に確認することができます。

import pytest

def plus(a,b):
    return a + b

@pytest.mark.parametrize(
    ["input_a", "input_b", "expected"],
    [
        pytest.param(1, 2, 3, id="pass1"),
        pytest.param(2, 3, 5, id="pass2"),
        pytest.param(10, 20, 31, id="failure"),
    ]
)
def test_plus(input_a, input_b, expected):
    assert plus(input_a, input_b) == expected

# テストケースに設定したIDが出力されている
>>> FAILED test_nine.py::test_plus[failure] - assert 30 == 31

加えて、fixtureにパラメータを渡したい場合にもparametrizeを利用することができます。 fixtureにパラメータを引き渡したい場合はparametrizeに追加でindirect引数を追加し、引数を引き渡すfixtureの名前を指定します(一括で引き渡す場合はTrueを指定してもOKです)。 パラメータを受け取るfixture側では、引数としてrequestを設定します。引き渡されたパラメータを取得する際は、requestのparam属性にアクセスします。

import pytest
from dataclasses import dataclass

@dataclass
class Person:
    name: str

@pytest.fixture
def prepare_person(request): # requestという名前の引数をとる
    return Person(request.param) # パラメータにはrequest.paramでアクセスする

@pytest.mark.parametrize(
    ("prepare_person", "expected"),
    [("Bob", "Bob"),("Alice", "Alice")],
    indirect=["prepare_person"] # fixtureの名前を指定
)
def test_person(prepare_person, expected):
    assert prepare_person.name == expected

このようにparametrizeを利用することで、複数のテストケースを効率よく記述することができます。

まとめ


今回の記事では、pytestの特徴やその便利な機能について紹介しました。 pytestは使い勝手がよく、個人的な感覚としてもスムーズにテストを書くことができています。 便利なツールであるため、単体テストの導入を検討する際は、ぜひ考慮に入れると良いでしょう。

参考


\もっと身近にもっとリアルに!DAAE 公式 X/


執筆者プロフィール:Sakuma Shumpei
トラックボールがお気に入り

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/

PHOTO:UnsplashLuca Bravo