実行時の環境
- python
- 3.11.2
- OS
- macOS Ventura 13.4.1
以前、【初級エンジニア向け】テストって何?から一歩先へいくテスト習慣付けのススメというコンテンツを執筆したのですが、実際にユニットテスト(単体テスト)を書く作業についてきちんと紹介します。
今回はPythonとそのライブラリであるpytestを使ったユニットテストについて順を追って説明していきます。
Pythonにはもともと備わっているunittestというユニットテスト用ライブラリやnoseという少し前までデファクトスタンダードだったユニットテストライブラリがありました。しかし、最近では更新されていないためpytestを使うほうが主流になっています。
pytestとは
一般的に『unittestやnoseの上位互換なユニットテストフレームワーク』と呼ばれています。unittestやnoseと共存でき、移行が比較的簡単であることもあり、Pythonプロジェクトを抱える企業はpytestに移行するケースも少なくありません。
unittest, nose, pytestそれぞれの特徴と違い
それぞれの特徴と違いを簡単にまとめると以下となります。
- unittest: Pythonの組み込みライブラリで、より伝統的で冗長なxUnitスタイルの
- nose:
unittestを拡張したものだが現在はメンテナンスされていない - pytest: モダンかつPythonicなテストフレームワークで、活発にメンテナンスされており、3つの中で最も機能が豊富
新しいプロジェクトを始めるときは、活発なコミュニティと豊富な機能があるため、一般的にpytestを使うことを推奨します。
すでにnoseやunittestを使っている古いプロジェクトで作業している場合、それらを使い続けることは理にかなっているとも言えます。しかし、pytestはそれらのフレームワークで書かれたテストを実行可能です。
unittest
unittestはPython組み込みのテストライブラリで、xUnitスタイルに従っています。標準ライブラリに含まれており、広い範囲のテストスイートを構築するための基盤として作用します。
特徴としては以下です。
- Python標準ライブラリに組み込まれている
- xUnitスタイルに従う
pytestやnoseに比べて冗長- 多くの定型的なコードが必要(
assertEqualやassertTrueなどの独自関数あり) - フィクスチャ、テストディスカバリ、テストランナーが提供されている
unittestサンプルコード
import unittest
class TestStringMethods(unittest.TestCase):
def test_upper(self):
self.assertEqual('foo'.upper(), 'FOO')
def test_islower(self):
self.assertTrue('foo'.islower())
self.assertFalse('Foo'.islower())
if __name__ == '__main__':
unittest.main()nose
noseはunittestを拡張し、テストの記述、検索、実行を容易にする外部ライブラリです。
テストはnosetestsコマンドで実行できます。
- 現在はメンテナンスされていない(最終リリースは2015年)
unittestよりも利用できるプラグインが多いunittestよりも冗長性が低い- 自動的にテストコードを見つけ実行
- シンプルなテスト関数を書くことを補助
noseサンプルコード
from nose.tools import assert_equal
def test_upper():
assert_equal('foo'.upper(), 'FOO')
def test_islower():
assert 'foo'.islower()
assert not 'Foo'.islower()pytest
pytestはPythonの最も人気のあるテストフレームワークの1つです。よりモダンでPythonicなアプローチが特徴です。
テストはpytestコマンドで実行できます。
- シンプルな構文
- フィクスチャとさまざまなプラグインのサポート
- テストの失敗に関する詳細な情報を提供する
unittestやnoseテストも実行可能- 柔軟で拡張性が高い
- 活発なコミュニティ
pytestのサンプルコード
def test_upper():
assert 'foo'.upper() == 'FOO'
def test_islower():
assert 'foo'.islower()
assert not 'Foo'.islower()pytestインストール
実際の使い方を見ていくために、pytestのインストールをしていきます。
今回はpyenvを使って余計なライブラリが干渉しない状態で進めていきます。
pyenv virtualenv 3.11.2 pytest-samplepyenv local pytest-samplepip 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.pyやxxx_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() == 0pytest 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は実行の際にpytestやpython -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=num | num個のテストを遅い順で表示 |
--cov=my_module | my_moduleのカバレッジを計測(要pytest-cov) |
--cov-report=term | カバレッジレポートの種別(ターミナル、HTML, XMLなど)を指定(要pytest-cov) |
-m MARKEXPR | マーカーを指定してテストを実行 |
--junitxml=path | 指定したパスにJUnit XML形式のレポートを作成 |
--setup-show | セットアップとティアダウンを表示 |
--doctest-modules | モジュール内にあるdoctestを実行 |
-r chars | charsで指定した形式で出力をカスタマイズ |
--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 Falsepytestを使う際のディレクトリ構成
pytestのディレクトリ構成はプロジェクトごとに合意が取れたものであればチーム内で共有しやすいですが、一例を提示しておきます。
このケースだと基本的にsrc/ディレクトリへ実際のアプリケーションコードを入れ、tests/にすべてのテストファイルを集約しています。
conftest.pyというフィクスチャを共有する際に便利なファイルを入れていますが後述します。
.
├── 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 + bfrom src.mymodule.mymodule import MyModule
def test_module():
mymodule = MyModule()
assert mymodule.add(1, 2) == 3これは以下それぞれの指定の仕方でテスト可能です。
pytest tests/unit/mymodule/test_mymodule.pypytest 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) == 3pytest tests/unit/mymodule/test_mymodule.py::TestMyModulepytest 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の使い方は以下の通りです。
conftest.pyをテストファイルがあるディレクトリ、または任意の親ディレクトリに設置(pytestは自動でconftest.pyを検知する)conftest.pyでフィクスチャを定義conftest.pyでフックを使う- 任意のCLIオプションを追加
- テストデータの共有
例として、データベースフィクスチャを作ってみましょう。
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()
# ログインをテストヘルパーを利用する際は、以下のことに注意してください。
- ヘルパーは副作用を発生させてはいけない(ステートレスであるべき)
- 外部システムと相互作用するヘルパーについては、セットアップとティアダウンの機能を利用できるようにフィクスチャに変換することも検討すべし
- この例では
utils.pyのように別のファイルとして設置してインポートして使うべし - DRY原則を守り省コード化するために使おう
便利なpytest拡張ライブラリ
pytestはその他の外部ライブラリを利用することでより便利に利用できます。
テストの順番をコントロール pytest-ordering
pytest-orderingを使うと、テストの順番を制御可能です。統合テストであれば、データフローやワークフローが関係するケースも多々あるため、この拡張は有用です。
インストールにはpipからできます。
pip install pytest-orderingfrom 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) == -1tests/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.pydef add(a, b):
return a + b
def subtract(a, b):
return a - b
def not_tested_func():
passfrom 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=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-datadir
pytest-datadirはテストで使用するデータファイルとディレクトリの管理を簡単にします。テスト固有のデータディレクトリへのアクセスを提供するdatadirフィクスチャを作成します。
インストールはpipです。
pip install pytest-datadir今回は以下のようなディレクトリ構成で試してみます。
.
├── mymodule.py
└── tests
├── data
│ └── test1.txt
├── test_mymodule
│ └── test2.txt
└── test_mymodule.pytests/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 contenttest1.txt, test2.txtには適当に文字を書いておきます。
test1test2もしこれが空の場合はテスト失敗、上記のように文字が入っていればテスト合格となります。
pytest-datadirは下記のような動作をしています。
- datadirフィクスチャは
dataという名前のディレクトリを探査。 1, このdataディレクトリを指すpathlib.Pathオブジェクトをshared_datadirの形で提供。 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 == "モックテストタイトル"- モッカーのフィクスチャは
pytest-mockで提供されている mocker.patch()は一時的に本物のrequests.get関数を定義済みのmock_responseを返すモックに置き換え- 実際の
requests.getはテスト終了時、元に戻る
テストを書く習慣は上級を目指すエンジニアに必須
規模が大きいアプリケーションだけでなく、使い捨てのようなコードも含め、テストを書く習慣をつけることでコードの品質は格段に上がります。自分がコードに何を期待し、何を成し遂げなくてはいけないかを自問自答する意味でもテストを書く習慣をつけていきましょう。
「この人すごいな」と感じるエンジニアとチームになるとわかりますが、必ずといっていいほどテストをプロジェクト初期から書いています。
PullRequestを送っても「テストを書いてください」と指示されるため、Approveされずにpushし直す作業が発生するため、そのような無駄を省くためにも普段からテストを習慣づけましょう。
もしまだ現在のプロジェクトにテストコードが存在しなければ、ぜひあなたが牽引してテストをチーム内で流行らせていきましょう。





