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で整えることにしたが、大まかな考え方は変わっていない。
- ファンクション単位のテストケース
- 結果にフォーカスしてアサート
- 前処理・後処理はテスト関数から分離する。