cacapon流テストコードの書き方

おはようございます、cacaponです。

今週はRPGづくりはちょっと一休みして、
cacaponがプログラムを書くときに一緒に書いている
テストコードというものについて書いていけたらなと思います。

元々プログラムを習い始めた当初、私はテストコードは全く書いていませんでした。
途中から書くようになったのですが、そのあたりの理由も踏まえて、
今のcacaponがどんな感じで描いているのか紹介したいと思います♪

そもそもテストコードって何?

テストコードというのはプログラムの動きを確認するためのプログラムです。
例えば、以下のようなpythonのプログラムがあったとしましょう。

devision.py

def devision(num, by_num):
    return num / by_num

この関数を使って、動作確認するプログラムがテストコードになります。
test_devision.py

from devision import devision


def test_devision(test_list: list):
    for test_data in test_list:
        if devision(test_data[0], test_data[1]) != test_data[2]:
            raise ValueError('計算結果が一致しませんでしたformula:{}/{} value:{}'.format(test_data[0], test_data[1], test_data[2]))
        print('OK: {}/{} = {}'.format(test_data[0], test_data[1], test_data[2]))
    print('testが無事に終了しました。')


if __name__ == "__main__":
    test_list = [
        (2, 1, 2),
        (10, 2, 5),
        (3.5, 0.7, 5.0),
        (1, 2, 0.3)  # ここは失敗する
    ]

    test_devision(test_list)

実行結果

$ python test_devision.py
OK: 2/1 = 2
OK: 10/2 = 5
OK: 3.5/0.7 = 5.0
Traceback (most recent call last):
  File "devision.py", line 21, in <module>
    test_devision(test_list)
  File "devision.py", line 8, in test_devision
    raise ValueError('計算結果が一致しませんでしたformula:{}/{} value:{}'.format(test_data[0], test_data[1],test_data[2]))
ValueError: 計算結果が一致しませんでしたformula:1/2 value:0.3

因みに、pythonでテストコードを書くときは
pytestというツールを使うので普段はこんな感じでは書いてないです。

敢えてこんな感じで書いたのは、
テストコード用のツールで書いたプログラムがテストコードなんだ
という勘違いをしてほしくなかったからです。

書こうと思ったら、その言語に沿った形ならツールを使わなくても書く事が出来ます。
それでも何故ツールを使うかは後で理由を述べましょう。

なんでテストコードを書くようになったの?

色々な書籍やブログでテストコードのメリットやデメリットは述べられていますが
一番でかいのは、後で見返したときにその関数の動きを理解しやすいからですかね。

コード量は増えるのでコストはかかりますが、
別の人が書いたコードを見る時になって、
「この関数やクラスは一体どう使われるのが想定されているのか」
を理解するのはコードにコメントが書いてあるより、
テストコードを動かした方が理解できます。
(もちろんコメントがあるのも助かりますが、こちらの議題ではないので割愛します)

そして、作成者が想定している動きは保証されているというのもメリットとして大きいと思います。
作成者の想定内の範囲だったら、その関数は動くってことが分かりますので。

以上2つのメリットから、自分自身が書くコードにも必ずテストコードを付けるようになりました。 …え?個人で開発しているから他の人なんて関係ないですって?

いやいや、数か月前に作った自分のコード見てみてください。
もはや他人ですよ

なんでpytestっていうツールを使っているの?

さっきさらっと流してしまいましたが、なぜ私がテストコードを書くときツールを使っているのか紹介します。 ずばり、楽で分かりやすくなるからです。
(仕事場で使っているというのもありますが)

まず、「楽な」部分を見てみましょう。
先ほどのテストコードtest_devision.pyをpytestを使ったコードに書き換えるとこうなります。

test_devision_remake.py

import pytest
from devision import devision

test_list = [
    (2, 1, 2),
    (10, 2, 5),
    (3.5, 0.7, 5.0),
    (1, 2, 0.3)  # ここは失敗する
]


@pytest.mark.parametrize('num, by_num, answer', test_list)
def test_devision(num, by_num, answer):
    assert devision(num, by_num) == answer

多少専用の書き方もありますが、かなりすっきりした見た目になったのではないでしょうか?
書く量がだいぶ減るのですよね…これはプログラマーとしてはありがたいです。

また、テストコードの内容も、
多少英語と数学っぽいのが理解できる人ならすんなり理解できるかと思います。
これは後で見返したときも「楽」ですね!

次に「分かりやすい」部分、これはテスト結果に現れます。

$ pytest
=============================================================== test session starts ================================================================ 
platform win32 -- Python 3.7.2, pytest-4.3.1, py-1.8.0, pluggy-0.9.0
rootdir: C:\Users\xxxxx
plugins: mock-3.2.0, dotenv-0.4.0
collected 4 items                                                                                                                                    

test_devision.py ...F                                                                                                                         [100%] 

===================================================================== FAILURES ===================================================================== 
______________________________________________________________ test_devision[1-2-0.3] ______________________________________________________________ 

num = 1, by_num = 2, answer = 0.3

    @pytest.mark.parametrize('num, by_num, answer', test_list)
    def test_devision(num, by_num, answer):
>       assert devision(num, by_num) == answer
E       assert 0.5 == 0.3
E        +  where 0.5 = devision(1, 2)

test_devision.py:14: AssertionError
======================================================== 1 failed, 3 passed in 0.08 seconds ======================================================== 

ブログの折り返しとかでちょっと見にくくなっていますが、実際のCLIだと折り返しもないのでもっと見やすいです。
どこで間違えているのか、どんな間違いをしているのか?テストにどのくらい時間がかかっているのか?
記述は簡単になったのに、得られる情報量は格段に増えている…これがツールを使うことのメリットです。

これはpytestの特徴で利用できるのはpythonのみですが、python以外のプログラム言語を利用している方は、
プログラミング言語に合ったテスト用のツールやライブラリがあると思います。
ぜひ、ご自身で探してみてください。

どんな感じでテストコードを書いてるの?

ではいよいよ表題の話ですね。
私がどんな感じでテストコードを書いているか紹介しましょう。

因みに私の書き方は巷で噂のテスト駆動開発ではありません。
この書き方があっているかはわかりませんが、慣れ親しんでいるやり方なのでこのやり方を紹介します。

今回は与えられた数字が整数の3だったらfizzと返す
fizz.py というプログラムを例に考えていきます。

①まず、実際のプログラムを書く

動作する部分を実際に作っていきます。なぜなら、そこが一番イメージしやすいからです。
側だけ作って、中身のない関数やクラスを作る時もあります。

def fizz(num):
    # 3で割り切れるなら fizzを返す予定
        return None
②テストコードを書く

次に、その関数を動かすテストコードを書きます。
この値だったら動くだろうなっていう値(正常系)を何個か用意して、その値を使って動かします。
例外処理が必要なプログラムは例外を起こさせる値も準備します。

import pytest
from fizz import fizz

def test_fizz():
    for i in range(10):
        if i % 3 == 0:
            assert fizz(i) == 'fizz'
        else:
            assert fizz(i) == ''


def test_fizz_error():
    with pytest.raises(ValueError):
        fizz('bad_word')
③テストを実行する

記念すべき第一回目のテスト実行です、ですが恐らく失敗するでしょう。
ですがそれで構いません。開発中のテストは失敗してなんぼなので。

$ pytest
=============================================================== test session starts ================================================================
platform win32 -- Python 3.7.2, pytest-4.3.1, py-1.8.0, pluggy-0.9.0
rootdir: C:\Users\xxxxx\
plugins: mock-3.2.0, dotenv-0.4.0
collected 2 items

test_fizz.py FF   

<以下略>
④コードを修正する

テストの方は「この入力があったらこの出力になるはずだ」という考えのもと作っているはずですので
そこが正しいならコードの方が間違っているはずです。
正しい値が返されるように修正いたしましょう

def fizz(num):
    if type(num) is not int:
        raise ValueError('num is not int: num is {}'.format(type(num)))

    if num % 3 == 0:
        return 'fizz'
    else:
        return ''
⑤テストを修正してみる

確認している内に、あ、この値だとどうなるんだろ?というのが出てくると思います
そしたらどんどん増やしてみましょう。

import pytest
from fizz import fizz


@pytest.mark.parametrize('num', range(10))
def test_fizz(num):
    if num % 3 == 0:
        assert fizz(num) == 'fizz'
    else:
        assert fizz(num) == ''


error_list = [
    'bad_word',
    1.08,
    ['bad', 'list'],
    ('bad', 'tuple'),
    {'bad': 'dict'}
]


@pytest.mark.parametrize('bad_args', error_list)
def test_fizz_error(bad_args):
    with pytest.raises(ValueError):
        fizz(bad_args)
⑥ ③~⑤を繰り返す

後はこの作業のループです。徐々に完成に近づいていくはずです。

私は基本的にこのようなループでコードを作りながら、テストコードも書いています。


いかがでしたでしょうか?
私の考え方がテストコードを作る際の一助になれば幸いです。

それではまた来週お会いしましょう♪


参考文献

書籍は「テスト開発!」という感じのものは読んではいないのですが、
プログラムを組む上での基本的な考え方は以下の著書の影響を強く受けている気がします。

これはテストだけでなく、プログラマー全般の格言がユーモアを踏まえて記載されている名著です。
機会があったら是非一読してみてください。

因みにテスト関係は tipsの43 「容赦ないテスト」が参考になるかと思います。

これはコードをいかに読みやすくするかの本ですね。
テストも「コード」なので勿論適用できます。

分かりやすいコードとは何かを「読みやすさ」の視点で説いていますが、
私はテストコードを通じて分かりやすいコードの追求をしています。
(もちろんコード自体の読みやすさも追及していますが)

因みに本ブログで書いた数か月前の自分は他人、
みたいな考え方はこの本のふざけた挿絵がヒントになっています。

本以外だと以下のページをよく読んでいると思います。

docs.pytest.org 公式ドキュメントです。

www.m3tech.blog

今回使ってませんけど、pytestで使うモックとか理解するのに参考にさせていただきました。

後は、殆どTry&Error で覚えて言った感じです。
習うより慣れろですね。