Pythonで書きたい！　(2) テストを書いてみよう

こんにちは。leo1109です。
今回は[前回](http://qiita.com/leo1109/items/045c54d9a3a4f8979cc2)の記事（コードフォーマットチェック）の続きになります。

記事で利用したコードは、すべてGitHub上にアップロードされています。

- https://github.com/shonuma/qiitas/tree/master/20170806

## 今回紹介する話

テストを書く話です。pytestを利用します。

- https://docs.pytest.org/en/latest/

## Pythonでテストを書きたい！
pytestのドキュメントには以下の例があります。

inc(x) は、xに1を足した値を返す関数です。(Increment)
テストとして、test_answer()が定義されています。

```
# content of test_sample.py
def inc(x):
    return x + 1

def test_answer():
    assert inc(3) == 5
```

テストを実行します。
pytestのインストールが必要ですので、pipでインストールを実行するか、
リポジトリにあるrequirements.txtを利用してインストールしてください。

```
pip install pytest
pip install -r requrements.txt
```

pytestを実行してみます。

```
$ pytest
======= test session starts ========
collected 1 item

test_sample.py F

======= FAILURES ========
_______ test_answer ________

    def test_answer():
>       assert inc(3) == 5
E       assert 4 == 5
E        +  where 4 = inc(3)

test_sample.py:5: AssertionError
======= 1 failed in 0.12 seconds ========
```

inc(3)は、4を返す想定ですから、５と等しくはないため、失敗と判定されました。


## get_fibonacci_by_index()をテストする

では、早速テストを書いてみましょう。
```get_fibonacci_by_index(1)```は1を返すので、１を返すことを確認するテストを書いてみます。

```
# python 3.5.2

import my_math


class TestGetFibonacciByIndex:
    def test(self):
        assert my_math.get_fibonacci_by_index(1) == 1
```

以下は実行した結果です。

```
$ pytest my_math_test.py
================================================================================= test session starts =================================================================================

my_math_test.py .

============================================================================== 1 passed in 0.03 seconds ===============================================================================
```

先ほどとは異なり、1 passed, と表示されました。

他のパターンも合わせてテストしてみましょう。

## 様々なassert
テストケースを追加するには、そのままassertを追加していく方法が簡単です。
テストの粒度によっては、戻り値がIntであることを確認する必要があるかもしれません。

Pythonのコードとしてかけますので、様々な演算子はもちろん、配列、辞書なども利用できます。

```
# python 3.5.2

import my_math


class TestGetFibonacciByIndex:
    def test(self):
        assert my_math.get_fibonacci_by_index(1) == 1
        assert my_math.get_fibonacci_by_index(2) == 1
        assert my_math.get_fibonacci_by_index(3) == 2
        assert my_math.get_fibonacci_by_index(4) > 2
        assert (my_math.get_fibonacci_by_index(5) == 5) is True

    def test_is_instance(self):
        assert isinstance(my_math.get_fibonacci_by_index(2), int)

    def test_as_array(self):
        expected = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
        got = []

        for i in range(1, 11):
            got.append(my_math.get_fibonacci_by_index(i))

        assert expected == got
```

実行してみましょう。
vオプションをつけて実行すると、テストのメソッドごとに結果が表示されます。
テストが実行されるためには、メソッド名がtestから始まっている必要があります。

```
$ pytest my_math_test.py -v
================================================================================= test session starts =================================================================================

my_math_test.py::TestGetFibonacciByIndex::test PASSED
my_math_test.py::TestGetFibonacciByIndex::test_is_instance PASSED
my_math_test.py::TestGetFibonacciByIndex::test_as_array PASSED

============================================================================== 3 passed in 0.09 seconds ===============================================================================
```

### 意図しない引数でのテスト

そういえば、```get_fibonacci_by_index(x)```に0や負の数を与えるとどのような挙動になるのでしょうか。今までは自然数しか与えていませんでしたね。

0や-1を与えてみたところ、1が取得できました。
これは意図した動作になっているでしょうか？

```
>>> import my_math; my_math.get_fibonacci_by_index(0)
1
>>> import my_math; my_math.get_fibonacci_by_index(-1)
1
```

ソースコードを見ると、```range(1, x)```に入らない限りは1を返すようになっているようです。

様々なケースを考慮してテストを書くことは重要ですが、あまりにも手を広げすぎると大変です。
例えば、引数に文字列が来た場合は？bool値が来た場合は？など、です。

今回の```get_fibonacci_by_index(x)```では、(x)は自然数を対象としているはずです。
ですので、自然数以外は想定しない、という定義にしても良いはずです。

...ですが、今回はテストを書いてみよう！という記事ですので、少し寄り道をしてみましょう。
以下のテストケースを見てください。

```
    def test_bool(self):
        assert my_math.get_fibonacci_by_index(True) == 1
        assert my_math.get_fibonacci_by_index(False) == 1
```

このテストは想定に反してPassします。
理由は、```True, False```は```Int```として評価でき、それぞれ1,0となるからです。

```
>>> int(True)
1
>>> int(False)
0
```
今後、型を厳しくチェックする実装を追加すると、本テストが通らなくなる可能性があります。

ですが、```True,False```が指定されるケースは意図したパターンではないはずです。
そのため、今回は```TypeError```を期待するテストを書いておくことにしましょう。
```pytest.raises```を```with```句と合わせて使うことで、例外を期待するテストを書くことができます。

```
    def test_bool(self):
        with pytest.raises(TypeError):
            my_math.get_fibonacci_by_index(True)
        with pytest.raises(TypeError):
            my_math.get_fibonacci_by_index(False)
```
ですが、当然テストは通りません。
この状態で放置しておくのは、あまり良くなさそうです...

```
$ pytest -v my_math_test.py
================================================================================= test session starts =================================================================================

my_math_test.py::TestGetFibonacciByIndex::test PASSED
my_math_test.py::TestGetFibonacciByIndex::test_is_instance PASSED
my_math_test.py::TestGetFibonacciByIndex::test_as_array PASSED
my_math_test.py::TestGetFibonacciByIndex::test_bool FAILED

====================================================================================== FAILURES =======================================================================================
__________________________________________________________________________ TestGetFibonacciByIndex.test_bool __________________________________________________________________________

self = <my_math_test.TestGetFibonacciByIndex object at 0x10566de10>

    def test_bool(self):
        with pytest.raises(TypeError):
>           my_math.get_fibonacci_by_index(True)
E           Failed: DID NOT RAISE <class 'TypeError'>

my_math_test.py:30: Failed
========================================================================= 1 failed, 3 passed in 0.12 seconds ==========================================================================
```
ですので、このテストはいったんスキップしておくことにしましょう。

## テストをスキップする
pytestをデコレータとして利用してみます。
```pytest.mark.skip```を利用することで、対象のテストをスキップできます。
importに```pytest```を追加してください。

```
import pytest
..
    @pytest.mark.skip
    def test_bool(self):
        with pytest.raises(TypeError):
            my_math.get_fibonacci_by_index(True)
        with pytest.raises(TypeError):
            my_math.get_fibonacci_by_index(False)
```

スキップする条件を指定することもできます。
```sys.version_info```はPythonのバージョンを取得するイディオムです。
もちろん、sysのimportを忘れずに。

```
import sys
..
    @pytest.mark.skipif(sys.version_info < (3,5),
                        reason="requires python 3.5")
    def test_requires_35(self):
        assert True
```

Python 2.7.11と、Python 3.5.2でそれぞれ実行してみると、2.7.11では対象のテストがskipされていることがわかります。

```
## Python2.7.11
$ pytest -v my_math_test.py
================================================================================= test session starts =================================================================================
platform darwin -- Python 2.7.11, pytest-3.2.0, py-1.4.34, pluggy-0.4.0 

my_math_test.py::TestGetFibonacciByIndex::test_requires_35 SKIPPED


## Python3.5.2
$ pytest -v my_math_test.py
================================================================================= test session starts =================================================================================
platform darwin -- Python 3.5.2, pytest-3.2.0, py-1.4.34, pluggy-0.4.0 

my_math_test.py::TestGetFibonacciByIndex::test_requires_35 PASSED
```
特定のバージョンしか実装されていない機能を利用している場合や、複数バージョンに対応するモジュールを実装する場合に便利ですね。

また、実行するPythonのバージョンを切り替える際には、```pyenv```が便利です。
（pyenvの説明については本章の内容とは離れますので、割愛させていただきます）

- https://github.com/pyenv/pyenv

## Importの順序を調整する
上記の様にコードを書いていくと、以下の３つのimportが書かれているはずです。

```
import sys

import pytest

import my_math

```
実は、この順序も調整するためのツールが用意されています。
isortを利用すると、importの並べ替えを行うことができます。
（他にも様々な機能がありますが、こちらは簡単な紹介のみにとどめておきます。）

- https://github.com/timothycrosley/isort

さて、無事テストを書くことができました。非常に簡単ですね！
コードを追記した際は、テストを書く癖をつけることで、実装のミスの軽減にも繋がります。

## 次回は
複雑なメソッドのテストをPythonで書きたい！です。