unittest moduleを使ったテストでの長い文字列同士の比較のエラーメッセージを変更する

## はじめに

pythonの標準ライブラリの[unittest](https://docs.python.org/3/library/unittest.html)で長い文字列同時の比較を行なった時のエラーメッセージが分かりづらいと感じるときがある。このエラーメッセージの出力を変更する方法を調べた。

方法は2つあり好きな方を選べば良い。

- 特別な assertion メソッドを作る
- `assertEqual()` で呼び出されるメソッドを変更する

## デフォルトのエラーメッセージ

デフォルトのunittestでの長い文字列同士の比較の際のエラーメッセージは以下のようなもの。

```
======================================================================
FAIL: test_it (__main__.Tests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "qr_43059JDo.py", line 35, in test_it
    self.assertEqual(self.ok, self.ng)
AssertionError: '* one\n* two\n* three\n' != '* one\n* twwo\n* three\n'
  * one
- * two
+ * twwo
?    +
  * three
```

比較結果を見て差分がわからないわけではないけれど。独自の形式になっていて見づらいような気がする。できれば見慣れたdiffの形式で表示されて欲しい。例えば以下の様な形で。

```diff
--- first
+++ second
@@ -1,3 +1,3 @@
 * one
-* two
+* twwo
 * three
```

このような形式でエラーメッセージが表示されるようにしたい。

## 特別な assertion メソッドを作る

特別な assertion メソッドを作ればとりあえず問題は解決する。

### difflib

pythonの標準ライブラリには[difflib](http://docs.python.jp/3/library/difflib.html)というライブラリがある。diffの出力を生成するライブラリなので簡単なdiffの出力をするだけなら標準ライブラリの範囲で賄える。

このライブラリはunittestの内部でも使われているが `difflib.ndiff()` の方が使われている。おそらくgitなどのdiffなどで見慣れているdiffの形式は `difflib.unified_diff()` の方だと思う。なのでこちらを使うように変更する。

ndiffの出力

```
  * one
- * two
+ * twwo
?    +
  * bar
```

unified_diffの出力

```diff
--- first
+++ second
@@ -1,3 +1,3 @@
 * one
-* two
+* twwo
 * bar
```

### 特別なassertion メソッドの `asertDiff()` を作成

`difflib.unified_diff()` を使った `assertDiff()` を定義してあげれば良い。

```python
import unittest
import difflib


class DiffTestCase(unittest.TestCase):
    def assertDiff(self, first, second, msg=None):
        self.assertIsInstance(first, str, 'First argument is not a string')
        self.assertIsInstance(second, str, 'Second argument is not a string')

        msg = msg or "{} != {}".format(repr(first)[:40], repr(second)[:40])

        if first != second:
            # don't use difflib if the strings are too long
            if (len(first) > self._diffThreshold or len(second) > self._diffThreshold):
                self._baseAssertEqual(first, second, msg)

            firstlines = first.splitlines(keepends=True)
            secondlines = second.splitlines(keepends=True)
            if not firstlines[-1].endswith("\n"):
                firstlines[-1] = firstlines[-1] + "\n"
            if not secondlines[-1].endswith("\n"):
                secondlines[-1] = secondlines[-1] + "\n"

            diff = '\n' + ''.join(difflib.unified_diff(firstlines, secondlines, fromfile="first", tofile="second"))
            raise self.fail(self._formatMessage(diff, msg))


class Tests(DiffTestCase):
    ok = """\
* one
* two
* three
"""
    ng = """\
* one
* twwo
* three
"""

    def test_it(self):
        self.assertEqual(self.ok, self.ng)

    def test_diff(self):
        self.assertDiff(self.ok, self.ng)
```

出力結果

```
FF
======================================================================
FAIL: test_diff (__main__.Tests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "qr_43059WUi.py", line 42, in test_diff
    self.assertDiff(self.ok, self.ng)
  File "qr_43059WUi.py", line 23, in assertDiff
    raise self.fail(self._formatMessage(diff, msg))
AssertionError: '* one\n* two\n* three\n' != '* one\n* twwo\n* three\n' : 
--- first
+++ second
@@ -1,3 +1,3 @@
 * one
-* two
+* twwo
 * three


======================================================================
FAIL: test_it (__main__.Tests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "qr_43059WUi.py", line 39, in test_it
    self.assertEqual(self.ok, self.ng)
AssertionError: '* one\n* two\n* three\n' != '* one\n* twwo\n* three\n'
  * one
- * two
+ * twwo
?    +
  * three


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

FAILED (failures=2)
```

unified_diffを使ったエラーメッセージに変わった(とは言え、stack traceが2行になっているのが少し気になるかもしれない)。

## `assertEqual()` で呼び出されるメソッドを変更する

先程定義した `assertDiff()` を使うように書き換えるというのでも良いけれど。どうせならば `assertEqual()` を使った時の出力結果が変わった方が嬉しいのではないかと思う。その方法について書く。

### unittestのエラーメッセージの生成の仕組み

`unittest.TestCase` の `assert.Equal()` 自体は内部的には型に応じたassertion メソッドを適宜呼び分けているだけだったりする。

具体的には以下の様な `_getAssertEqualityFunction()` が呼ばれる

```python
    def _getAssertEqualityFunc(self, first, second):
        """Get a detailed comparison function for the types of the two args.

        Returns: A callable accepting (first, second, msg=None) that will
        raise a failure exception if first != second with a useful human
        readable error message for those types.
        """
        #
        # NOTE(gregory.p.smith): I considered isinstance(first, type(second))
        # and vice versa.  I opted for the conservative approach in case
        # subclasses are not intended to be compared in detail to their super
        # class instances using a type equality func.  This means testing
        # subtypes won't automagically use the detailed comparison.  Callers
        # should use their type specific assertSpamEqual method to compare
        # subclasses if the detailed comparison is desired and appropriate.
        # See the discussion in http://bugs.python.org/issue2578.
        #
        if type(first) is type(second):
            asserter = self._type_equality_funcs.get(type(first))
            if asserter is not None:
                if isinstance(asserter, str):
                    asserter = getattr(self, asserter)
                return asserter

        return self._baseAssertEqual
```

ここから分かる通り、内部的には `self._type_equality_funcs` に型ごとに使われる assersion method が格納されている。この関連の設定自体は `unittest.TestCase` の `__init__()` で行われている。

```python
    def __init__(self, methodName='runTest'):
# .. snip
        # Map types to custom assertEqual functions that will compare
        # instances of said type in more detail to generate a more useful
        # error message.
        self._type_equality_funcs = {}
        self.addTypeEqualityFunc(dict, 'assertDictEqual')
        self.addTypeEqualityFunc(list, 'assertListEqual')
        self.addTypeEqualityFunc(tuple, 'assertTupleEqual')
        self.addTypeEqualityFunc(set, 'assertSetEqual')
        self.addTypeEqualityFunc(frozenset, 'assertSetEqual')
        self.addTypeEqualityFunc(str, 'assertMultiLineEqual')

    def addTypeEqualityFunc(self, typeobj, function):
## .. snip
        self._type_equality_funcs[typeobj] = function

```

### `assertEqual()` で `assertDiff()` が呼ばれるようなクラスを作成

以下のようにinitで追加してあげれば良い。

```diff
--- 00test.py	2016-12-24 22:16:45.000000000 +0900
+++ 01test.py	2016-12-24 22:35:16.000000000 +0900
@@ -3,6 +3,10 @@
 
 
 class DiffTestCase(unittest.TestCase):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.addTypeEqualityFunc(str, 'assertDiff')
+
```

`assertEqual()` を使いながら `difflib.unified_diff()` を使った結果になっている。そしてうれしいことにtracebackも1行になっている。

test

```python
class Tests(DiffTestCase):
    ok = """\
* one
* two
* three
"""
    ng = """\
* one
* twwo
* three
"""

    def test_it(self):
        self.assertEqual(self.ok, self.ng)

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

実行結果

```
F
======================================================================
FAIL: test_it (__main__.Tests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "qr_43059wou.py", line 43, in test_it
    self.assertEqual(self.ok, self.ng)
AssertionError: '* one\n* two\n* three\n' != '* one\n* twwo\n* three\n' : 
--- first
+++ second
@@ -1,3 +1,3 @@
 * one
-* two
+* twwo
 * three


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

FAILED (failures=1)
```

## 付録: difflibの出力確認

比較した文章は以下のようなものだった。

first

```
* one
* two
* three
```

second

```
* one
* twwo
* three
```

difflibのndiffとunified_diffの出力結果の確認は以下の様なコードでできる。

```python
import difflib

first = """\
* one
* two
* bar
""".splitlines(keepends=True)

second = """\
* one
* twwo
* bar
""".splitlines(keepends=True)

print("".join(difflib.ndiff(first, second)))
print("**")
print("".join(difflib.unified_diff(first, second, fromfile="first", tofile="second")))
```