unittest を学ぶ理由
テストは実施フェーズごとに単体テスト、結合テスト、システムテスト、受入テストなどに分類されます。単体テスト(unit test)とは、ソフトウェアの最小単位(関数やクラスなど)を他のコンポーネントから切り離して検証する作業です。これは通常、開発者がモジュール実装後に実施します。
単体テストを早期に実施することで、バグを发现・修正しやすくなり、安定性向上やFizzBuzzレベルの初期不具合を低コストで解消できます。また、単体レベルでの検証が済んでいれば、後の結合テストやシステムテストの信頼性も高まります。
unittest は Python 標準で備わっている単体テスト用フレームワークです。構造が明確で習得が容易であり、テストの整理・実行を効率化できます。
unittest のアーキテクチャ
unittest フレームワークの核心的な構成要素は以下の4つです:
- テストケース(Test Case):単一のテスト実行単位。unittest.TestCase を継承したクラス内に test_ で始まるメソッドを定義し、その1つ1つがテストケースになります。メソッド名の ASCII 並び順で実行順序が決まります。
- テストフィクスチャ(Test Fixture):テスト実行前の準備(setUp / setUpClass)と実行後の後始末(tearDown / tearDownClass)。例えば、テストごとに外部システムに接続して token を取得し、その後切断するといった状態管理に利用されます。
- テストスイート(Test Suite):複数のテストケースを1つにまとめた実行単位です。TestLoader クラスを使って、クラスやモジュール単位でスイートに追加できます。
- テストランナー(Test Runner):テストスイート内のテストケースを実行し、結果を出力します。標準では console 出力ですが、HTMLTestRunner などのサードパーティ拡張により HTML レポートを生成可能です。
アサーション(検証)
テスト結果が期待通りであるかを検証するための仕組みです。unittest では assert 処理が失敗した場合、AssertionError が送出され、そのテストケースは fail になります。
以下は代表的なアサーションメソッドです(すべて msg パラメータに対応):
| メソッド | 検証内容 |
|---|---|
| assertEqual(a, b) | a == b |
| assertNotEqual(a, b) | a != b |
| assertTrue(x) | bool(x) is True |
| assertFalse(x) | bool(x) is False |
| assertIs(x, y) | x is y |
| assertIsNot(x, y) | x is not y |
| assertIsNone(x) | x is None |
| assertIsNotNone(x) | x is not None |
| assertIn(item, container) | item in container |
| assertNotIn(item, container) | item not in container |
| assertIsInstance(obj, cls) | isinstance(obj, cls) |
| assertNotIsInstance(obj, cls) | not isinstance(obj, cls) |
TestCase の実装例
以下は、単純なユーザー登録ロジックを対象としたテストクラスの例です。
テスト対象モジュール(register.py):
# register.py
users = [{'username': 'testing', 'password': '123456'}]
def register(username, password1, password2):
if not all([username, password1, password2]):
return {'code': 0, 'msg': 'すべてのパラメータは必須です。'}
for existing_user in users:
if username == existing_user['username']:
return {'code': 0, 'msg': 'このユーザー名はすでに使用されています。'}
if password1 != password2:
return {'code': 0, 'msg': '入力されたパスワードが一致しません。'}
if not (6 <= len(username) <= 18 and 6 <= len(password1) <= 18):
return {'code': 0, 'msg': 'ユーザー名とパスワードは6~18文字で入力してください。'}
users.append({'username': username, 'password': password2})
return {'code': 1, 'msg': '登録が完了しました。'}
テストケース(test_register.py):
# test_register.py
import unittest
from register import register
class RegisterTest(unittest.TestCase):
def test_registration_success(self):
input_data = ('newuser', 'pass1234', 'pass1234')
actual = register(*input_data)
expected = {'code': 1, 'msg': '登録が完了しました。'}
self.assertEqual(actual, expected)
def test_duplicate_username(self):
input_data = ('testing', '123456', '123456')
actual = register(*input_data)
expected = {'code': 0, 'msg': 'このユーザー名はすでに使用されています。'}
self.assertEqual(actual, expected)
def test_missing_parameter(self):
input_data = ('', 'pass1234', 'pass1234')
actual = register(*input_data)
expected = {'code': 0, 'msg': 'すべてのパラメータは必須です。'}
self.assertEqual(actual, expected)
def test_invalid_length_username(self):
input_data = ('thisusernameiswaytoolong', 'pass1234', 'pass1234')
actual = register(*input_data)
expected = {'code': 0, 'msg': 'ユーザー名とパスワードは6~18文字で入力してください。'}
self.assertEqual(actual, expected)
def test_mismatched_passwords(self):
input_data = ('newuser2', 'pass1234', 'pass5678')
actual = register(*input_data)
expected = {'code': 0, 'msg': '入力されたパスワードが一致しません。'}
self.assertEqual(actual, expected)
if __name__ == '__main__':
unittest.main()
実行結果例:
...... ---------------------------------------------------------------------- Ran 6 tests in 0.002s OK
テストフィクスチャの利用
テストの前後で共通の初期化・クリーンアップ処理を行うために、以下のメソッドが利用可能です:
setUp():各テストメソッド実行前に呼ばれる(インスタンススコープ)tearDown():各テストメソッド実行後に呼ばれる(インスタンススコープ)setUpClass():クラス内のすべてのテストメソッド実行前に1回だけ呼ばれる(クラスメソッド)tearDownClass():クラス内のすべてのテストメソッド実行後に1回だけ呼ばれる(クラスメソッド)
class RegisterTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
print('[Class Level] 初期化処理 ...')
@classmethod
def tearDownClass(cls):
print('[Class Level] 終了処理 ...')
def setUp(self):
print(f'{self._testMethodName} 開始 ...')
def tearDown(self):
print(f'{self._testMethodName} 終了 ...')
# test メソッドは省略
テストスイートの構成
明示的にテストケースを集めて実行するためにテストスイートを利用できます。
addTest(TestCase インスタンス):単一のテストメソッドを追加addTests([TestCase インスタンス...]):テストメソッドリストを追加TestLoader().loadTestsFromTestCase(TestClass):クラス単位でロードTestLoader().loadTestsFromModule(module):モジュール単位でロードTestLoader().discover(start_dir, pattern):ディレクトリ以下からファイル名パターンに合致するテスト群を再帰的に検出
# test_runner.py
import unittest
import os
from pathlib import Path
import test_register
base_dir = Path(__file__).parent.resolve()
suite = unittest.TestSuite()
loader = unittest.TestLoader()
# discover を利用し、test_register.py を検出・追加
suite.addTests(loader.discover(start_dir=base_dir, pattern='test_*'))
# 実行(後述の runner でハンドリング可能)
テストランナーとレポート生成
unittest 単体では簡易な実行結果しか出力しません。本格的なレポート生成には外部ライブラリ(例:HTMLTestRunner/CN)を用いるのが一般的です。
HTML レポート出力例:
# report_runner.py
from HTMLTestRunner import HTMLTestRunner
import unittest
import os
base_dir = os.path.dirname(os.path.abspath(__file__))
suite = unittest.TestSuite()
loader = unittest.TestLoader()
suite.addTests(loader.discover(start_dir=base_dir, pattern='test_*.py'))
with open('report.html', 'wb') as f:
runner = HTMLTestRunner(
stream=f,
verbosity=2,
title='ソフトウェア登録機能の自動テスト',
description='Python unittest を用いた登録処理の検証結果',
tester='QA Team'
)
runner.run(suite)
HTMLTestRunner には以下の主な設定項目があります:
stream: レポート出力先ストリーム(fileオブジェクト or バイナリモードの io)tester: レポート中に表示されるテスト担当者名description: レポートの説明文title: HTML レポートのタイトルverbosity: 出力の詳細度(0: 結果のみ、1: 基本、2: 詳細)
よくあるエラーと対処法
TypeError: a bytes-like object is required, not 'str'- 原因:open モードが textile で open('report.html', 'w') → バイナリモードで open('report.html', 'wb') に変更
- 日本語が文字化けする
- 原因:HTMLTestRunner のエンコーディング指定不足
- 対策:HTMLTestRunner を独自に修正(またはサードパーティ互換版の利用)。または日本語入力を避けるなどの工夫が有効です。