Contents

開発者的な書き方 第2話: テスト

プログラムに対して何らかの動作確認をしたくなったら「テスト」を実装し、確認作業そのものを再現可能にしよう。 テスト自体も保守性を意識して書く必要がある。

/images/test.jpg

前回までの流れ

  • ルール変更の可能性がある fizzbuzz プログラムを実装することになった
  • スクリプトで実装しかけたものの、目視で動作確認することの危険性に気づき、関数化した
  • 関数の動作確認が必要

本記事の流れ

  • pytest の使い方を確認しつつ、とりあえずテストを書く
  • 保守性が悪い方法であることを察知し、パラメタを渡す方法にテストを書き換える
  • テスト駆動開発で新ルールを実装する

前提条件

  • Python 3 がインストールされている
  • pytest がインストールされている

参考サイト

pytest によるテスト

とりあえず書いてみよう

さっそく pytest を使っていこう。 まず、テストを書くためのファイル test_fizzbuzz.py 1を作る。 これからテストしようとしている関数は、前回 fizzbuzz.py で定義した generate_fizzbuzz_msg() だ。 これをまずコード冒頭でインポートしよう。

1
from fizzbuzz import generate_fizzbuzz_msg

テストは関数定義の形で書く必要がある。 どんな状況をテストしようとしているかを関数名で表現し、 関数の本体では、左辺と右辺が等しいかを assert 文で確認する。 fizzbuzz ゲームを例にとれば、‘1’ のときのテストは次のようになる:

1
2
3
4
from fizzbuzz import generate_fizzbuzz_msg

def test_1_is_given():
    assert generate_fizzbuzz_msg(1) == 1

書き終わったら、さっそくテストを実行してみよう。 端末からテストがあるディレクトリに移動し、コマンド pytest を実行するとテストが走る。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
~/fizzbuzz $ pytest
========== test session starts ==========
platform darwin -- Python 3.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/yourname/fizzbuzz
collected 1 item

test_fizzbuzz.py F                                                                                            [100%]

========== FAILURES ==========
_________________________________________________ test_one_is_given _________________________________________________

    def test_one_is_given():
>       assert generate_fizzbuzz_msg(1) == 1
E       AssertionError: assert '1' == 1
E        +  where '1' = generate_fizzbuzz_msg(1)

test_fizzbuzz.py:6: AssertionError
========== short test summary info ==========
FAILED test_fizzbuzz.py::test_one_is_given - AssertionError: assert '1' == 1
========== 1 failed in 0.13s ==========
~/fizzbuzz $

おっと。さっそく失敗してしまった2assert 文の右辺は 1 を期待しているが、 実際に generate_fizzbuzz_msg(1) が返したのは文字列の '1' だったためにエラーとなっている。 ──そうだ、この関数の内部では str() が使われているので、返り値は文字列になるのだった。自分で書いた関数の仕様を忘れていた。 assert 文の右辺を書き直そう:

1
2
3
from fizzbuzz import generate_fizzbuzz_msg
def test_1_is_given():
    assert generate_fizzbuzz_msg(1) == str(1)

修正が終わったら、また実行だ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
~/fizzbuzz $ pytest
========== test session starts ==========
platform darwin -- Python 3.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/yourname/fizzbuzz
collected 1 item

test_fizzbuzz.py .                                                                                            [100%]

========== 1 passed in 0.08s ==========
~/fizzbuzz $

今度は無事成功した3

テストの書き方にもう少し慣れるために、‘2–5’ のケースについてもテストを書いてみよう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import pytest
from fizzbuzz import generate_fizzbuzz_msg

def test_one_is_given():
    assert generate_fizzbuzz_msg(1) == str(1)

def test_two_is_given():
    assert generate_fizzbuzz_msg(2) == str(2)

def test_three_is_given():
    assert generate_fizzbuzz_msg(3) == 'Fizz'

def test_four_is_given():
    assert generate_fizzbuzz_msg(4) == str(4)

def test_five_is_given():
    assert generate_fizzbuzz_msg(5) == 'Buzz'

──あぁ疲れた。では、実行してみよう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
~/fizzbuzz $ pytest
========== test session starts ==========
platform darwin -- Python 3.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/yourname/fizzbuzz
collected 5 items

test_fizzbuzz.py .....                                                                                        [100%]

========== 5 passed in 0.09s ==========
~/fizzbuzz $

OK。成功だ4

立ち止まろう

さて次は、「FizzBuzz」をコールする必要がある ‘15’ のケースもテストしたい。 しかし一旦立ち止まって、どんな結果につながりそうかを想像してみよう ──延々と test_n_is_given() を定義していくのは面倒だし、退屈なテストが並んでいては、読まされるほうもたまらない。

理想的なテストとは、ビジネスの言葉に近い表現で書かれたテストだ。 fizzbuzz ゲームについて言うなら、ルールの説明文のように書かれたテストが望ましい。

パラメタ化されたテスト

テスト関数に引数を与えれば、テストはルールの説明文に近い形で書けるようになる。 擬似コードを使って表現するとこうだ:

1
2
3
4
assert generate_fizzbuzz_msg(3の倍数) == 'Fizz'
assert generate_fizzbuzz_msg(5の倍数) == 'Buzz'
assert generate_fizzbuzz_msg(15の倍数) == 'FizzBuzz'
assert generate_fizzbuzz_msg(それ以外) == str(それ以外)

ありがたいことに、 pytest にはこれを可能にする mark.parametrize() というデコレータ5がある。 これを使って、テストをスマートに書いていこう。

3の倍数

デコレータの使い方は少しややこしい。 まずは欲張らず、 ‘3’、‘6’、‘9’、‘12’ のケースだけテストしてみよう。 テスト関数は、 mark.parametrize() を使うと次のように書ける:

1
2
3
@pytest.mark.parametrize('multiple_of_3', [3, 6, 9, 12])
def test_return_fizz(multiple_of_3):
    assert generate_fizzbuzz_msg(multiple_of_3) == 'Fizz'

では重要度の高い順に、このコードの内容を説明していくことにしよう。 コード中の登場順とは一致しなくなってしまうが、大目に見てほしい。

もっとも重要なのが、テスト関数が引数をとるようになったことだ。 引数は multiple_of_3 というわかりやすい名前になっている。 テスト関数の名前もわかりやすくなった。 以前の書き方では「n が与えられたらどうなるか」という視点だったが、 こんどは「‘fizz’ を返すかどうか」という視点に変わった。 結果的に、関数定義部分は「3 の倍数を与えたとき、‘fizz’ が返ることをテストする」と自然言語に近い形で読めるようになっている。

「3 の倍数」の内容を定義しているのが @ からはじまるデコレータで、いまは ‘3’、‘6’、‘9’、‘12’ だけを扱っている。

では、この新しいテストを実行してみよう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
~/fizzbuzz $ pytest
========== test session starts ==========
platform darwin -- Python 3.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/yourname/fizzbuzz
collected 9 items

test_fizzbuzz.py .........                                                                                    [100%]

========== 9 passed in 0.01s ==========
~/fizzbuzz $

テストが四つぶん、つまり ‘3’、‘6’、‘9’、‘12’ のぶんだけ増えていることがわかる。 成功だ。 あとは multiple_of_3 として扱う数字のリストを充実させれば、3 の倍数のテストは十分だろう。

さて Python で 3 の倍数を生成するには、 リスト内包をうまく使うといい。

1
print([i for i in range(1, 50) if i % 3 == 0])
1
[3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42, 45, 48]

さっそく、これを先ほどのデコレータに与えてみよう。

1
2
3
@pytest.mark.parametrize('multiple_of_3', [i for i in range(1, 50) if i % 3 == 0])
def test_return_fizz(multiple_of_3):
    assert generate_fizzbuzz_msg(multiple_of_3) == 'Fizz'
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
~/fizzbuzz $ pytest
========== test session starts ==========
platform darwin -- Python 3.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/yourname/fizzbuzz
collected 21 items

test_fizzbuzz.py .........F....F....F.                                                                        [100%]

========== FAILURES ==========
_______________________________________________ test_return_fizz[15] ________________________________________________

multiple_of_3 = 15

    @pytest.mark.parametrize('multiple_of_3', [i for i in range(1, 50) if i % 3 == 0])
    def test_return_fizz(multiple_of_3):
>       assert generate_fizzbuzz_msg(multiple_of_3) == 'Fizz'
E       AssertionError: assert 'FizzBuzz' == 'Fizz'
E         - Fizz
E         + FizzBuzz

test_fizzbuzz.py:23: AssertionError
_______________________________________________ test_return_fizz[30] ________________________________________________

multiple_of_3 = 30

    @pytest.mark.parametrize('multiple_of_3', [i for i in range(1, 50) if i % 3 == 0])
    def test_return_fizz(multiple_of_3):
>       assert generate_fizzbuzz_msg(multiple_of_3) == 'Fizz'
E       AssertionError: assert 'FizzBuzz' == 'Fizz'
E         - Fizz
E         + FizzBuzz

test_fizzbuzz.py:23: AssertionError
_______________________________________________ test_return_fizz[45] ________________________________________________

multiple_of_3 = 45

    @pytest.mark.parametrize('multiple_of_3', [i for i in range(1, 50) if i % 3 == 0])
    def test_return_fizz(multiple_of_3):
>       assert generate_fizzbuzz_msg(multiple_of_3) == 'Fizz'
E       AssertionError: assert 'FizzBuzz' == 'Fizz'
E         - Fizz
E         + FizzBuzz

test_fizzbuzz.py:23: AssertionError
========== short test summary info ==========
FAILED test_fizzbuzz.py::test_return_fizz[15] - AssertionError: assert 'FizzBuzz' == 'Fizz'
FAILED test_fizzbuzz.py::test_return_fizz[30] - AssertionError: assert 'FizzBuzz' == 'Fizz'
FAILED test_fizzbuzz.py::test_return_fizz[45] - AssertionError: assert 'FizzBuzz' == 'Fizz'
========== 3 failed, 18 passed in 0.20s ==========
~/fizzbuzz $

おっと。三つ失敗してしまった。 失敗しているのは、‘15’、‘30’、そして ‘45’ のとき──そうだ、3 の倍数が 5 の倍数でもあるときには、 ‘Fizz’ ではなく ‘FizzBuzz’ とコールしなければいけないんだった。 テストが通るように修正しよう。

3 の倍数から、5 の倍数でもあるものを除外する。

1
2
3
@pytest.mark.parametrize('multiple_of_3', [i for i in range(1, 50) if i % 3 == 0 if i % 5 != 0])
def test_return_fizz(multiple_of_3):
    assert generate_fizzbuzz_msg(multiple_of_3) == 'Fizz'
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11

~/fizzbuzz $ pytest
========== test session starts ==========
platform darwin -- Python 3.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/yourname/fizzbuzz
collected 18 items

test_fizzbuzz.py ..................                                                                           [100%]

========== 18 passed in 0.10s ==========
~/fizzbuzz $

読みにくくなってしまったが、ひとまず OK だ。

5の倍数、15の倍数

同様に、5 の倍数のテストも書いてみよう。 さっきの失敗を活かして、デコレータに渡す 5 の倍数からは、3 の倍数でもあるものを除外しておく。

1
2
3
@pytest.mark.parametrize('multiple_of_5', [i for i in range(1, 50) if i % 5 == 0 and i % 3 != 0])
def test_return_buzz(multiple_of_5):
    assert generate_fizzbuzz_msg(multiple_of_5) == 'Buzz'

15の倍数は、素直に書けばいい。 15の倍数は少ないので、値の範囲を99まで広げておこう。

1
2
3
@pytest.mark.parametrize('multiple_of_15', [i for i in range(1, 100) if i % 3 == 0 and i % 5 == 0])
def test_return_fizzbuzz(multiple_of_15):
    assert generate_fizzbuzz_msg(multiple_of_15) == 'FizzBuzz'
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
~/fizzbuzz $ pytest
========== test session starts ==========
platform darwin -- Python 3.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/yourname/fizzbuzz
collected 30 items

test_fizzbuzz.py ..............................                                                               [100%]

========== 30 passed in 0.11s ==========
~/fizzbuzz $

よし、成功た。

それ以外の場合

3 の倍数でも 5 の倍数でもないものは、そのまま文字列が返るはずだ。 これをテストにしよう。

1
2
3
@pytest.mark.parametrize('otherwise', [i for i in range(1, 100) if i % 3 != 0 and i % 5 != 0])
def test_return_asis(otherwise):
    assert generate_fizzbuzz_msg(otherwise) == str(otherwise)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
~/fizzbuzz $ pytest
========== test session starts ==========
platform darwin -- Python 3.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/yourname/fizzbuzz
collected 77 items

test_fizzbuzz.py .............................................................................                [100%]

========== 77 passed in 0.15s ==========
~/fizzbuzz $

これも OK だ。 さて、新たに追加したパラメータ形式のテストが無事動いていることを確認できたので、 最初に書いたハードコードのテストは削除しておこう。

ここまで書いてきたテストにもまだ改善点はあるが、この間にも試合は進んでいる。 新しいルールを追加しよう。

ルール追加

テストから書く

さて、新しいルールは「‘12’または'34’のときには最後に『連番!』とコールすること」というものだった。 コードを書き始める前に、この期待をテストにしよう。 頭を整理するのに役立つ。

1
2
3
@pytest.mark.parametrize('consecutive', [12, 34])
def test_return_renban(consecutive):
    assert generate_fizzbuzz_msg(consecutive) == str(consecutive) + '連番!'

書けたら、恐る恐る実行してみよう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

~/fizzbuzz $ pytest
========== test session starts ==========
platform darwin -- Python 3.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/yourname/fizzbuzz
collected 21 items

test_fizzbuzz.py ...................FF                                                                        [100%]

========== FAILURES ==========
_____________________________________________ test_return_renban[12] ______________________________________________

consecutive = 12

    @pytest.mark.parametrize('consecutive', [12, 34])
    def test_return_renban(consecutive):
>       assert generate_fizzbuzz_msg(consecutive) == str(consecutive) + '連番!'
E       AssertionError: assert 'Fizz' == '12連番!'
E         - 12連番!
E         + Fizz

test_fizzbuzz.py:22: AssertionError
_____________________________________________ test_return_renban[34] ______________________________________________

consecutive = 34

    @pytest.mark.parametrize('consecutive', [12, 34])
    def test_return_renban(consecutive):
>       assert generate_fizzbuzz_msg(consecutive) == str(consecutive) + '連番!'
E       AssertionError: assert '34' == '34連番!'
E         - 34連番!
E         + 34

test_fizzbuzz.py:22: AssertionError
========== short test summary info ==========
FAILED test_fizzbuzz.py::test_return_renban[12] - AssertionError: assert 'Fizz' == '12連番!'
FAILED test_fizzbuzz.py::test_return_renban[34] - AssertionError: assert '34' == '34連番!'
========== 2 failed, 19 passed in 0.08s ==========
~/fizzbuzz $

そう、通るわけない。 でも大丈夫。 なぜなら、失敗したテストは、我々が直面している課題を示してくれているからだ。 先に課題を作り、課題を達成するようにコードを書いていく──これがテスト駆動開発だ。 さ、実装してみよう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def generate_fizzbuzz_msg(i):
    msg = ''
    if i % 3 == 0:
        msg += 'Fizz'
    if i % 5 == 0:
        msg += 'Buzz'

    if str(i) == '12' or str(i) == '34':
        msg = str(i) + '連番!'
    else:
        if msg == '':
            msg += str(i)
    return msg
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
~/fizzbuzz $ pytest
========== test session starts ==========
platform darwin -- Python 3.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
rootdir: /Users/yourname/fizzbuzz
collected 20 items

test_fizzbuzz.py ....................                                                                                                                                                              [100%]

========== 20 passed in 0.03s ==========
~/fizzbuzz $

よし、課題は解決だ。

まとめ

さて今回は、自作関数をテストし、テスト駆動開発でルールを追加してみた。 テスト駆動開発の主なメリットは二つある: 一つは求められている仕事をクリアにできること、 もう一つはテストの書き忘れがなくなることだ。

このシリーズは、FizzBuzz ゲームを題材にして、かなり馬鹿馬鹿しい設定でお送りしている。 しかしここでいうルール追加とは、一般のプログラムでいえば機能追加にあたる。 私たちが毎日使っているサービスやソフトウェアも、ひょっとしたらテスト駆動で開発されているかもしれない。

次回予告: リファクタリング

実は、我々はまだテスト駆動開発のサイクルを一巡させていない。 次に必要になるのは、リファクタリング──読みにくい部分の書き直しだ。 仕様に着目してコードを書いていたら、だいぶ読みにくい部分が出てきてしまった。 次回はこれをすっきりさせよう。 コードは、よりビジネスの言葉に近い表現になっていくはずだ。


  1. pytest にが正常に動作するためには、ファイル名は test_ ではじまるか、 _test.py で終わる必要がある。本家サイトの解説 ↩︎

  2. テスト失敗を知らせる手がかりは、 test_fizzbuzz.py の右にある F の文字、そしてその下の FAILURES の見出しだ ↩︎

  3. テスト成功を知らせる手がかりは、 test_fizzbuzz.py の右にある . (ピリオド)と、 1 passed in 0.08s という見出しだ ↩︎

  4. ピリオドは成功したテストを表しているので、ここではピリオドマークが5個表示されている ↩︎

  5. デコレータとは、関数を修飾し、そのふるまいを変える関数だ ↩︎