🐙

pythonのunittestのコア部分を自作してみる

2024/07/02に公開

はじめに

python の組み込みのテストライブラリであるunittestを自作してみました。

といってもすべての機能ではなく、
具体的には、以下の画像のように実行したテスト件数と失敗したテスト件数が表示される、テストライブラリのコア部分のみの実装になります。

元はというと、ソフトウェアエンジニアの世界では超有名な本「テスト駆動開発」を読んでみたのがきっかけでした。
この本の第3章「xUnit」でまさにunittestのコア部分を TDD で実装するのですが、
その内容をより python のunittestに似せた形にしたのと、型ヒントの付与などのリファクタリングを加えました。
(あとは python でクラスのバリデーションを強化するpydanticに最近ハマっているのでそれも適用しました)

コードはすべてこちらの github リポジトリに格納しています。

実装したコード

使い方

今回スクラッチで作成したのがmy_unittestというパッケージです。

先に使い方を載せた方がわかりやすいと思うのでそうします。
見てわかるとおり、 組み込みのunittestとほぼ同じ使い方になっています。

sample.py
import my_unittest


# テスト対象の関数
def add(x: int, y: int) -> int:
    return x + y


class TestSample(my_unittest.TestCase):
    def setUp(self):
        # setUpの処理が必要な場合はここに追記
        ...

    def tearDown(self):
        # tearDownの処理が必要な場合はここに追記
        ...

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

    def test_add_failed(self):
        assert add(1, 2) == 2


if __name__ == "__main__":
    my_unittest.main(TestSample)

コマンドラインで実行すると以下のようになります。

my_unittestパッケージの中身

まずは、ロジックのコアとなるTestCaseクラスです。

インターフェイスとしてITestCaseを定義している点と、
getattr(test_case, test_case.name)の箇所でメソッド名を動的に取得している点がポイントです。

(ただし、getattrはコードの静的解析が難しくなるので多用しない方が良いらしい)

TestCase.py
import abc

from pydantic import BaseModel

from my_unittest.TestResult import TestResult


class ITestCase(abc.ABC):
    name: str

    @abc.abstractmethod
    def setUp(self): ...

    @abc.abstractmethod
    def tearDown(self): ...

    @abc.abstractmethod
    def run(self, result: TestResult, test_case: "ITestCase") -> TestResult: ...


class TestCase(BaseModel, ITestCase):
    """ロジックのコアとなるクラス"""

    name: str

    def setUp(self): ...

    def tearDown(self): ...

    def run(self, result: TestResult, test_case: ITestCase) -> TestResult:
        result.testStarted()
        test_case.setUp()
        try:
            method = getattr(test_case, test_case.name)
            method()
        except Exception as e:
            print(e)
            result.testFailed()
        test_case.tearDown()
        return result

テストの実行結果を保存する責務をもつTestResultクラスです。

TestResult.py
from pydantic import BaseModel


class TestResult(BaseModel):
    run_count: int = 0
    failed_count: int = 0

    def testStarted(self):
        self.run_count += 1

    def testFailed(self):
        self.failed_count += 1

    def summary(self):
        return f"{self.run_count} run, {self.failed_count} failed"

複数のテストをまとめて実行できるようにするTestSuiteクラスです。

TestSuite.py
from my_unittest.TestCase import ITestCase
from my_unittest.TestResult import TestResult
from pydantic import BaseModel


class TestSuite(BaseModel):
    tests: list[object] = []

    def add(self, test: ITestCase):
        self.tests.append(test)

    def run(self, result: TestResult):
        [t.run(result, t) for t in self.tests if isinstance(t, ITestCase)]
        return result

テストを実行するmain関数です。

main.py
from typing import Type

from my_unittest.TestCase import TestCase
from my_unittest.TestResult import TestResult
from my_unittest.TestSuite import TestSuite

HR_LINE = "=" * 40


def main(cls: Type[TestCase]):
    print(HR_LINE + " test session starts " + HR_LINE)
    suite = TestSuite()
    for method in get_test_methods(cls):
        suite.add(cls(name=method))
    result = TestResult()
    suite.run(result)
    print(HR_LINE + f" {result.summary()} " + HR_LINE)

def get_test_methods(cls: object):
    return [
        method_name
        for method_name in cls.__dict__
        if method_name.startswith("test") and callable(getattr(cls, method_name))
    ]

おわりに

python でunittestのコア部分をスクラッチで実装してみました。

個人的にはなかなか面白い実装だなと感じました。
(ただ、やっぱり「テスト駆動開発」の内容で行われているテストライブラリをテスト駆動で開発する、という試みは TDD の初学者には挑戦的すぎるよな、というのも感じました。)

また、オブジェクト指向の色が強い実装になっているので、関数型のパラダイムの言語ではテストフレームワークはどのような実装になっているのか気になりました。

Discussion