Python unittestのベストプラクティス

Pythonユニットテストについて、現状の結論(ベストプラクティス)を記したいと思う。

使用するフレームワーク

標準モジュールのunittestを使用する。 pytestという有名なライブラリも存在するが、ライブラリ管理の手間が減るため標準モジュールで行けるところはそれでいきたい。 unittestで対応できない場面に遭遇したら他のライブラリも検討してみようと思う。

ルール

Pythonは自由度が高く、型も含めて制約がほとんどないのが特徴だが、その自由度の高さゆえに自分でルールを設けなければコードから統一感が喪失してしまう。 統一性のないコードは人間の推測する能力(いまやコンピュータでもできるが。。。)を活かせないため、1か月後には解読不能なコードになってしまう。 それを避けるためにユニットテストについて、いくつかルールを設けた。

関数ごとにテストケースを作成する

Test_関数名という名前でテストケースクラスを設計する。

例えば、get_name()という関数のテストケースであればTest_get_nameのようにテストケースを実装する。

from unittest import TestCase

class Test_get_name:
    
    def test_XX(self) -> None:
        # test here

メソッドであればTestクラス名_メソッド名でテストケースを作成する。

from unittest import TestCase

class TestMyClass_method(TestCase):
    
    def test_XX(self) -> None:
        # test here

これはクラス名はパスカルケースというPythonの慣例に背く形となるが、そうであってもこのようにした理由がいくつかある。

チュートリアルコードなどではclass TestMyClass(TestCase):とクラスごとにテストケースとする例が多くみられるが、その設計はメソッドが多くなると破綻してしまう。 新しいテスト関数を追加するときも、メソッドの順番を無視すれば保守性が崩壊して、順番を意識して実装すればその順番を探すのに時間がかかってしまう。 そこでclass Testクラス名_メソッド名(TestCase):とすることで関数ごとにテストケースを管理するようにした。

ここで、class TestMyClassGetName(TestCase):のように全てパスカルケースでクラスを定義してしまうと、どこまでがクラス名でどこからメソッド名かわからなくなってしまう。 そこで_でクラス名とメソッド名を区切ることにして、それに合わせてメソッド名もスネークケースのままで記すことにした。 統一性を持たせるために、ただの関数の場合もclass Test_関数名(TestCase):となったのである。

プロパティのテストはTestCaseクラス名_propertyで行う

@propertyデコレータのついた関数のテストはTestCaseクラス名_property(TestCase)で行う。 一つのプロパティに対するテストは戻り値のチェックだけなので、それらをまとめて一つのテストケースにする。

アサーションは"結果"に対して行う

テスト関数の中でアサーションは戻り値や送出された例外など、関数呼び出しの"結果"に対して行う。 関数内部のif分岐やロジックに対してはアサーションを行わない。

関数内部のif文についてもテストを書けばコードはより堅牢になるが、実装への依存度が高くなるのでテストしない。

クリーンアップ処理はテスト関数から切り離す

副作用のある関数(ファイルダンプなど)のクリーンアップ処理をテスト関数内に書くと、どこがテストの本質なのか見えにくくなってしまう。 クリーンアップが必要なテストはテストケースとしてまとめてunittest.TestCase.tearDown()でクリーンアップ処理を行う。

チュートリアル

以上のルールを踏まえて、tutorial.pyモジュールのユニットテストtest_tutorial.pyを実装してみる。

tutorial.py

from __future__ import annotations
from math import lcm
from typing import Union


def add_positive_integers(x: int, y: int) -> int:
    if x <= 0:
        raise ValueError('`x` must be a positive number.')
    if y <= 0:
        raise ValueError('`y` must be a positive number.')
    return x + y



class Rational:
    _numerator: int
    _denominator: int

    def __init__(self, numerator: int, denominator: int) -> None:
        if denominator == 0:
            raise ValueError('`denominator` must be non-zero.')

        self._numerator = numerator
        self._denominator = denominator


    @property
    def numerator(self) -> None:
        return self._numerator

    
    @property
    def denominator(self) -> None:
        return self._denominator

    
    def __add__(self, other: Union[int, Rational]) -> Rational:
        if isinstance(other, Rational):
            denominator = lcm(self.denominator, other.denominator)
            n = self.numerator * denominator // self.denominator
            m = other.numerator * denominator // other.denominator
            numerator = n + m
            return Rational(numerator, denominator)
        elif isinstance(other, int):
            return self + Rational(other, 1)
        else:
            return NotImplemented

tests/test_tutorial.py

from unittest import TestCase
import tutorial

class Test_add_positive_integers(TestCase):

    def test_two_positives(self) -> None:
        x, y = 1, 2
        actual = tutorial.add_positive_integers(x, y)
        expect = x + y
        self.assertEqual(actual, expect)


    def test_zero(self) -> None:
        with self.assertRaises(ValueError):
            tutorial.add_positive_integers(0, 1)
        with self.assertRaises(ValueError):
            tutorial.add_positive_integers(1, 0)

    
    def test_negative(self) -> None:
        with self.assertRaises(ValueError):
            tutorial.add_positive_integers(-1, 1)
        with self.assertRaises(ValueError):
            tutorial.add_positive_integers(1, -1)
    


class TestRational___init__(TestCase):

    def test_numeator(self) -> None:
        numerator = 1
        denominator = 2
        r = tutorial.Rational(numerator, denominator)
        self.assertEqual(r._numerator, numerator)


    def test_denominator(self) -> None:
        numerator = 1
        denominator = 2
        r = tutorial.Rational(numerator, denominator)
        self.assertEqual(r._denominator, denominator)
    

    def test_denominator_is_zero(self) -> None:
        numerator = 1
        denominator = 0
        with self.assertRaises(ValueError):
            tutorial.Rational(numerator, denominator)



class TestRational_property(TestCase):

    def setUp(self) -> None:
        self.r = tutorial.Rational(1, 2)

    
    def test_numerator(self) -> None:
        self.assertEqual(self.r.numerator, 1)
    

    def test_denominator(self) -> None:
        self.assertEqual(self.r.denominator, 2)



class TestRational___add__(TestCase):

    def test_add_rational(self) -> None:
        r1 = tutorial.Rational(1, 2)
        r2 = tutorial.Rational(2, 3)
        actual = r1 + r2
        expect = tutorial.Rational(7, 6)
        self.assertEqual(actual.numerator, expect.numerator)
        self.assertEqual(actual.denominator, expect.denominator)

    
    def test_add_integer(self) -> None:
        r1 = tutorial.Rational(1, 2)
        actual = r1 + 2
        expect = tutorial.Rational(5, 2)
        self.assertEqual(actual.numerator, expect.numerator)
        self.assertEqual(actual.denominator, expect.denominator)


    def test_add_None(self) -> None:
        r1 = tutorial.Rational(1, 2)
        with self.assertRaises(TypeError):
            r1 + None

実行

coverageを通して実行する

$ pip install coverage
$ coverage run -m unittest tests.test_tutorial
...........
----------------------------------------------------------------------
Ran 11 tests in 0.003s
$ coverage report
Name                     Stmts   Miss  Cover
--------------------------------------------
tests\__init__.py            1      0   100%
tests\test_tutorial.py      59      0   100%
tutorial.py                 33      0   100%
--------------------------------------------
TOTAL                       93      0   100%

追記(2022/09/30)

結局pytestで整えることにしたが、大まかな考え方は変わっていない。

  • ファンクション単位のテストケース
  • 結果にフォーカスしてアサート
  • 前処理・後処理はテスト関数から分離する。