nose2

単体テストPythonテストフレームワークunittest拡張プラグインパラメータ化

nose2

概要

nose2は、元のnoseテストフレームワークの後継として開発された、unittest2プラグインブランチをベースとしたPythonテストフレームワークです。より優れたプラグインAPIを提供し、内部プロセスとインターフェースを簡素化しています。unittest標準ライブラリをベースとしているため、unittestのドキュメントから始めて、nose2でその上に価値を追加できます。

詳細

主要な特徴

プラグインシステム

  • 多数のプラグインがnose2モジュールに直接組み込まれ、デフォルトで読み込まれる
  • テストパラメータ化、フィクスチャのレイヤー化、ログメッセージのキャプチャ、レポートを支援

テスト発見と実行

  • test で始まる名前のPythonファイルでテストを検索し、発見したすべてのテスト関数を実行
  • 自動テスト発見をサポート

拡張されたテストサポート

  • noseよりも多くの種類のパラメータ化テストとジェネレータテストをサポート
  • unittest TestCaseサブクラスのテスト関数、テストクラス、すべてのテストジェネレータをサポート
  • テストパラメータ化のために別パッケージのインストールが不要

設定駆動型アプローチ

  • 設定ファイルによるほぼ全ての設定を期待
  • プラグインは一般的に一つのコマンドラインオプションのみ(プラグインを有効化するオプション)
  • より再現可能なテスト実行とコマンドラインオプションセットの簡素化

メリット・デメリット

メリット

  1. unittest互換性: Python標準ライブラリのunittestとの完全な互換性
  2. 豊富なプラグイン: デフォルトで多くの有用なプラグインを提供
  3. パラメータ化テスト: 追加パッケージ不要でテストパラメータ化をサポート
  4. 設定の柔軟性: 設定ファイルによる詳細なカスタマイズが可能
  5. 段階的移行: 既存のunittestベースのテストに段階的に導入可能

デメリット

  1. メンテナンス状況: 2024年時点で積極的なメンテナンスが減少
  2. コミュニティサポート: pytestと比較するとコミュニティサポートが限定的
  3. 新規プロジェクトでの推奨度: メンテナーからpytestが新規プロジェクトで推奨される
  4. パッケージレベルフィクスチャ: unittest2と同様、パッケージレベルフィクスチャは非サポート

参考ページ

書き方の例

基本的なテストクラス

import unittest

class TestCalculator(unittest.TestCase):
    
    def setUp(self):
        """各テストメソッドの前に実行"""
        self.calculator = Calculator()
    
    def test_addition(self):
        """加算のテスト"""
        result = self.calculator.add(5, 3)
        self.assertEqual(result, 8)
    
    def test_subtraction(self):
        """減算のテスト"""
        result = self.calculator.subtract(10, 4)
        self.assertEqual(result, 6)
    
    def test_division_by_zero(self):
        """ゼロ除算のテスト"""
        with self.assertRaises(ZeroDivisionError):
            self.calculator.divide(10, 0)
    
    def tearDown(self):
        """各テストメソッドの後に実行"""
        self.calculator = None

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

関数ベースのテスト

def test_string_operations():
    """文字列操作のテスト"""
    text = "Hello, World!"
    assert "World" in text
    assert len(text) == 13
    assert text.startswith("Hello")

def test_list_operations():
    """リスト操作のテスト"""
    numbers = [1, 2, 3, 4, 5]
    assert len(numbers) == 5
    assert 3 in numbers
    assert max(numbers) == 5

def test_dictionary_operations():
    """辞書操作のテスト"""
    user_data = {"name": "Alice", "age": 30, "email": "[email protected]"}
    assert user_data["name"] == "Alice"
    assert "age" in user_data
    assert len(user_data) == 3

パラメータ化テスト

import unittest
from nose2.tools import params

class TestMathOperations(unittest.TestCase):
    
    @params(
        (2, 3, 5),
        (10, 15, 25),
        (-1, 1, 0),
        (0, 0, 0),
    )
    def test_addition_params(self, a, b, expected):
        """パラメータ化された加算テスト"""
        calculator = Calculator()
        result = calculator.add(a, b)
        self.assertEqual(result, expected)
    
    @params(
        (10, 2, 5),
        (15, 3, 5),
        (100, 10, 10),
    )
    def test_division_params(self, dividend, divisor, expected):
        """パラメータ化された除算テスト"""
        calculator = Calculator()
        result = calculator.divide(dividend, divisor)
        self.assertEqual(result, expected)

# 関数ベースのパラメータ化テスト
@params(
    ("hello", "world", "hello world"),
    ("foo", "bar", "foo bar"),
    ("", "test", " test"),
)
def test_string_concatenation(first, second, expected):
    """パラメータ化された文字列連結テスト"""
    processor = StringProcessor()
    result = processor.concatenate(first, second)
    assert result == expected

レイヤーフィクスチャ

import unittest
from nose2 import layers

class DatabaseLayer(layers.Layer):
    """データベースレイヤーフィクスチャ"""
    
    def setUp(self):
        """レイヤーセットアップ - 一度だけ実行"""
        self.connection = create_test_database()
        self.connection.execute("CREATE TABLE users (id INT, name TEXT)")
        print("データベースを初期化しました")
    
    def tearDown(self):
        """レイヤー終了処理 - 一度だけ実行"""
        self.connection.close()
        print("データベースを閉じました")

# データベースレイヤーのインスタンス作成
database_layer = DatabaseLayer()

class TestUserRepository(unittest.TestCase):
    
    layer = database_layer  # レイヤーを指定
    
    def setUp(self):
        """各テストの前に実行"""
        self.repository = UserRepository(self.layer.connection)
        # テストデータをクリーンアップ
        self.layer.connection.execute("DELETE FROM users")
    
    def test_create_user(self):
        """ユーザー作成のテスト"""
        user = User(name="Alice", email="[email protected]")
        user_id = self.repository.create(user)
        self.assertIsNotNone(user_id)
        
        # データベースから取得して確認
        saved_user = self.repository.get_by_id(user_id)
        self.assertEqual(saved_user.name, "Alice")
    
    def test_get_user_not_found(self):
        """存在しないユーザーの取得テスト"""
        user = self.repository.get_by_id(999)
        self.assertIsNone(user)

ジェネレータテスト

def test_number_operations():
    """ジェネレータテストの例"""
    test_cases = [
        (lambda x: x * 2, 5, 10),
        (lambda x: x ** 2, 4, 16),
        (lambda x: x + 10, 15, 25),
    ]
    
    for operation, input_val, expected in test_cases:
        yield check_operation, operation, input_val, expected

def check_operation(operation, input_val, expected):
    """個別のテストケース実行"""
    result = operation(input_val)
    assert result == expected, f"Expected {expected}, got {result}"

def test_string_validations():
    """文字列検証のジェネレータテスト"""
    email_validator = EmailValidator()
    
    valid_emails = [
        "[email protected]",
        "[email protected]",
        "[email protected]"
    ]
    
    invalid_emails = [
        "invalid-email",
        "missing-at-sign.com",
        "@missing-local.com"
    ]
    
    # 有効なメールアドレスのテスト
    for email in valid_emails:
        yield check_valid_email, email_validator, email
    
    # 無効なメールアドレスのテスト
    for email in invalid_emails:
        yield check_invalid_email, email_validator, email

def check_valid_email(validator, email):
    """有効なメールアドレスの確認"""
    assert validator.is_valid(email), f"Email {email} should be valid"

def check_invalid_email(validator, email):
    """無効なメールアドレスの確認"""
    assert not validator.is_valid(email), f"Email {email} should be invalid"

nose2設定ファイル

# nose2.cfg または unittest.cfg
[unittest]
start-dir = tests
code-directories = src
test-file-pattern = test_*.py

# テスト実行オプション
verbosity = 2
catch = True
buffer = True

# プラグイン設定
[log-capture]
always-on = True
clear-handlers = True
filter = -nose2
log-level = INFO

[coverage]
always-on = True
coverage = myproject
coverage-report = html
coverage-config = .coveragerc

[multiprocess]
always-on = False
processes = 4

[parameterized]
always-on = True

[printhooks]
always-on = False

高度なテスト例

import unittest
import tempfile
import os
from unittest.mock import patch, MagicMock

class TestFileProcessor(unittest.TestCase):
    
    def setUp(self):
        """テスト用一時ディレクトリ作成"""
        self.temp_dir = tempfile.mkdtemp()
        self.processor = FileProcessor()
    
    def tearDown(self):
        """一時ディレクトリのクリーンアップ"""
        import shutil
        shutil.rmtree(self.temp_dir)
    
    def test_process_file_success(self):
        """ファイル処理成功のテスト"""
        # テストファイル作成
        test_file = os.path.join(self.temp_dir, "test.txt")
        with open(test_file, "w") as f:
            f.write("Hello, World!")
        
        result = self.processor.process_file(test_file)
        self.assertTrue(result.success)
        self.assertEqual(result.lines_processed, 1)
    
    def test_process_nonexistent_file(self):
        """存在しないファイルの処理テスト"""
        nonexistent_file = os.path.join(self.temp_dir, "missing.txt")
        
        with self.assertRaises(FileNotFoundError):
            self.processor.process_file(nonexistent_file)
    
    @patch('mymodule.external_service')
    def test_process_with_external_service(self, mock_service):
        """外部サービスをモックしたテスト"""
        # モックの設定
        mock_service.validate_content.return_value = True
        mock_service.upload_content.return_value = {"status": "success", "id": "123"}
        
        # テストファイル作成
        test_file = os.path.join(self.temp_dir, "upload_test.txt")
        with open(test_file, "w") as f:
            f.write("Content to upload")
        
        result = self.processor.process_and_upload(test_file)
        
        # アサーション
        self.assertTrue(result.uploaded)
        self.assertEqual(result.upload_id, "123")
        
        # モックの呼び出し確認
        mock_service.validate_content.assert_called_once()
        mock_service.upload_content.assert_called_once()
    
    def test_batch_processing(self):
        """バッチ処理のテスト"""
        # 複数のテストファイル作成
        test_files = []
        for i in range(3):
            file_path = os.path.join(self.temp_dir, f"batch_{i}.txt")
            with open(file_path, "w") as f:
                f.write(f"Content {i}")
            test_files.append(file_path)
        
        results = self.processor.process_batch(test_files)
        
        self.assertEqual(len(results), 3)
        for result in results:
            self.assertTrue(result.success)

カスタムアサーションとヘルパー

import unittest

class CustomAssertions:
    """カスタムアサーションのミックスイン"""
    
    def assertEmailValid(self, email):
        """メールアドレスの妥当性を確認"""
        import re
        pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
        if not re.match(pattern, email):
            raise AssertionError(f"'{email}' is not a valid email address")
    
    def assertDateInRange(self, date_obj, start_date, end_date):
        """日付が指定範囲内にあることを確認"""
        if not (start_date <= date_obj <= end_date):
            raise AssertionError(
                f"Date {date_obj} is not between {start_date} and {end_date}"
            )
    
    def assertDictContainsSubset(self, subset, dictionary):
        """辞書がサブセットを含むことを確認"""
        for key, value in subset.items():
            if key not in dictionary:
                raise AssertionError(f"Key '{key}' not found in dictionary")
            if dictionary[key] != value:
                raise AssertionError(
                    f"Value for key '{key}': expected {value}, got {dictionary[key]}"
                )

class TestUserService(unittest.TestCase, CustomAssertions):
    
    def setUp(self):
        self.user_service = UserService()
    
    def test_create_user_with_valid_email(self):
        """有効なメールアドレスでのユーザー作成"""
        user_data = {
            "name": "John Doe",
            "email": "[email protected]",
            "age": 30
        }
        
        user = self.user_service.create_user(user_data)
        
        # カスタムアサーションの使用
        self.assertEmailValid(user.email)
        self.assertDictContainsSubset(user_data, user.to_dict())
        
        # 標準アサーション
        self.assertEqual(user.name, "John Doe")
        self.assertIsNotNone(user.created_at)