CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: doctest を書いてみよう

今回は Python が標準で提供しているテスト機構の一つである doctest について書いてみる。 doctest というのは docstring という Python のドキュメンテーション機構を使って一緒にテストもしちゃおうという発想の代物。 docstring については以前にもこのブログで詳しく書いたことがある。

blog.amedama.jp

docstring のおさらい

ひとまず docstring のおさらいから入ろう。 docstring はモジュールや関数などを解説するための特殊なコメント。

例えば次の FizzBuzz を実装した関数 fizzbuzz() に対して docstring を書いてみることにしよう。

# -*- coding: utf-8 -*-


def fizzbuzz(n):
    if n % 3 == 0 and n % 5 == 0:
        return 'FizzBuzz'

    if n % 3 == 0:
        return 'Fizz'

    if n % 5 == 0:
        return 'Buzz'

    return str(n)

docstring は関数の直後に書く。 例えばこんな感じ。 通常は docstring 用のコメントを書くのに """ または ''' を使う。

# -*- coding: utf-8 -*-


def fizzbuzz(n):
    '''
    FizzBuzz を実装した関数です。
    引数の整数に応じて次のような値を返します。
      - 3 で割り切れる: 'Fizz'
      - 5 で割り切れる: 'Buzz'
      - 3 と 5 の両方で割り切れる: 'FizzBuzz'
      - いずれの値でも割り切れない: 数値の文字列表現
    '''
    if n % 3 == 0 and n % 5 == 0:
        return 'FizzBuzz'

    if n % 3 == 0:
        return 'Fizz'

    if n % 5 == 0:
        return 'Buzz'

    return str(n)

docstring を書くと、それがモジュールや関数のドキュメントになる。 docstring で書かれたドキュメントは組み込み関数 help() を使うことで読める。

例えば、先ほどの fizzbuzz のサンプルコードを fizzbuzz.py という名前で保存しておこう。 そして、同じディレクトリで Python の REPL を起動する。 fizzbuzz() 関数をインポートしたら、それを help() 関数に渡してやろう。

>>> from fizzbuzz import fizzbuzz
>>> help(fizzbuzz)

すると、次のように fizzbuzz() 関数に書いた docstring を確認できる。

Help on function fizzbuzz in module fizzbuzz:

fizzbuzz(n)
    FizzBuzz を実装した関数です。
    引数の整数に応じて次のような値を返します。
      - 3 で割り切れる: 'Fizz'
      - 5 で割り切れる: 'Buzz'
      - 3 と 5 の両方で割り切れる: 'FizzBuzz'
      - いずれの値でも割り切れない: 数値の文字列表現

doctest を書いてみよう

doctest を書くには、まずテスト対象への入力と期待される出力の組が必要になる。 これには先ほど docstring を読んだのと同様に REPL を使うと良い。

例えば fizzbuzz() 関数にいくつかの値を入力して、その出力を確認しておこう。

$ python
>>> from fizzbuzz import fizzbuzz
>>> fizzbuzz(2)
'2'
>>> fizzbuzz(3)
'Fizz'
>>> fizzbuzz(4)
'4'
>>> fizzbuzz(5)
'Buzz'
>>> fizzbuzz(6)
'Fizz'
>>> fizzbuzz(7)
'7'
>>> fizzbuzz(14)
'14'
>>> fizzbuzz(15)
'FizzBuzz'
>>> fizzbuzz(16)
'16'

doctest で必要な作業のほとんどは上記のように REPL で得られた出力をそのまま docstring の中に埋め込むだけで終わる。 先ほどの fizzbuzz() 関数の中に先ほど得られた内容をそのまま埋め込んでみよう。 コードの前に入っているインデントに使われる空白は無視されるので、特に気にする必要はない。 そして、モジュールがスクリプトとして実行された際に doctest を実行するためのイディオムをソースコードの末尾に追加する。

# -*- coding: utf-8 -*-


def fizzbuzz(n):
    '''
    FizzBuzz を実装した関数です。
    引数の整数に応じて次のような値を返します。
      - 3 で割り切れる: 'Fizz'
      - 5 で割り切れる: 'Buzz'
      - 3 と 5 の両方で割り切れる: 'FizzBuzz'
      - いずれの値でも割り切れない: 数値の文字列表現

      >>> from fizzbuzz import fizzbuzz
      >>> fizzbuzz(2)
      '2'
      >>> fizzbuzz(3)
      'Fizz'
      >>> fizzbuzz(4)
      '4'
      >>> fizzbuzz(5)
      'Buzz'
      >>> fizzbuzz(6)
      'Fizz'
      >>> fizzbuzz(7)
      '7'
      >>> fizzbuzz(14)
      '14'
      >>> fizzbuzz(15)
      'FizzBuzz'
      >>> fizzbuzz(16)
      '16'

    '''
    if n % 3 == 0 and n % 5 == 0:
        return 'FizzBuzz'

    if n % 3 == 0:
        return 'Fizz'

    if n % 5 == 0:
        return 'Buzz'

    return str(n)


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

これで doctest を実行する準備は整った。 早速 fizzbuzz.py をスクリプトとして実行してみよう。

$ python fizzbuzz.py
$

出力結果が何もない状態で実行が終了した。 とはいえ、これで何も問題ない。

テストの実行結果を詳しく見たいときは -v オプションを付けて実行しよう。

$ python fizzbuzz.py -v
Trying:
    from fizzbuzz import fizzbuzz
Expecting nothing
ok
Trying:
    fizzbuzz(2)
Expecting:
    '2'
ok
Trying:
    fizzbuzz(3)
Expecting:
    'Fizz'
ok
Trying:
    fizzbuzz(4)
Expecting:
    '4'
ok
Trying:
    fizzbuzz(5)
Expecting:
    'Buzz'
ok
Trying:
    fizzbuzz(6)
Expecting:
    'Fizz'
ok
Trying:
    fizzbuzz(7)
Expecting:
    '7'
ok
Trying:
    fizzbuzz(14)
Expecting:
    '14'
ok
Trying:
    fizzbuzz(15)
Expecting:
    'FizzBuzz'
ok
Trying:
    fizzbuzz(16)
Expecting:
    '16'
ok
1 items had no tests:
    __main__
1 items passed all tests:
  10 tests in __main__.fizzbuzz
10 tests in 2 items.
10 passed and 0 failed.
Test passed.

今度は様々な入力に対して期待される結果と実際の結果が比較されていることがわかる。

スクリプトとして実行した時に doctest 以外の処理を走らせたい

先ほどの例ではモジュールをスクリプトとして実行した際に doctest が走るようになっていた。 とはいえ、doctest を書いた状態でスクリプトとして実行した時は別の処理を走らせたいというニーズももちろんあるはず。 そんなときはどうするかについても書いておく。

まずは先ほどのソースコードの末尾にあったイディオムを取り除こう。

# -*- coding: utf-8 -*-


def fizzbuzz(n):
    '''
    FizzBuzz を実装した関数です。
    引数の整数に応じて次のような値を返します。
      - 3 で割り切れる: 'Fizz'
      - 5 で割り切れる: 'Buzz'
      - 3 と 5 の両方で割り切れる: 'FizzBuzz'
      - いずれの値でも割り切れない: 数値の文字列表現

      >>> from fizzbuzz import fizzbuzz
      >>> fizzbuzz(2)
      '2'
      >>> fizzbuzz(3)
      'Fizz'
      >>> fizzbuzz(4)
      '4'
      >>> fizzbuzz(5)
      'Buzz'
      >>> fizzbuzz(6)
      'Fizz'
      >>> fizzbuzz(7)
      '7'
      >>> fizzbuzz(14)
      '14'
      >>> fizzbuzz(15)
      'FizzBuzz'
      >>> fizzbuzz(16)
      '16'

    '''
    if n % 3 == 0 and n % 5 == 0:
        return 'FizzBuzz'

    if n % 3 == 0:
        return 'Fizz'

    if n % 5 == 0:
        return 'Buzz'

    return str(n)

この状態で doctest を実行する場合には -m オプションに doctest を指定しつつ、テストしたいファイルを渡せば良い。

$ python -m doctest fizzbuzz.py
$

例外を扱う場合

テストの中で例外が上がる際の挙動について記述したい場合もあるはず。

例えば、次のように常に Exception を上げる関数の doctest を書きたい。 まずはこのソースコードを exception.py という名前で保存しておこう。

# -*- coding: utf-8 -*-


def always_raise():
    '''
    常に Exception を上げます。
    '''
    raise Exception('Oops!')

そして、先ほどと同じように REPL で例外が上がる際の出力を手に入れる。 ただ、この状態だと保存されているファイルなどがトレースバックの中に含まれていて邪魔だ。

$ python
>>> from exception import always_raise
>>> always_raise()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/amedama/Documents/workspace/exception.py", line 5, in always_raise
    raise Exception('Oops!')
Exception: Oops!

doctest に組み込む際には Traceback 以降の行は ... (ピリオドを3つ以上連続させる) で省略してしまって構わない。 必要なのは最初の Traceback ... の行と、最後の Exception: ... の行だけだ。

# -*- coding: utf-8 -*-


def always_raise():
    '''
    常に Exception を上げます。

    >>> from exception import always_raise
    >>> always_raise()
    Traceback (most recent call last):
        ...
    Exception: Oops!
    '''
    raise Exception('Oops!')

上記の doctest を実行してみよう。

$ python -m doctest -v exception.py
Trying:
    from exception import always_raise
Expecting nothing
ok
Trying:
    always_raise()
Expecting:
    Traceback (most recent call last):
        ...
    Exception: Oops!
ok
1 items had no tests:
    exception
1 items passed all tests:
   2 tests in exception.always_raise
2 tests in 2 items.
2 passed and 0 failed.
Test passed.

ばっちりだ。

使った感じ doctest はがっつりテストをするというよりも、どちらかというと使い方の例を明示しつつデグレードを防ぐような用途で使うのが良さそうな気がした。