蛇ノ目の記

技術のことも。そうでないことも。

assertRaises()で嵌った話

今、テストコードを書いている。前職ではテストコードを書く文化が無かったのでこれが初めて。ユニットテストフレームワークunittestを使っている。

これは初めてユニットテストを書いた男が数時間同じ箇所で嵌り続けたという話だ。格好悪い。

まず、assertRaisesとは何か。公式ドキュメントには、

assertRaises(exception, callable, *args, **kwds)

callable を呼び出した時に例外が発生することをテストします。

とある。これを読んですぐにテストを書き始めた。要するにexceptionにテストで検出したい例外、callableにテスト対象の関数を書けばいいんだろ、というぐらいの勢いで。じゃあ*args **kwargsは何のためにあるんだよ、と今になって思う。

ここからは簡単のため、単純に除算をするだけの関数(division.py)を例としていく。

def division(dividend, divisor):
    try:
        answer = dividend // divisor
    except ZeroDivisionError:
        return None
    return answer

という関数をテストしたい。除算の結果を返すだけだけど、ZeroDivisionErrorではNoneを返す。というわけでテストを書いた。余談だけど_call_fut()でテスト対象関数を呼び出す方法は@aodagさんの効果的なunittest - または、callFUTの秘密で学んだ。

import unittest

class TestDivision(unittest.TestCase):

    def _call_fut(self, *args):
        from division import division
        return division(*args)

    def test_valid_answer(self):
        actual = 5
        self.assertEqual(actual, self._call_fut(25, 5))

    def test_zero_division_error(self):
        self.assertRaises(ZeroDivisionError, self._call_fut(25, 0))

では、こいつを実行するとどうなるか。

assertRaises_practice/tests/test_division.py:16: DeprecationWarning: callable is None
self.assertRaises(ZeroDivisionError, self._call_fut(25, 0))

こうなる。callableがNoneだぞと言っている。このcallableがわからずに2時間くらい嵌り続けた。テスト対象関数の戻り値をNone以外にもしたし、raiseさせてみたりもした。関数の戻り値をraiseにすると、

ZeroDivisionError: division by zero

こうなる。ZeroDivisionErrorが発生することを確認するテストがZeroDivisionErrorで落ちる。これはひどい

ここらの足掻きをひたすら書くのも冗長に過ぎるので、先輩方に助けによって得た学びを以て、一足飛びに結論に行く。

実は先のZeroDivisionErrorになってしまう件によって、どこがおかしいか気付きやすくなった。例外で落ちるということはassertRaises()の中で呼び出されて欲しいdivision(25, 0)が先に実行されているということになる。そしてassertRaises()が失敗するということはこれはcallableではない。オブジェクトが呼び出し可能オブジェクト (=callable)であるか判定してくれる組み込み関数callableで確認してみる。

>>> def division(divided, divisor):
...     try:
...             answer = divided/divisor
...     except ZeroDivisionError:
...             raise
...     return answer
>>> 
>>> callable(division(25,1))
False

(エラーが出てしまうため引数は変えている)

division(25,1)callableではないことがわかった。公式ドキュメントを見てみよう。

なお、クラスは呼び出し可能 (クラスを呼び出すと新しいインスタンスを返します) です。また、インスタンスはクラスが __call__() メソッドを持つなら呼び出し可能です。

とある。つまりクラスではないdivision(25, 1) = float型のオブジェクト__call__()メソッドを持っていないということだろうか。float型のインスタンスであること、__call__()を持たないことを 確認してみる。

>>> answer = division(25, 1)
>>> isinstance(answer, float)
True
>>> '__call__' in dir(answer)
False

これで間違いがはっきりした。assertRaises()には、テストしたい関数を引数を与えた状態で入れてはいけない。

ではどうすればいいのか。俺はどうしてもdivision()をテストしたいんだ。ならば引数を渡さない形にするとどうだろう。

>>> callable(division)
True

ようやくたどり着いた。callableには関数を入れるべきだった。正しいアサーションはこうなる。

self.assertRaises(ZeroDivisionError, self._call_fut, 25, 0)

ここまで来るのに随分掛かった……。

他のアサーションではself.assertEqual(1, self._call_fut(5, 5))のようにメソッドの中に戻り値を入れるけど、assertRaises()の引数callableは先に実行されるとテストメソッドが例外で落ちてしまう。つまり、assertRaises()が実行されてから、引数callableを実行してほしいからこうなるということ?

最後に念の為、divisionの有効な属性を確認しておく。

>>> dir(division)
['__annotations__', '__call__', '__class__', '__closure__', '__code__', '__defaults__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__get__', '__getattribute__', '__globals__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__kwdefaults__', '__le__', '__lt__', '__module__', '__name__', '__ne__', '__new__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__']

__call____class__があるけど、クラスとして呼び出し可能なのか、__call__()メソッドを持つインスタンスとして呼び出し可能なのか……。謎が残ってしまった。