こんにちは。技術開発部データサイエンティストチームの西岡です。
前回の連載ではなぜユニットテストを書くのかについて述べました。
今回は実際にPythonのunittestモジュールを使用しながら、テストの書き方を説明しようと思います。
Pythonのユニットテストの話ではありますが、ユニットテストの思想自体は他言語でも通用するものでもありますし、Pythonの使用者以外の方も自身のよく使われる言語のユニットテストフレームワークと照らし合わせながら読んでいただければと思います。
なお、ここではPython3を使用しています。
1 2 |
$ python3 -V Python 3.4.3 |
テストケース
calc.pyはテスト対象のスクリプト、test_calc.pyはテストスクリプトです。
calc.py
1 2 |
def add_three(x): return x + 3 |
tests/test_calc.py
1 2 3 4 5 6 7 |
import unittest import calc class TestCalc(unittest.TestCase): def test_add_three(self): self.assertEqual(calc.add_three(5), 7) |
ディレクトリ構造は以下のようであるとします。
1 2 3 4 5 |
$ tree . ├── calc.py └── tests └── test_calc.py |
test_calc.pyでは、calc.pyのadd_three関数にある入力を渡し、その出力が期待通りであるかをテストしています。 これをテストケースと呼びます。
unittestには
- unittest.TestCaseクラスを継承したクラスにテストメソッドを記述
- テストメソッドはtest_*で始める
というルールがありますが、これさえ守ればコマンドラインからテストを実行することができます。
この例ではもちろん5+3≠7なので、
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
$ python3 -m unittest tests.test_calc F ====================================================================== FAIL: test_add_three (tests.test_calc.TestCalc) ---------------------------------------------------------------------- Traceback (most recent call last): File "/tmp/tests/test_calc.py", line 6, in test_add_three self.assertEqual(calc.add_three(5), 7) AssertionError: 8 != 7 ---------------------------------------------------------------------- Ran 1 test in 0.000s FAILED (failures=1) |
上のようにテストが失敗します。 テストが通ったときは下のような表示になります。
1 2 3 4 5 6 |
$ python3 -m unittest tests.test_calc . ---------------------------------------------------------------------- Ran 1 test in 0.000s OK |
テストフィクスチャ
ここで、Rectangleという長方形を表すクラスがあるとします。 Rectangleクラスには、
- 長方形の幅・高さを取得するgetメソッド
- 長方形の幅・高さを設定するsetメソッド
- 長方形の面積を取得するareaメソッド
があり、インスタンスの使用が終わるとfinalizeメソッドで幅と高さをNoneにしなければならないものとします。
階層構造
1 2 3 4 5 |
$ tree . ├── rect.py └── tests └── test_rect.py |
rect.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
class Rectangle(object): def __init__(self, width=None, height=None): self._width = width self._height = height def get(self): return (self._width, self._height) def set(self, width, height): self._width = width self._height = height def area(self): return self._width * self._height def finalize(self): self._width = None self._height = None |
tests/test_rect.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import unittest import rect class TestRect(unittest.TestCase): def test_get(self): rec = rect.Rectangle(5, 3) self.assertTupleEqual(rec.get(), (5, 3)) rec.finalize() def test_set(self): rec = rect.Rectangle(5, 3) rec.set(8, 8) self.assertEqual(rec.get(), (8, 8)) rec.finalize() def test_area(self): rec = rect.Rectangle(5, 3) self.assertEqual(rec.area(), 15) rec.finalize() |
という感じになるかと思います。
しかし、これではrec = rect.Rectangle(5, 3)
とrec.finalize()
を全テストメソッドで書かなければならず、非常に手間です。
unittestでは、このようなテストに伴って発生する初期化処理・終了処理をまとめるためにsetUp・tearDownメソッドを使用します。
tests/test_rect.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import unittest import rect class TestRect(unittest.TestCase): def setUp(self): print('setUp') self._rec = rect.Rectangle(5, 3) def test_get(self): self.assertTupleEqual(self._rec.get(), (5, 3)) def test_set(self): self._rec.set(8, 8) self.assertTupleEqual(self._rec.get(), (8, 8)) def test_area(self): self.assertEqual(self._rec.area(), 15) def tearDown(self): print('tearDown') self._rec.finalize() |
これにより初期化・終了処理をまとめることができ、すっきりしたテストコードになります。
ただし、setUpとtearDownによってインスタンスの生成が1回にまとまったわけではないことに注意が必要です(以下の実行結果を参照)。
1 2 3 4 5 6 7 8 9 10 11 12 |
$ python3 -m unittest tests.test_rect setUp tearDown .setUp tearDown .setUp tearDown . ---------------------------------------------------------------------- Ran 3 tests in 0.001s OK |
これはテストが以前におこなわれた何らかの実行に依存しないようにするためです。
テストの書き方
テスト対象の関数が複数のことをおこなう場合
テストの書き方は、テスト対象のコードにより大きく書き方が変わります。
以下の関数をどうテストすればよいかを考えてみましょう。
1 2 3 4 5 6 |
def calc_average_bmi(all): total = 0 for person in all: bmi = person.weight/(person.height**2) total += bmi return total/len(all) |
このcalc_average_bmi
関数では、全員のBMIの平均を返します(BMIが何かについては、こちらのサイトを参照)。
この関数をテストする際に問題が起こりうる箇所は2つあります。
-
返される平均の値が正しくないとき、BMI計算が間違っているのか平均計算が間違っているのか特定できない
-
引数に空の配列が渡された時、平均計算においてZeroDivisionErrorになる
-
person.height == 0のときにBMI計算においてZeroDivisionErrorになる
下2つの「引数に空の配列が渡される」「身長が0である」といった例外はcatchすれば済みますが、そもそもcalc_average_bmi
関数は
- 平均計算
- BMI計算
の2つのことをやっているため、関数自身はどんどん読みづらく、肥大化していきます。
そこで、BMI計算と平均計算を分離しましょう。
1 2 3 4 5 |
$ tree . ├── bmi.py └── tests └── test_bmi.py |
bmi.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
def avg(summation, number): if number == 0: raise ValueError return summation/number def bmi(height, weight): if height == 0: raise ValueError return weight/(height**2) def calc_average_bmi(all): total = 0 for person in all: total += bmi(person.height, person.weight) return avg(total, len(all)) |
test_bmi.py
1 2 3 4 5 6 7 8 9 10 11 |
import unittest import bmi class TestBMI(unittest.TestCase): def test_avg(self): self.assertRaises(ValueError) self.assertEqual(bmi.avg(960, 8), 120) def test_bmi(self): self.assertRaises(ValueError) self.assertEqual(bmi.calc_bmi(1.7, 65)) |
と分離すれば、bmi計算と平均計算は各々のメソッドで完結し、calc_average_bmi
はほぼユニットテストを書く必要もないほど単純なコードになりました。
ここでは省きましたが、身長の単位がセンチメートルかメートルかもこのメソッドの中でテストすることができます。
メソッドに1つのことだけをさせるように変更すれば、テストもその1つのことだけを確認すればよくなるため、テストが非常に書きやすくなり、またテスト漏れも防ぐことができます。
プログラムをテスタブルにするため、テストが書きやすいようにプロダクションコードの設計から見直すことが重要です。
外部サービスのテスト
ここでいう外部サービスとは、データベースやWeb APIなどです。
git commit
時のテストで毎回データベースを丸々スキャンするようなクエリを流すわけにもいきませんし、外部APIは(あまり無いかとは思いますが)落ちている可能性もあるので、外部サービスとの連携テストにはモックオブジェクトを使用するのが一般的です。
今回はモックを使用したテストについては述べませんが、いずれ取り上げてみたいと思います。
まとめ
テスタブルなコードを書くことでバグを減らし、より高速な開発をおこなっていきましょう。