📝

【学習メモ】pythonのテストその1--doctest、unittest--

に公開

テストの仕組みっていまいちわかってないので、ちゃんとわかりたいなあと思いました

テストを行う理由

テストとは 「バグを早期に発見し、既存機能を守り、安心してコードを改善するための仕組み」

  1. 仕様と実装のズレをすぐに検知できる
    • テストを行うことで、間違った計算ロジックや型の不一致・例外処理漏れを早期発見できる
  2. 既存機能が壊れていないことを保証する
    • 新しい機能を追加するなどしたときに、テストを行うことで「既存機能が壊れていないか?」「前と同じ動作をするか?」を簡単に確認できる。
  3. 仕様の明文化になる
    • 対象の関数が、「どんな入力を想定しているか」「どんな出力を返すべきか」がテストコードを見て分かるようになる。
    • テストコードを見れば関数の動作や使い方が分かるので、仕様の認識ズレが起きにくくなる。

doctest

実務向きではないけど、docstringがそのままテストになるので
ちょっとした関数のテストや教育用サンプルコードのテストに便利。

def add(a, b):
    """
    2つの数値を加算する関数

    >>> add(10, 2)
    12
    >>> add(10, -2)
    8
    """
    return a + b


if __name__ == "__main__":
    import doctest
    doctest.testmod()

テストの流れ

  1. docstringに実行例を書く
    • >>> add(1, 2)のように、pythonの対話モードを同じ形式で実行例を書く。
    • その直後の行に、期待される戻り値を書く。
      >>>の後の空白行か、その次の>>>までを出力結果として認識
  2. doctest.testmod()を呼び出してdocstringをスキャン
    • testmod()はモジュール内のすべてのdocstringを読み取り、>>>で始まる行をテストとして実行する。
  3. 実行結果が一致すれば成功
    • スクリプトを実行して、すべてのテストが成功していれば何も表示されない。
      ※スクリプト実行時に-vオプションをつけると、成功時でもログが表示される
  4. 失敗すると差分が表示される
    • もし関数の実装が間違っていた場合、Expected(期待値) と Got(実際の値) が表示される。

以下はテストを失敗させてみた例。

def add(a, b):
    """
    2つの数値を加算する関数

    >>> add(10, 2)
    12
    >>> add(10, -2)
    8
    """
    return a * b    # 乗算に変更


if __name__ == "__main__":
    import doctest
    doctest.testmod()

↓ 実行結果

**********************************************************************
File "(実行ファイル).py", line 5, in __main__.add
Failed example:
    add(10, 2)
Expected:
    12
Got:
    20
**********************************************************************
File "(実行ファイル).py", line 7, in __main__.add
Failed example:
    add(10, -2)
Expected:
    8
Got:
    -20
**********************************************************************
1 item had failures:
   2 of   2 in __main__.add
***Test Failed*** 2 failures.

docstringに直接テストをかけるので、ドキュメントとコードの剥離が起きづらく、関数の挙動がわかりやすくなる。APIの動作保証などに使用される場合もある。
ちなみにdoctestは 完全一致 でないとテストが成功しない。空白・改行・空行が含まれていたり、「集合」などの並び順が保証されないような出力結果となるテストには向かない。

unittest

pythonの標準ライブラリ。

テスト対象のコード(addition.py)

def add(a, b):
    return a + b

unittestのテストコード(test_addition.py)

import unittest
from addition import add

class AddTest(unittest.TestCase):
    def test_add_two_integers_positive(self):
        """2つの整数の合計値を計算
        
            プラスの値同士の計算テスト
        """
        actual = add(10, 2)
        expected = 12
        self.assertEqual(actual, expected)

        
    def test_add_two_integers_negative(self):
        """2つの整数の合計値を計算
        
            プラスとマイナスの計算テスト
        """
        actual = add(10, -2)
        expected = 8
        self.assertEqual(actual, expected)

if __name__ == "__main__":
    unittest.main()

↓ 実行結果

..
----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

テストの流れ

  1. テスト実行ファイルにfrom addition import addで、テストしたい関数をインポートする。
    ※同じファイルに書く場合はimportは不要
  2. unittest.TestCaseを継承したクラスを作成する。
    • class AddTest(unittest.TestCase)の部分がテストクラス。
      このテストクラスの中のtestで始まるメソッドがテストメソッドとなり、テストとして実行される。
  3. アサーションで期待値を検証する
    • アサーションメソッドを使用しているself.assertEqual(actual, expected)の部分で、期待値と実際の値が一致するかを確認
    • self.assertEqual(actual, expected)は、assertEqualに設定された第一引数と第二引数が等しいかどうかを確認するメソッド。
      つまり、actual == expectedであればテスト通過となる。
       ※アサーションメソッドについては他にもいろいろあるので後述
  4. 実行するとテスト結果がターミナルに表示される

上記実行結果から、2つのテスト(上記例だとtest_add_two_integers_positivetest_add_two_integers_negative)が実行され、問題なく通過したことが分かる。

テスト対象のコード(addition.py)を以下のように変更し、同じテストをしてみよう。

def add(a, b):
    return a * b # 加算を実装予定だったが、誤って乗算で実装した!(という想定)

↓ test_addition.pyの実行結果

FF
======================================================================
FAIL: test_add_two_integers_negative (__main__.AddTest.test_add_two_integers_negative)
2つの整数の合計値を計算
----------------------------------------------------------------------
Traceback (most recent call last):
  File "(ファイルパス)\test_addition.py", line 18, in test_add_two_integers_negative
    self.assertEqual(actual, expected)
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
AssertionError: -20 != 8

======================================================================
FAIL: test_add_two_integers_positive (__main__.AddTest.test_add_two_integers_positive)
2つの整数の合計値を計算
----------------------------------------------------------------------
Traceback (most recent call last):
  File "(ファイルパス)\test_addition.py", line 10, in test_add_two_integers_positive
    self.assertEqual(actual, expected)
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
AssertionError: 20 != 12

----------------------------------------------------------------------
Ran 2 tests in 0.002s

FAILED (failures=2)

加算が正しく行われているかを確認するためのテストなので、テストは失敗し、エラーメッセージが表示される。
スタックトレースを読むと、self.assertEqual(actual, expected)の部分でアサーションエラーが発生し、引数actualexpectedの値が一致しないためテストが失敗していることがわかる。

今回は期待値と実際の値の不一致によりテスト結果は「FAIL」となったが、
以下の場合はどうだろうか。

def add(a, b):
    a = str(a) # 数値が途中で文字列に変更されてしまった!(という想定)
    return a + b

↓ test_addition.pyの実行結果

EE
======================================================================
ERROR: test_add_two_integers_negative (__main__.AddTest.test_add_two_integers_negative)
2つの整数の合計値を計算
----------------------------------------------------------------------
Traceback (most recent call last):
  File "(ファイルパス)\test_addition.py", line 16, in test_add_two_integers_negative
    actual = add(10, -2)
  File "(ファイルパス)\addition.py", line 3, in add
    return a + b
           ~~^~~
TypeError: can only concatenate str (not "int") to str

======================================================================
ERROR: test_add_two_integers_positive (__main__.AddTest.test_add_two_integers_positive)
2つの整数の合計値を計算
----------------------------------------------------------------------
Traceback (most recent call last):
  File "(ファイルパス)\test_addition.py", line 8, in test_add_two_integers_positive
    actual = add(10, 2)
  File "(ファイルパス)\addition.py", line 3, in add
    return a + b
           ~~^~~
TypeError: can only concatenate str (not "int") to str

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (errors=2)

同じくテストは失敗となるが、結果が「ERROR」となる。発生しているエラーもAssertionError(値の不一致)ではなくTypeError(型が一致しない例外の発生)となっている。

種類 重要度 何が起きた? 原因の方向性
FAIL テストは実行できたが、assertionが失敗した ロジックのバグ / テストの期待値が間違い
ERROR テストの実行中に例外が発生し、テスト自体が成立しなかった コードの実行が破綻している(例外・設定ミス)

「FAIL」は 期待値が違う 、「ERROR」は そもそも動かない という違いがある。
テストでエラーとなった場合、ここに注意して見直すとよい。

subtest()を使用して、複数ケースを一度にテストする

例えば、第一引数は絶対値として計算させるメソッドがあるとする。

# addition.py
def add(a, b):
    a = abs(a)
    return a + b

テストを行う前段階として、すべて失敗するテストケースを実行させる。

# test_addition.py
import unittest
from addition import add

class TestAddFunction(unittest.TestCase):
    def test_add_various_cases(self):
        self.assertEqual(add(1, 2), -3)
        self.assertEqual(add(-5, 7), -2)
        self.assertEqual(add(-1, -2), 10)
        self.assertEqual(add(3, -5), 8)

if __name__ == "__main__":
    unittest.main()

↓ 実行結果

F
======================================================================
FAIL: test_add_various_cases (__main__.TestAddFunction.test_add_various_cases)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "(ファイルパス)\test_addition.py", line 7, in test_add_various_cases
    self.assertEqual(add(1, 2), -3)
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^
AssertionError: 3 != -3

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)

全て失敗するテストなので当然結果は失敗となるが、実行されたテストは一番最初に記載しているself.assertEqual(add(1, 2), -3)だけであり、それ以降のテストは実行されない。

subtest()メソッドをwith文と一緒に使用することで、すべてのテストを実行させることができる。
withブロック内で呼ばれたアサーションメソッドは、途中でAssertionErrorが発生しても中断されずに実行され続ける。
 ※TypeErrorなどの通常の例外はsubTest内でもテストを中断する

import unittest
from addition import add

class TestAddFunction(unittest.TestCase):
    def test_add_various_cases(self):
        test_cases = [
            (1, 2, -3),
            (-5, 7, -2),
            (-1, -2, 10),
            (3, -5, 8),
        ]

        for a, b, expected in test_cases:
            with self.subTest(f"{a} + {b}の計算が失敗しました", a=a, b=b):
                self.assertEqual(add(a, b), expected)

if __name__ == "__main__":
    unittest.main()

↓ 実行結果

FFFF
======================================================================
FAIL: test_add_various_cases (__main__.TestAddFunction.test_add_various_cases) [1 + 2の計算が失敗しました] (a=1, b=2)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "(ファイルパス)\test_addition.py", line 15, in test_add_various_cases
    self.assertEqual(add(a, b), expected)
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^
AssertionError: 3 != -3

======================================================================
FAIL: test_add_various_cases (__main__.TestAddFunction.test_add_various_cases) [-5 + 7の計算が失敗しました] (a=-5, b=7)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "(ファイルパス)\test_addition.py", line 15, in test_add_various_cases
    self.assertEqual(add(a, b), expected)
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^
AssertionError: 12 != -2

======================================================================
FAIL: test_add_various_cases (__main__.TestAddFunction.test_add_various_cases) [-1 + -2の計算が失敗しました] (a=-1, b=-2)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "(ファイルパス)\test_addition.py", line 15, in test_add_various_cases
    self.assertEqual(add(a, b), expected)
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^
AssertionError: -1 != 10

======================================================================
FAIL: test_add_various_cases (__main__.TestAddFunction.test_add_various_cases) [3 + -5の計算が失敗しました] (a=3, b=-5)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "(ファイルパス)\test_addition.py", line 15, in test_add_various_cases
    self.assertEqual(add(a, b), expected)
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^
AssertionError: -2 != 8

----------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (failures=4)

全てのテストの結果が出力され、テストは失敗となることが確認できる。
subTest(msg=None, **params)のように引数を指定することで、テスト失敗時にメッセージ
[1 + 2の計算が失敗しました] (a=1, b=2)の部分)が表示され、テストケースがどのような場合にテストが失敗となったかがわかりやすくなる。

よく使われるアサーションメソッド

メソッド テスト内容 主な使われ方
assertEqual(a, b) ab の値が等しい 計算結果など、値が等しいことを確認
assertTrue(a) aTrue である 条件が真であるとき
assertFalse(a) aFalse である 条件が偽であるとき
assertIsNone(a) aNone である 値が None である
assertIs(a, b) ab が同一のインスタンスである 同じオブジェクトが参照されていることを確認
assertIsInstance(a, b) ab 型(またはクラス)のインスタンスである 型チェック(複数の型を許容する場合も)
assertRaises(exception) 指定した exception 例外が送出されている 不正な入力に対するバリデーション
外部 API や DB のエラー処理のテスト

assertNotEqual(a, b)のように、Notをつけて否定形での比較もできる。


(予告)pythonのテストその2ではunittestの続きとpytestについて学習する。

脚注
  1. テストメソッドの先頭がtestで始まっていない・テストディスカバリーと一致するテストファイルがない( addition.py に対する test_addition.py がない)などが考えられる ↩︎

Discussion