フリーキーズ | 独学プログラミング

pytestの使い方。ディレクトリ構成やライブラリなど実践で使えるレベルまで

最終更新日
実行時の環境

python 3.11.2

OS macOS Ventura 13.4.1

以前、【初級エンジニア向け】テストって何?から一歩先へいくテスト習慣付けのススメというコンテンツを執筆したのですが、実際にユニットテスト(単体テスト)を書く作業についてきちんと紹介します。

今回はPythonとそのライブラリであるpytestを使ったユニットテストについて順を追って説明していきます。
Pythonにはもともと備わっているunittestというユニットテスト用ライブラリやnoseという少し前までデファクトスタンダードだったユニットテストライブラリがありました。しかし、最近では更新されていないためpytestを使うほうが主流になっています。

pytestとは

一般的に『unittestnoseの上位互換なユニットテストフレームワーク』と呼ばれています。unittestnoseと共存でき、移行が比較的簡単であることもあり、Pythonプロジェクトを抱える企業はpytestに移行するケースも少なくありません。

unittest, nose, pytestそれぞれの特徴と違い

それぞれの特徴と違いを簡単にまとめると以下となります。

  • unittest: Pythonの組み込みライブラリで、より伝統的で冗長なxUnitスタイルの
  • nose: unittestを拡張したものだが現在はメンテナンスされていない
  • pytest: モダンかつPythonicなテストフレームワークで、活発にメンテナンスされており、3つの中で最も機能が豊富

新しいプロジェクトを始めるときは、活発なコミュニティと豊富な機能があるため、一般的にpytestを使うことを推奨します。
すでにnoseunittestを使っている古いプロジェクトで作業している場合、それらを使い続けることは理にかなっているとも言えます。しかし、pytestはそれらのフレームワークで書かれたテストを実行可能です。

unittest

unittestはPython組み込みのテストライブラリで、xUnitスタイルに従っています。標準ライブラリに含まれており、広い範囲のテストスイートを構築するための基盤として作用します。
特徴としては以下です。

  1. Python標準ライブラリに組み込まれている
  2. xUnitスタイルに従う
  3. pytestnoseに比べて冗長
  4. 多くの定型的なコードが必要(assertEqualassertTrueなどの独自関数あり)
  5. フィクスチャ、テストディスカバリ、テストランナーが提供されている

nose

noseunittestを拡張し、テストの記述、検索、実行を容易にする外部ライブラリです。
テストはnosetestsコマンドで実行できます。

  1. 現在はメンテナンスされていない(最終リリースは2015年)
  2. unittestよりも利用できるプラグインが多い
  3. unittestよりも冗長性が低い
  4. 自動的にテストコードを見つけ実行
  5. シンプルなテスト関数を書くことを補助

pytest

pytestはPythonの最も人気のあるテストフレームワークの1つです。よりモダンでPythonicなアプローチが特徴です。
テストはpytestコマンドで実行できます。

  1. シンプルな構文
  2. フィクスチャとさまざまなプラグインのサポート
  3. テストの失敗に関する詳細な情報を提供する
  4. unittestnoseテストも実行可能
  5. 柔軟で拡張性が高い
  6. 活発なコミュニティ

pytestインストール

実際の使い方を見ていくために、pytestのインストールをしていきます。
今回はpyenvを使って余計なライブラリが干渉しない状態で進めていきます。

pyenv virtualenv 3.11.2 pytest-sample
pyenv local pytest-sample
pip install pytest

簡単に使ってみる

当たり前ですが、インストールしただけで何もテストファイルがない場合はそのままテストが通ります。

pytest
======================================================== test session starts ========================================================
platform darwin -- Python 3.11.2, pytest-7.4.0, pluggy-1.2.0
rootdir: /pytest
collected 0 items

======================================================= no tests ran in 0.00s =======================================================
(pytest-sample)

最小でテストを実行してみましょう。
test_xxx.pyxxx_test.pyのようにファイル名にtestをつけるとテストファイルとして認識されます。

def say_hello():
    return 'Hello, World!'

def test_say_hello():
    assert say_hello() == 'Hello, World!'

これを実行すると以下のようにテストが成功します。

pytest hello_test.py
======================================================== test session starts ========================================================
platform darwin -- Python 3.11.2, pytest-7.4.0, pluggy-1.2.0
rootdir: /pytest
collected 1 item

hello_test.py .                                                                                                               [100%]

========================================================= 1 passed in 0.00s =========================================================
(pytest-sample)

失敗するパターンもやってみましょう。

def fail():
    return 1

def test_fail():
    assert fail() == 0
pytest fail_test.py
======================================================== test session starts ========================================================
platform darwin -- Python 3.11.2, pytest-7.4.0, pluggy-1.2.0
rootdir: /pytest
collected 1 item

fail_test.py F                                                                                                                [100%]

============================================================= FAILURES ==============================================================
_____________________________________________________________ test_fail _____________________________________________________________

    def test_fail():
>       assert fail() == 0
E       assert 1 == 0
E        +  where 1 = fail()

fail_test.py:5: AssertionError
====================================================== short test summary info ======================================================
FAILED fail_test.py::test_fail - assert 1 == 0
========================================================= 1 failed in 0.01s =========================================================
(pytest-sample)

このように失敗した箇所がわかりやすく表示されます。

実践的なpytestの使い方

pytest実行時のオプション

pytestは実行の際にpytestpython -m pytestのようにすべてで実行するかファイル名を指定するかといった使い方ができます。さらに、オプションを与えることで出力をコントロールしたりデバッガを表示したりできます。
一覧にすると以下になります。

オプション説明
-h, --helpヘルプの表示
-v, --verboseキャッシュや成功結果など、細かな結果を表示
-q, --quietシンプルな失敗情報のみ表示
-k EXPRESSION与えた文字列にマッチするテストのみ実行
-x, --exitfirst最初にテストが失敗した箇所で停止
--maxfail=numテスト失敗においてnumで指定した数で停止
-s, --capture=noコード内のprint()を表示
-l, --showlocalsトレースバック内でローカル変数を表示
--ff, --failed-first失敗済みのテストを先に実行
--durations=numnum個のテストを遅い順で表示
--cov=my_modulemy_moduleのカバレッジを計測(要pytest-cov
--cov-report=termカバレッジレポートの種別(ターミナル、HTML, XMLなど)を指定(要pytest-cov
-m MARKEXPRマーカーを指定してテストを実行
--junitxml=path指定したパスにJUnit XML形式のレポートを作成
--setup-showセットアップとティアダウンを表示
--doctest-modulesモジュール内にあるdoctestを実行
-r charscharsで指定した形式で出力をカスタマイズ
--collect-onlyテストを集約して表示するが実行はしない
--pdbデバッガを出力
--lf, --last-failed最後に失敗したテストを実行
--tb=styleトレースバックを指定のstyle(short, long, nativeなど)で出力

結果にコメント

自然語のコメントをつけることで「何を期待していたが得られなかったか」をよりわかりやすくできます。

def test_hello():
    word = 'world'
    assert word == 'hello', 'helloと言っていない'
FAILED test_hello.py::test_hello - AssertionError: helloと言っていない

よく使うデコレータや関数

基本的にpytestは予約語であるassertを覚えておけば基本的な使い方は可能です。
assertに続くコードがTrueを返せば成功、Flaseを返せばテストは失敗します。

それ以外にも、pytest独自のデコレータや関数を覚えておけば便利に使えます。

pytest.raises()

例外を検出する際に使います。

例として、2つの数値を除算する関数があるとします。ゼロで割ったときにZeroDivisionErrorが発生するようにします。

def divide(a, b):
    return a / b

そして、ゼロで割ったときのテストを用意します。

from src.devide import devide
import pytest


def test_devide_by_zero():
    with pytest.raises(ZeroDivisionError):
        devide(1, 0)
  • withでそのコンテキスト内に発生した例外をキャッチ
  • pytest.raises(ZeroDivisionError)は、withブロック内でZeroDivisionErrorが発生することを保証
  • withブロック内のコードがZeroDivisionErrorを発生させなければテスト失敗

または、match引数を使えば例外メッセージが特定の文字列にマッチするかどうかをテストできます。

def test_divide_by_zero_message():
    with pytest.raises(ZeroDivisionError, match="division by zero"):
        divide(1, 0)

@pytest.texture()

このデコレータはフィクスチャを宣言します。
フィクスチャとは、テストの中で使いたい特定のオブジェクトや値を返す関数のことです。たとえば、データベース接続や設定データなどを返すようなものです。

@pytest.fixture
def sample_data():
    return {"key": "value"}

@pytest.mark.[MARK_NAME]

特定のタグでテスト関数をマークします。
これにより、テストを選択的に実行できます。カスタムマーカーを定義できますし、組み込みのものもいくつかあります。

@pytest.mark.slow
def test_slow_function():
    pass

@pytest.mark.skip(reason=None)

このデコレータは、特定の条件下あるいは無条件でテスト関数をスキップするようにマークします。

@pytest.mark.skip(reason="スキップします")
def test_example():
    pass

@pytest.mark.skipif(condition, reason=None)

このデコレータは、条件がTrueの場合にテストをスキップするようにマークします。

@pytest.mark.skipif(sys.version_info < (3,7), reason="Python3.7以上のバージョンが必要です")
def test_example():
    pass

@pytest.mark.parametrize(argnames, argvalues)

このデコレーターを使うと、テスト関数を異なる引数で複数回実行できます。

@pytest.mark.parametrize("x, y, expected", [(1, 2, 3), (4, 5, 9)])
def test_add(x, y, expected):
    assert x + y == expected

@pytest.mark.xfail(reason=None, raises=None, run=True, strict=False)

このデコレータは、テストが失敗することを想定していることを表します。

@pytest.mark.xfail
def test_example():
    assert False

pytestを使う際のディレクトリ構成

pytestのディレクトリ構成はプロジェクトごとに合意が取れたものであればチーム内で共有しやすいですが、一例を提示しておきます。
このケースだと基本的にsrc/ディレクトリへ実際のアプリケーションコードを入れ、tests/にすべてのテストファイルを集約しています。

.
├── README.md
├── requirements.txt
├── setup.py
├── src
│   └── mymodule
│       └── mymodule.py
└── tests
    ├── __init__.py
    ├── conftest.py
    ├── fixtures
    ├── integration
    └── unit
        └── mymodule
            └── test_mymodule.py

指定のテスト実行

先述したオプション-kによる実行するテストの指定以外にも、以下の方法で実行するテストを指定可能です。

例として、先ほどのディレクトリ構成のmymoduleにクラスを作成してみます。

class MyModule(object):
    def __init__(self):
        pass

    def add(self, a, b):
        return a + b
from src.mymodule.mymodule import MyModule


def test_module():
    mymodule = MyModule()
    assert mymodule.add(1, 2) == 3

これは以下それぞれの指定の仕方でテスト可能です。

  • pytest tests/unit/mymodule/test_mymodule.py
  • pytest tests/unit/mymodule/test_mymodule.py::test_module

テストをまとめるためにクラス化した場合は以下のようにできます。

from src.mymodule.mymodule import MyModule


class TestMyModule(object):
    def test_module():
        mymodule = MyModule()
        assert mymodule.add(1, 2) == 3
  • pytest tests/unit/mymodule/test_mymodule.py::TestMyModule
  • pytest tests/unit/mymodule/test_mymodule.py::TestMyModule::test_module

fixtureの事後処理

pytestでは、フィクスチャはセットアップアクションとティアダウン(またはポスト)アクションの両方を持つことができます。これらはそれぞれ、フィクスチャを使用するテスト関数の前と後に実行されるアクションです。

セットアップコードはフィクスチャのyield文の前に行うすべてのことで、ティアダウン(またはポストアクション)コードはyield文の後に行うすべてのことです。

たとえば、データベースシステムをテストしているとします。テストを実行する前にデータベースへの接続を確立し、 テストが終わったら接続を閉じたいとします。

import pytest

class SimpleDatabase:
    def __init__(self):
        self.connection = None

    def connect(self):
        # 接続状態
        self.connection = "Connected"

    def close(self):
        # 接続終了状態
        self.connection = None

@pytest.fixture
def database():
    # セットアップ: データベース接続
    db = SimpleDatabase()
    db.connect()

    yield db  # テストにデータベースインスタンスを返す

    # ティアダウン: データベース接続終了
    db.close()

def test_database_connection(database):
    assert database.connection == "Connected"

def test_database_func(database):
    pass

# テスト終了後はティアダウンで接続が失われた状態

conftest.pyの使い方

pytestでは、conftest.pyはディレクトリ内の複数のテストファイルで利用可能なフック、フィクスチャ、その他の設定を定義できます。主なメリットはコードの重複を避け、設定とフィクスチャのための一元的な設定データとして機能することです。
conftest.pyの使い方は以下の通りです。

  1. conftest.pyをテストファイルがあるディレクトリ、または任意の親ディレクトリに設置(pytestは自動でconftest.pyを検知する)
  2. conftest.pyでフィクスチャを定義
  3. conftest.pyでフックを使う
  4. 任意のCLIオプションを追加
  5. テストデータの共有

例として、データベースフィクスチャを作ってみましょう。

import pytest

# 共有するフィクスチャの定義
@pytest.fixture
def database():
    db = connect_to_database()
    yield db
    db.close()

# フックの定義
def pytest_configure(config):
    config.addinivalue_line("markers", "slow: marks tests as slow")

# カスタムオプションの定義
def pytest_addoption(parser):
    parser.addoption("--myoption", action="store", default="default_value", help="カスタムオプション")

@pytest.fixture
def myoption(request):
    return request.config.getoption("--myoption")

# テストデータの共有
@pytest.fixture
def common_data():
    return {"key": "value"}
def test_database_func(database):
    pass

def test_myoption_receive(myoption):
    assert myoption == "test"

カスタムオプションを定義した場合、以下のような形でpytestを実行できます。

pytest --myoption=test

ヘルパーの作成

pytestでは、テストの一般的な操作を支援するヘルパー関数を作成し、コードの再利用性と可読性を高めることができます。デフォルトでは組み込みのヘルパーを持ちませんが、ユーティリティ関数を作成するための標準的なPythonのアプローチを利用するだけで簡単に作成できます。

例として、Webアプリケーションをテストしていて、ランダムなユーザーデータを生成する必要があるとします。以下のようにヘルパーを作成していきます。

import random
import string


def random_string(length=10):
    """固定幅のランダム文字列を生成"""
    letters = string.ascii_lowercase
    return ''.join(random.choice(letters) for i in range(length))

def generate_user():
    """ランダムなユーザーデータ生成"""
    return {
        "username": random_string(8),
        "password": random_string(12),
        "email": f"{random_string(5)}@example.com"
    }

これを以下のようにテストで利用します。

from utils import generate_user
import pytest


def test_user_registration():
    user = generate_user()
    # このデータを使ってユーザー登録の実装をテストする
    # ユーザー登録機能がTrueかどうかをassert

def test_user_login():
    user = generate_user()
    # ログインをテスト

ヘルパーを利用する際は、以下のことに注意してください。

  1. ヘルパーは副作用を発生させてはいけない(ステートレスであるべき)
  2. 外部システムと相互作用するヘルパーについては、セットアップとティアダウンの機能を利用できるようにフィクスチャに変換することも検討すべし
  3. この例ではutils.pyのように別のファイルとして設置してインポートして使うべし
  4. DRY原則を守り省コード化するために使おう

便利なpytest拡張ライブラリ

pytestはその他の外部ライブラリを利用することでより便利に利用できます。

テストの順番をコントロール pytest-ordering

pytest-orderingを使うと、テストの順番を制御可能です。統合テストであれば、データフローやワークフローが関係するケースも多々あるため、この拡張は有用です。
インストールにはpipからできます。

pip install pytest-ordering
from src.mymodule.mymodule import MyModule
import pytest


class TestMyModule(object):
    # これは2番目に実行
    @pytest.mark.run(order=2)
    def test_module_one(self):
        mymodule = MyModule()
        assert mymodule.add(1, 2) == 3

    # これは最初に実行
    @pytest.mark.run(order=1)
    def test_module_two(self):
        mymodule = MyModule()
        assert mymodule.subtract(1, 2) == -1
tests/integration/mymodule/test_mymodule.py::TestMyModule::test_module_two PASSED  [ 50%]
tests/integration/mymodule/test_mymodule.py::TestMyModule::test_module_one PASSED  [100%]

カバレッジ(網羅性)を調べる pytest-cov

pytest-covはコードカバレッジを測定するライブラリです。
pipでインストールしましょう。

pip install pytest-cov

以下のようなディレクトリ構成でテストをするとします。

.
├── mymodule.py
└── tests
    ├── __init__.py
    └── test_mymodule.py
def add(a, b):
    return a + b

def subtract(a, b):
    return a - b

def not_tested_func():
    pass
from mymodule import add, subtract


def test_add():
    assert add(1, 2) == 3

def test_subtract():
    assert subtract(3, 2) == 1

ここで以下のコマンドを実行します。

pytest --cov=mymodule tests/

すると、以下のような結果を得られるはずです。

collected 2 items

tests/test_mymodule.py ..                                                                                                     [100%]

---------- coverage: platform darwin, python 3.11.2-final-0 ----------
Name          Stmts   Miss  Cover
---------------------------------
mymodule.py       6      1    83%
---------------------------------
TOTAL             6      1    83%

この結果は、mymodule.pyのテストカバレッジが83%であることを示しています。これは、モジュール内のプログラムの67%がテストによって実行されたことを意味します。

HTMLレポートのようなより詳細なレポートが欲しい場合は次のようにします。

pytest --cov=mymodule --cov-report html tests/

実行すると、htmlcov/が生成され、ディレクトリ以下にHTMLファイルが生成されます。これをブラウザで表示すると以下のようになります。

pytest-cov結果

pytest-cov結果

カバレッジ結果に含めるファイルや除外するファイル、ディレクトリを指定できます。

pytest --cov=mymodule --cov-report html --cov-fail-under=80 --cov-branch tests/
  • -cov-fail-under=80: カバレッジが80%未満の場合、テスト実行が失敗
  • -cov-branch: 条件分岐のカバレッジ計測

HTML形式で結果を表示 pytest-html

続いてpytest-htmlです。これもpipでインストールできます。

pip install pytest-html

先ほどのtest_mymodule.pyを以下のようにします。

from mymodule import add, subtract

def test_add():
    assert add(1, 2) == 3

def test_subtract():
    assert subtract(3, 2) == 1

def test_failure():
    assert 1 == 2

この状態だとtest_failure()がテスト失敗として検知されます。
pytest-htmlでHTML化しましょう。

pytest --html=report.html tests/

これによって出力されるreport.htmlをブラウザで表示すると以下のようになります。

pytest-html結果

pytest-html結果

上記のレポートは下記のような結果を示しています。

  1. 合格、不合格、スキップされたテストの数などを示す要約
  2. 各テスト、そのステータス(合格、不合格など)、および追加情報が記載された詳細
  3. 失敗したテストにおける予想結果と実際の結果の詳細な比較

テストデータ操作 pytest-datadir

pytest-datadirはテストで使用するデータファイルとディレクトリの管理を簡単にします。テスト固有のデータディレクトリへのアクセスを提供するdatadirフィクスチャを作成します。
インストールはpipです。

pip install pytest-datadir

今回は以下のようなディレクトリ構成で試してみます。

.
├── mymodule.py
└── tests
    ├── data
    │   └── test1.txt
    ├── test_mymodule
    │   └── test2.txt
    └── test_mymodule.py

tests/data/ディレクトリには、テスト専用のデータファイルを置きます。

def test_read_data_file1(shared_datadir):
    # datadirはpathlib.Pathオブジェクト
    data_file = shared_datadir / 'test1.txt'

    with open(data_file, 'r') as f:
        content = f.read()

    # 実験のため単純にデータが空でないことを確認
    assert content

def test_read_data_file2(datadir):
    data_file = datadir / 'test2.txt'

    with open(data_file, 'r') as f:
        content = f.read()

    assert content

test1.txt, test2.txtには適当に文字を書いておきます。

test1
test2

もしこれが空の場合はテスト失敗、上記のように文字が入っていればテスト合格となります。
pytest-datadirは下記のような動作をしています。

  1. datadirフィクスチャはdataという名前のディレクトリを探査。 1, このdataディレクトリを指すpathlib.Pathオブジェクトをshared_datadirの形で提供。
  2. datadirの場合はテストファイルと同名のディレクトリを参照。

モックを作る pytest-mock

pytest-mockは、unittest.mockライブラリをpytestに統合し、便利なモッカーとして機能します。
これもまた、pipでインストール可能です。

pip install pytest-mock

例として、特定のWebサイトからタイトルを抽出したいとします。

import requests


def get_website_title(url):
    response = requests.get(url)
    if response.status_code == 200:
        return response.text.split('<title>')[1].split('</title>')[0]
    else:
        return None

この関数は与えられたURLからWebサイトのタイトルを取得します。実際のHTTPリクエストを行わずにこの関数をテストするには、pytest-mockを使ってrequests.getをモックします。

from mymodule import get_website_title


def test_get_website_title(mocker):
    # `text`と200の`status_code`を要素としてモックレスポンスを作成
    mock_response = mocker.Mock()
    mock_response.text = "<html><head><title>モックテストタイトル</title></head></html>"
    mock_response.status_code = 200

    # requests.get()をモック化し、モックレスポンスを返すように設定
    mocker.patch('requests.get', return_value=mock_response)

    # 実際のHTTPリクエストをお叶わずに"モックテストタイトル"を取得
    result = get_website_title("http://mock.url")
    assert result == "モックテストタイトル"
  1. モッカーのフィクスチャはpytest-mockで提供されている
  2. mocker.patch()は一時的に本物のrequests.get関数を定義済みのmock_responseを返すモックに置き換え
  3. 実際のrequests.getはテスト終了時、元に戻る

テストを書く習慣は上級を目指すエンジニアに必須

規模が大きいアプリケーションだけでなく、使い捨てのようなコードも含め、テストを書く習慣をつけることでコードの品質は格段に上がります。自分がコードに何を期待し、何を成し遂げなくてはいけないかを自問自答する意味でもテストを書く習慣をつけていきましょう。

「この人すごいな」と感じるエンジニアとチームになるとわかりますが、必ずといっていいほどテストをプロジェクト初期から書いています。
PullRequestを送っても「テストを書いてください」と指示されるため、Approveされずにpushし直す作業が発生するため、そのような無駄を省くためにも普段からテストを習慣づけましょう。

もしまだ現在のプロジェクトにテストコードが存在しなければ、ぜひあなたが牽引してテストをチーム内で流行らせていきましょう。

関連するコンテンツ