unittest

Python単体テスト標準ライブラリTDDxUnitTestCaseモックテストスイート

単体テストツール

unittest

概要

unittestは、Pythonの標準ライブラリに含まれる単体テストフレームワークです。Java用のJUnitにインスパイアされたxUnitスタイルのテストフレームワークで、テスト駆動開発(TDD)やテストファーストな開発アプローチをサポートします。Pythonのインストールと同時に利用可能になるため、追加のインストールが不要で、すぐにテストを開始できます。

詳細

unittestは、Pythonに標準で組み込まれているため、新しい依存関係を追加することなく信頼性の高いテスト環境を構築できます。オブジェクト指向アプローチを採用し、テストケースはunittest.TestCaseの基底クラスを継承することで作成されます。

unittestの主な特徴:

  • 標準ライブラリ: 追加インストール不要でPythonに同梱
  • xUnitアーキテクチャ: 確立されたテストパターンに基づく設計
  • オブジェクト指向: クラスベースのテスト構造
  • 豊富なアサーション: 多様な検証メソッドを提供
  • テストスイート: テストの組織化と一括実行をサポート
  • モック機能: unittest.mockによるテストダブル作成
  • テストディスカバリー: 自動テスト検出機能
  • セットアップ・ティアダウン: テスト前後の処理を定義可能
  • スキップ機能: 条件付きでテストをスキップ
  • 継続的統合対応: Jenkins、GitHub Actions等との統合

unittestは、テストケース、テストスイート、テストフィクスチャ、テストランナーの4つの主要概念で構成されます。これらの概念により、体系的で保守しやすいテスト構造を構築できます。

メリット・デメリット

メリット

  1. インストール不要: Python標準ライブラリのため追加設定が不要
  2. 信頼性: 長年使用されている安定したフレームワーク
  3. 高速実行: 軽量で迅速なテスト実行
  4. 豊富なアサーション: 多様な検証メソッドを標準提供
  5. IDE統合: VS Code、PyCharmなどの主要IDEでサポート
  6. テスト自動化: テストディスカバリーによる自動テスト検出
  7. 柔軟なテスト構成: テストスイートによる組織的なテスト管理
  8. モック機能: 依存関係の分離とテストダブル作成をサポート

デメリット

  1. 冗長な記述: 他のフレームワーク(pytest等)と比較して記述量が多い
  2. 読みにくさ: オブジェクト指向の抽象化により意図が分かりにくい場合
  3. 大規模プロジェクトでの制限: 複雑なプロジェクトでは機能不足を感じることがある
  4. パラメータ化テストの制限: データ駆動テストのサポートが限定的
  5. エラーメッセージ: pytestと比較してエラーメッセージが詳細でない場合がある
  6. プラグインエコシステム: pytestほど豊富なプラグインがない

参考ページ

使い方の例

基本セットアップ

基本的なテストクラス

import unittest

class TestStringMethods(unittest.TestCase):
    
    def setUp(self):
        """各テストメソッド実行前に呼ばれる"""
        self.test_string = "hello world"
    
    def tearDown(self):
        """各テストメソッド実行後に呼ばれる"""
        # クリーンアップ処理
        pass
    
    def test_upper(self):
        """文字列の大文字変換をテスト"""
        self.assertEqual(self.test_string.upper(), 'HELLO WORLD')
    
    def test_split(self):
        """文字列の分割をテスト"""
        result = self.test_string.split()
        self.assertEqual(result, ['hello', 'world'])
        self.assertEqual(len(result), 2)

if __name__ == '__main__':
    unittest.main()

テストの実行

# ファイル単位での実行
python test_string_methods.py

# モジュール単位での実行
python -m unittest test_string_methods

# 特定のテストクラス実行
python -m unittest test_string_methods.TestStringMethods

# 特定のテストメソッド実行
python -m unittest test_string_methods.TestStringMethods.test_upper

# テストディスカバリー(自動検出)
python -m unittest discover

アサーションメソッド

基本的なアサーション

import unittest

class TestAssertions(unittest.TestCase):
    
    def test_equality_assertions(self):
        """等価性のアサーション"""
        self.assertEqual(1 + 1, 2)
        self.assertNotEqual(1 + 1, 3)
        
    def test_boolean_assertions(self):
        """真偽値のアサーション"""
        self.assertTrue(True)
        self.assertFalse(False)
        
    def test_none_assertions(self):
        """None値のアサーション"""
        value = None
        self.assertIsNone(value)
        
        value = "not none"
        self.assertIsNotNone(value)
    
    def test_membership_assertions(self):
        """包含関係のアサーション"""
        container = [1, 2, 3, 4, 5]
        self.assertIn(3, container)
        self.assertNotIn(6, container)
        
    def test_type_assertions(self):
        """型のアサーション"""
        value = "hello"
        self.assertIsInstance(value, str)
        self.assertNotIsInstance(value, int)

数値と文字列のアサーション

class TestNumericAndStringAssertions(unittest.TestCase):
    
    def test_numeric_assertions(self):
        """数値のアサーション"""
        self.assertGreater(5, 3)
        self.assertGreaterEqual(5, 5)
        self.assertLess(3, 5)
        self.assertLessEqual(3, 3)
        
        # 浮動小数点数の近似比較
        self.assertAlmostEqual(3.14159, 3.14, places=2)
        self.assertNotAlmostEqual(3.14159, 2.71, places=2)
        
    def test_string_assertions(self):
        """文字列のアサーション"""
        text = "Hello, World!"
        
        self.assertRegex(text, r'Hello.*')
        self.assertNotRegex(text, r'goodbye')
        
    def test_collection_assertions(self):
        """コレクションのアサーション"""
        list1 = [1, 2, 3]
        list2 = [1, 2, 3]
        
        self.assertListEqual(list1, list2)
        
        dict1 = {'a': 1, 'b': 2}
        dict2 = {'a': 1, 'b': 2}
        
        self.assertDictEqual(dict1, dict2)

例外テスト

例外のテスト

class TestExceptions(unittest.TestCase):
    
    def test_exception_raised(self):
        """例外が発生することをテスト"""
        with self.assertRaises(ValueError):
            int("not a number")
            
    def test_exception_with_message(self):
        """特定のメッセージの例外をテスト"""
        with self.assertRaises(ValueError) as context:
            raise ValueError("Invalid value provided")
        
        self.assertIn("Invalid value", str(context.exception))
        
    def test_exception_not_raised(self):
        """例外が発生しないことをテスト"""
        try:
            result = int("123")
            self.assertEqual(result, 123)
        except ValueError:
            self.fail("ValueError was raised unexpectedly")

セットアップとティアダウン

クラスレベルのセットアップ

class TestDatabaseOperations(unittest.TestCase):
    
    @classmethod
    def setUpClass(cls):
        """クラス全体のセットアップ(一度だけ実行)"""
        print("Setting up database connection")
        cls.db_connection = create_test_database()
        
    @classmethod
    def tearDownClass(cls):
        """クラス全体のティアダウン(一度だけ実行)"""
        print("Closing database connection")
        cls.db_connection.close()
        
    def setUp(self):
        """各テストメソッド前のセットアップ"""
        self.test_data = {"name": "John", "age": 30}
        
    def tearDown(self):
        """各テストメソッド後のティアダウン"""
        # テストデータのクリーンアップ
        self.db_connection.rollback()
        
    def test_insert_user(self):
        """ユーザー挿入のテスト"""
        result = self.db_connection.insert_user(self.test_data)
        self.assertTrue(result)

モッキング

unittest.mockの使用

import unittest
from unittest.mock import Mock, patch, MagicMock

class TestMocking(unittest.TestCase):
    
    def test_mock_object(self):
        """モックオブジェクトのテスト"""
        mock_service = Mock()
        mock_service.get_user.return_value = {"id": 1, "name": "John"}
        
        result = mock_service.get_user(1)
        
        self.assertEqual(result["name"], "John")
        mock_service.get_user.assert_called_once_with(1)
        
    @patch('requests.get')
    def test_api_call(self, mock_get):
        """外部APIコールのモック"""
        # モックのレスポンスを設定
        mock_response = Mock()
        mock_response.json.return_value = {"status": "success"}
        mock_response.status_code = 200
        mock_get.return_value = mock_response
        
        # テスト対象の関数を呼び出し
        response = make_api_call("http://example.com/api")
        
        self.assertEqual(response["status"], "success")
        mock_get.assert_called_once_with("http://example.com/api")
        
    def test_side_effect(self):
        """副作用のあるモック"""
        mock_function = Mock()
        mock_function.side_effect = [1, 2, 3, StopIteration]
        
        self.assertEqual(mock_function(), 1)
        self.assertEqual(mock_function(), 2)
        self.assertEqual(mock_function(), 3)
        
        with self.assertRaises(StopIteration):
            mock_function()

パッチデコレータの使用

class TestPatchDecorator(unittest.TestCase):
    
    @patch('os.path.exists')
    @patch('builtins.open', new_callable=unittest.mock.mock_open, 
           read_data="file content")
    def test_file_operations(self, mock_open, mock_exists):
        """ファイル操作のモック"""
        mock_exists.return_value = True
        
        result = read_file_content("test.txt")
        
        self.assertEqual(result, "file content")
        mock_exists.assert_called_once_with("test.txt")
        mock_open.assert_called_once_with("test.txt", 'r')

テストスイート

カスタムテストスイート

def create_test_suite():
    """カスタムテストスイートの作成"""
    suite = unittest.TestSuite()
    
    # 特定のテストケースを追加
    suite.addTest(TestStringMethods('test_upper'))
    suite.addTest(TestStringMethods('test_split'))
    
    # テストクラス全体を追加
    suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestAssertions))
    
    return suite

if __name__ == '__main__':
    # カスタムスイートの実行
    runner = unittest.TextTestRunner(verbosity=2)
    result = runner.run(create_test_suite())

テストスキップ

条件付きスキップ

import sys
import unittest

class TestSkipping(unittest.TestCase):
    
    @unittest.skip("まだ実装されていない機能")
    def test_not_implemented_feature(self):
        """実装されていない機能のテスト"""
        pass
        
    @unittest.skipIf(sys.version_info < (3, 8), "Python 3.8以上が必要")
    def test_python38_feature(self):
        """Python 3.8以上の機能をテスト"""
        # walrus operator (Python 3.8+)
        if (n := len([1, 2, 3])) > 2:
            self.assertGreater(n, 2)
            
    @unittest.skipUnless(sys.platform.startswith("win"), "Windows専用")
    def test_windows_specific(self):
        """Windows専用機能のテスト"""
        import winsound
        # Windows特有の処理をテスト
        pass
        
    def test_conditional_skip(self):
        """実行時条件でのスキップ"""
        if not has_network_connection():
            self.skipTest("ネットワーク接続が必要")
        
        # ネットワークを使用するテスト
        response = requests.get("http://example.com")
        self.assertEqual(response.status_code, 200)

パラメータ化テスト

サブテストの使用

class TestParameterized(unittest.TestCase):
    
    def test_multiple_inputs(self):
        """複数の入力値でのテスト"""
        test_cases = [
            (2, 3, 5),
            (1, 1, 2),
            (0, 5, 5),
            (-1, 1, 0)
        ]
        
        for a, b, expected in test_cases:
            with self.subTest(a=a, b=b, expected=expected):
                result = add_numbers(a, b)
                self.assertEqual(result, expected)
                
    def test_string_operations(self):
        """文字列操作のパラメータ化テスト"""
        operations = {
            "hello": "HELLO",
            "world": "WORLD",
            "python": "PYTHON"
        }
        
        for input_str, expected in operations.items():
            with self.subTest(input=input_str):
                result = input_str.upper()
                self.assertEqual(result, expected)

カスタムアサーション

独自アサーションメソッド

class CustomAssertionsMixin:
    """カスタムアサーションのミックスイン"""
    
    def assertBetween(self, value, min_val, max_val, msg=None):
        """値が範囲内にあることをアサート"""
        if not (min_val <= value <= max_val):
            standardMsg = f'{value} not between {min_val} and {max_val}'
            self.fail(self._formatMessage(msg, standardMsg))
            
    def assertDictContainsSubset(self, subset, dictionary, msg=None):
        """辞書がサブセットを含むことをアサート"""
        missing = []
        mismatched = []
        
        for key, value in subset.items():
            if key not in dictionary:
                missing.append(key)
            elif dictionary[key] != value:
                mismatched.append(f'{key}: expected {value}, got {dictionary[key]}')
        
        if missing or mismatched:
            errors = []
            if missing:
                errors.append(f'Missing keys: {missing}')
            if mismatched:
                errors.append(f'Mismatched values: {mismatched}')
            
            standardMsg = '; '.join(errors)
            self.fail(self._formatMessage(msg, standardMsg))

class TestCustomAssertions(unittest.TestCase, CustomAssertionsMixin):
    
    def test_custom_assertions(self):
        """カスタムアサーションのテスト"""
        self.assertBetween(5, 1, 10)
        
        full_dict = {"a": 1, "b": 2, "c": 3}
        subset = {"a": 1, "b": 2}
        self.assertDictContainsSubset(subset, full_dict)

テスト設定とカバレッジ

設定ファイルとカバレッジ測定

# conftest.py または test_config.py
import unittest
import coverage
import sys

class TestRunner:
    def __init__(self):
        self.cov = coverage.Coverage()
        
    def run_tests_with_coverage(self):
        """カバレッジ測定付きでテスト実行"""
        self.cov.start()
        
        # テストの実行
        loader = unittest.TestLoader()
        suite = loader.discover('tests', pattern='test_*.py')
        runner = unittest.TextTestRunner(verbosity=2)
        result = runner.run(suite)
        
        self.cov.stop()
        self.cov.save()
        
        print("\nカバレッジレポート:")
        self.cov.report()
        self.cov.html_report(directory='htmlcov')
        
        return result.wasSuccessful()

if __name__ == '__main__':
    runner = TestRunner()
    success = runner.run_tests_with_coverage()
    sys.exit(0 if success else 1)