現在地: ホーム Dive Into Python 3

難易度: ♦♦♢♢♢

ユニットテスト

確信は確実性の証拠とはならない。我々はそれほど確かでないことを、幾度となく心から信じ込んできたのだ。
Oliver Wendell Holmes, Jr.

 

飛び込(まない)

まったく最近の若い連中ときたら、速いコンピュータとステキな「動的」言語に甘やかされてやがる。書くのが最初で、出荷が二番目、デバッグは(やるとしても)三番目だ。俺の時代には規律があった。規律だ、おい聞けよ。プログラムをに書いてだな、パンチカードでコンピュータに入力するんだ。あれは楽しかったなあ!

この章では、整数とローマ数字を相互変換する一組のユーティリティ関数を書いて、それをデバッグしてもらう。ローマ数字の組み立て方とその有効性の検証法については、「ケーススタディ: ローマ数字」で学んだ。では一度そこに立ち返って、どうやったらそこから双方向なユーティリティを作れるかを考えよう。

ローマ数字の規則を見ると、興味深い洞察がいくつか得られる:

  1. ある特定の数をローマ数字で表現する方法は一つしかない。
  2. 逆もまた真なり。ある文字列が有効なローマ数字ならば、それは特定の一つの数を表している(つまり、ローマ数字は一通りにしか解釈できない)。
  3. ローマ数字で表しうる数の範囲には限りがある。具体的に言えば1から3999までの数だ。実際には、ローマ数字でもっと大きな数を表す方法はいくつかあって、例えば数字の上に棒線を引くことでその数字の1000倍の数を表すことができた。だがまあ、とりあえずこの章ではローマ数字は1から3999までの数しか表せないものとしよう。
  4. ローマ数字でゼロを表すことはできない。
  5. ローマ数字で負の数を表すことはできない。
  6. ローマ数字で分数や整数でない数を表すことはできない。

では、roman.pyモジュールに何が必要なのかを考えていこう。まず、主たる関数としてto_roman()from_roman()の二つがいるだろう。to_roman()関数は1から3999までの整数を引数にとり、対応するローマ数字を文字列として返す……

一旦手を止めてほしい。唐突かもしれないが、ここでちょっと違ったことをやろう。to_roman()関数が望み通りの動きをするかをチェックするテストケースを書くのだ。大丈夫、読み間違えでもなんでもない。実際にこれから、まだ書かれてもいないコードをテストするコードを書いてもらう。

これはテスト駆動開発 (TDD) と呼ばれている手法だ。変換を行うこの二つの関数 — to_roman()と後で出てくるfrom_roman() — は、これらをインポートする他のもっと大規模なプログラムとは独立に、一つの構成単位として書いたりテストしたりできる。Pythonにはユニットテストのためのフレームワークがある。その名もずばりunittestモジュールだ。

ユニットテストは、テストを中心に据える開発手法全般のかなめといえる存在だ。ユニットテストを書くなら、早い段階で書きあげた上で、コードや要件の変更に合わせてアップデートしていくことが重要になる。多くの人はコードを書く前にテストを書くやり方を推奨していて、実際にこの章でもこのスタイルを用いるのだが、ユニットテスト自体はいつ書いても有益なものなので間違えないように。

一つの問い

一つのテストケースは、そのコードに関するただ一つの問いのみに答える。また、テストケースというものは...

以上のことを前提に、一番最初の要件に対するテストケースを書いてみよう。

  1. to_roman()は、1から3999までのすべての整数に対応するローマ数字を返せなくてはならない。

一見すると、「このコードって本当に何かの役に立つの?」と思われるかもしれない。一つのクラスが定義されているが、中には__init__メソッドが入っていない。別のメソッドもあることはあるのだが、このメソッドは一度も呼び出されていない。 このスクリプトの__main__ブロックにしても、このクラスやメソッドを参照してすらいない。だが安心してほしい。これは実際に役に立つのだ。

[romantest1.pyをダウンロードする]

import roman1
import unittest

class KnownValues(unittest.TestCase):               
    known_values = ( (1, 'I'),
                     (2, 'II'),
                     (3, 'III'),
                     (4, 'IV'),
                     (5, 'V'),
                     (6, 'VI'),
                     (7, 'VII'),
                     (8, 'VIII'),
                     (9, 'IX'),
                     (10, 'X'),
                     (50, 'L'),
                     (100, 'C'),
                     (500, 'D'),
                     (1000, 'M'),
                     (31, 'XXXI'),
                     (148, 'CXLVIII'),
                     (294, 'CCXCIV'),
                     (312, 'CCCXII'),
                     (421, 'CDXXI'),
                     (528, 'DXXVIII'),
                     (621, 'DCXXI'),
                     (782, 'DCCLXXXII'),
                     (870, 'DCCCLXX'),
                     (941, 'CMXLI'),
                     (1043, 'MXLIII'),
                     (1110, 'MCX'),
                     (1226, 'MCCXXVI'),
                     (1301, 'MCCCI'),
                     (1485, 'MCDLXXXV'),
                     (1509, 'MDIX'),
                     (1607, 'MDCVII'),
                     (1754, 'MDCCLIV'),
                     (1832, 'MDCCCXXXII'),
                     (1993, 'MCMXCIII'),
                     (2074, 'MMLXXIV'),
                     (2152, 'MMCLII'),
                     (2212, 'MMCCXII'),
                     (2343, 'MMCCCXLIII'),
                     (2499, 'MMCDXCIX'),
                     (2574, 'MMDLXXIV'),
                     (2646, 'MMDCXLVI'),
                     (2723, 'MMDCCXXIII'),
                     (2892, 'MMDCCCXCII'),
                     (2975, 'MMCMLXXV'),
                     (3051, 'MMMLI'),
                     (3185, 'MMMCLXXXV'),
                     (3250, 'MMMCCL'),
                     (3313, 'MMMCCCXIII'),
                     (3408, 'MMMCDVIII'),
                     (3501, 'MMMDI'),
                     (3610, 'MMMDCX'),
                     (3743, 'MMMDCCXLIII'),
                     (3844, 'MMMDCCCXLIV'),
                     (3888, 'MMMDCCCLXXXVIII'),
                     (3940, 'MMMCMXL'),
                     (3999, 'MMMCMXCIX'))           

    def test_to_roman_known_values(self):           
        '''to_roman should give known result with known input'''
        for integer, numeral in self.known_values:
            result = roman1.to_roman(integer)       
            self.assertEqual(numeral, result)       

if __name__ == '__main__':
    unittest.main()
  1. テストケースを書くには、まずunittestモジュールのTestCaseクラスをサブクラス化する。このクラスには、テストケースで特定の条件をテストする時に便利なメソッドがたくさん入っている。
  2. これは整数/数字のペアのリストで、検証は私が自ら行った。このリストには、最も小さい10個の数、最も大きい数、一文字のローマ数字で表される数すべて、さらにこれら以外の有効な数字からランダムに抽出された数が含まれている。ありとあらゆる入力値を試す必要はないが、境界事例だと分かっているものについてはすべてテストするべきだろう。
  3. 個々のテストはそれぞれメソッドとして表される。メソッドは引数を取らなければ値も返さないようなものであり、メソッド名は4文字のtestで始まっていなければならない。もし、メソッドが例外を送出することなく普通に終了したならば、テストをパスしたものとみなされる。一方、例外が送出された場合には、テストは失敗したとみなされる。
  4. この部分では実際のto_roman()関数を呼び出している(正確に言えばto_roman()関数はまだ書かれていないのだが、まあどうにせよ、一度書けばここで呼び出されるようになる)。注意してほしいのは、ここでto_roman()APIが定められているということだ。つまり、この関数は一つの整数(変換する数)をとり、文字列(対応するローマ数字)を返さなくてはならない。もしAPIがこれと異なっていたら、このテストは失敗したものとみなされる。また、to_roman()を呼び出す時に、全く例外を捕捉していないことにも気を付けてほしい。これは意図的にそうしていることで、要するにto_roman()は、有効な入力値を与えられた場合に例外を送出するようなものであってはならないのだ。もし、to_roman()が例外を返したら、テストは失敗したものとみなされる。
  5. to_roman()関数を正しく定義し、適切に呼び出し、そして何の問題もなく処理が終わって値が返ってきたものとしよう。最後に残ったステップは、正しい値が返されているかどうかチェックすることだ。これはテストケース一般でチェックされる項目なので、TestCaseクラスに二つの値が等しいかどうかを調べるassertEqualメソッドが用意されている。to_roman()が返した値(result)が、返されるべき既知の値(numeral)と一致しなかったなら、assertEqualは例外を送出し、テストは失敗する。二つの値が等しければ、assertEqualは何もしない。だから、to_roman()のすべての戻り値が、返されるべき既知の値と一致したならば、assertEqualは一度も例外を送出せず、test_to_roman_known_valuesは正常に終了する。言い換えれば、to_roman()がこのテストをパスしたことになるのだ。

テストケースが出来上がったら、to_roman()関数のコードを書き始めることができる。だが、まずは最初に中身が空のto_roman()関数を作って、テストが失敗することを確かめなくてはならない。もし、何も書いていないのにパスできてしまうようなら、何のテストにもならないじゃないか! ユニットテストは例えるならばダンスのようなもので、テストがリードし、コードがフォローする。通らないようなテストを書いて、パスするまでコーディングするんだ。

# roman1.py

def to_roman(n):
    '''convert integer to Roman numeral'''
    pass                                   
  1. この段階では、to_roman()APIを定義するだけで、中身をコーディングしようとは思ってはいない(まずはテストを失敗させなくてはならないからだ)。この場合にはPythonの予約語のpassを使えばいい。これは実行されても、全く何の処理も行わない。

コマンドラインでromantest1.pyを起動してテストを実行しよう。コマンドラインのオプションとして-vをつければ、それぞれのテストケースが実行される際の処理の状況が詳しく出力されるようになる。運が良ければ、次のように出力されるはずだ:

you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
test_to_roman_known_values (__main__.KnownValues)                      
to_roman should give known result with known input ... FAIL            

======================================================================
FAIL: to_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest1.py", line 73, in test_to_roman_known_values
    self.assertEqual(numeral, result)
AssertionError: 'I' != None                                            

----------------------------------------------------------------------
Ran 1 test in 0.016s                                                   

FAILED (failures=1)                                                    
  1. このスクリプトを走らせるとunittest.main()が呼び出され、それぞれのテストケースが実行される。各々のテストケースはromantest.pyの中のクラスに入っているメソッドだ。このテストクラスはどのような構成をしていてもよい。いくつかのクラスがあって、それぞれにテストメソッドが一つずつ入っているというのでもよいし、複数のメソッドが入ったクラスが一つあるというのでも構わない。ただすべてのテストクラスがunittest.TestCaseを継承してさえいればいいのだ。
  2. 各々のテストについて、unittestモジュールはそのメソッドのdocstringとテストの成否を出力する。予想通り、このテストケースは失敗している。
  3. 失敗したテストケースについては、unittestは何が起こったのかをトレースして表示してくれる。このケースでは、assertEqual()を呼び出した際にAssertionErrorが送出されている。to_roman(1)'I'を返すものとされていたのに、そうならなかったからだ(この関数にはreturn文が置かれて無いので、PythonのNull値にあたるNoneが返されている)。
  4. それぞれのテストの詳細を出力した後で、unittestはいくつのテストが実行され、それにどれくらいの時間がかかったのかを表示する。
  5. まとめると、少なくとも一つのテストケースをパスしなかったので、このテストは失敗したことになる。なお、テストケースにパスしなかったという場合について、unittestはFailureとErrorを区別する。Failureとは、assertEqualassertRaisesといったassertXYZメソッドを呼びだしたが、表明された条件が真でなかったり、期待通りに例外が送出されなかったせいで失敗してしまった場合を指す。これ以外の、テストしているコードやユニットテストのテストケース自体から送出された例外はすべてErrorとされる。

これでようやく、to_roman()関数を書くことができる。

[roman1.pyをダウンロードする]

roman_numeral_map = (('M',  1000),
                     ('CM', 900),
                     ('D',  500),
                     ('CD', 400),
                     ('C',  100),
                     ('XC', 90),
                     ('L',  50),
                     ('XL', 40),
                     ('X',  10),
                     ('IX', 9),
                     ('V',  5),
                     ('IV', 4),
                     ('I',  1))                 

def to_roman(n):
    '''convert integer to Roman numeral'''
    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:                     
            result += numeral
            n -= integer
    return result
  1. roman_numeral_mapはタプルのタプルで、次の三つのものを定めている。すなわち、最も基本的なローマ数字の文字表記、ローマ数字の順番(MからIまで、数が大きい順に並べてある)、そしてそれぞれのローマ数字が表す値だ。この内側のタプルはすべて(numeral, value)というペアからなっている。ちなみに、ここでは一文字のローマ数字だけではなく、CM(「1000引く100」)など二文字のものについても定めている。こうすることで、to_roman()関数のコードがより簡潔なものになるのだ。
  2. roman_numeral_mapにデータを詰め込んだことが功を奏していて、引き算を用いて数を表すルールを扱うための特別なロジックを組む必要がなくなっている。つまり、ローマ数字に変換するには次のようにすればいいのだ。まず、単純にroman_numeral_mapをイテレートして、入力値以下の数の中で最大の整数値を探す。次に、そのような数が見つかったら、対応するローマ数字を出力値の末尾に付け加え、さらに入力値からその数を引く。後はこれを繰り返し、また繰り返してさらに繰り返せばいい。

to_roman()関数がどのように動くのかまだよく分からないのなら、print()whileループの末尾につけてみるといい:


while n >= integer:
    result += numeral
    n -= integer
    print('subtracting {0} from input, adding {1} to output'.format(integer, numeral))

デバッグ用のprint()文をつけると、次のように出力されるようになる:

>>> import roman1
>>> roman1.to_roman(1424)
subtracting 1000 from input, adding M to output
subtracting 400 from input, adding CD to output
subtracting 10 from input, adding X to output
subtracting 10 from input, adding X to output
subtracting 4 from input, adding IV to output
'MCDXXIV'

どうやらto_roman()関数はちゃんと動くようだ。少なくとも、この手作業の抜き取り検査ではうまくやっているように見える。だが、さっき書いたテストケースはパスするだろうか?

you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok               

----------------------------------------------------------------------
Ran 1 test in 0.016s

OK
  1. やったー!to_roman()関数は「既知の値」のテストケースに通ったぞ。このテストはありうるケースすべてを試しているわけではないが、この関数を多様な入力値、例えば、一文字のローマ数字に対応するすべての数、最大の数(3999)、最も長いローマ数字に対応する数(3888)などでテストしている。だから、この関数は適切な入力値ならどれでもうまく処理できると考えても差し支えないだろう。

「適切な」入力値だって? ふーむ。じゃあ不適切な値を入力したらどうなるんだ?

“Halt And Catch Fire”

適切な入力を与えて成功するかをテストするだけでは十分ではなく、適切でない入力を与えられた時に失敗することも検証しなくてはならない。それもどんな失敗でも良いというわけではなく、狙った通りに失敗しなくてはならないのだ。

>>> import roman1
>>> roman1.to_roman(4000)
'MMMM'
>>> roman1.to_roman(5000)
'MMMMM'
>>> roman1.to_roman(9000)  
'MMMMMMMMM'
  1. 明らかにこれは意図していない戻り値だ — そもそも、ちゃんとしたローマ数字ですらない! 実を言うと、ここにある数はすべて入力値の制限範囲を越えているのだが、それでも関数はとりあえず戻り値をでっちあげているのだ。こっそり不適当な値を返すというのはものすごーく悪いことだ。どうせ落ちるプログラムなら、早いうちに騒々しく落ちてくれた方がずっと良い。言い習わされているように「Halt and catch fire」(中断の後、発火せよ)というやつだ。Pythonでは、例外の送出がHalt and Catch Fireの役目を果たす。

考えるべき問題は「どうやったらこれをテストできる条件として表せられるのだろう?」ということだ。取っ掛かりとして、まずはこのようにしたらどうだろう:

to_roman()関数は3999より大きい整数を与えられたらOutOfRangeErrorを送出しなければならない。

このテストはどんな感じになるだろうか?

[romantest2.pyをダウンロードする]

class ToRomanBadInput(unittest.TestCase):                                 
    def test_too_large(self):                                             
        '''to_roman should fail with large input'''
        self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)  
  1. 前のテストケースと同じように、unittest.TestCaseを継承したクラスを作っている。一つのクラスに複数のテストを入れてもいいのだが(この章の後の方ではそうしている)、ここでは新しいクラスを作ることにする。このテストは先ほどのテストとは性質が異なったものだからだ。適切な入力値に対するテストを一つのクラスにまとめて、不適切な入力値に対するテストはまた別のクラスにまとめるのだ。
  2. 前のテストケースと同様に、テストの実体はクラスに収められている、testで始まる名前のメソッドだ。
  3. unittest.TestCaseクラスにはassertRaisesメソッドが用意されているが、これは次のような引数をとるものだ。すなわち、送出されるべき例外、テスト対象の関数、そして関数に渡す引数だ(テスト対象の関数が複数の引数をとる場合には、それらの引数を順に並べてassertRaisesに渡せばいい。そうすれば、そのまま関数に渡してくれる)。

コードの最後の行をよく見てほしい。to_roman()を直接呼び出して、特定の例外を送出しているかどうかを(try...exceptブロックを使って)自ら調べなくても、assertRaisesメソッドがこの処理を全部カプセル化してくれている。だから、どんな例外が送出されるべきなのか(roman2.OutOfRangeError)と、対象となる関数(to_roman())、そして関数がとる引数(4000)を渡すだけでよいのだ。後は、assertRaisesメソッドがto_roman()を呼び出して、roman2.OutOfRangeErrorが送出されるかどうかをチェックしてくれる。

また、to_roman()関数自体が引数として渡されていることにも注意してほしい。この関数を呼び出しているわけでも、関数の名前を文字列として渡しているわけでもない。そういえば、少し前のところで、Pythonではあらゆるものがオブジェクトだということがどれほど便利かってこと話したっけ?

それでは、この新しいテストを組み込んだテストスイートを走らせたらどうなるのだろうか?

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ERROR                         

======================================================================
ERROR: to_roman should fail with large input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest2.py", line 78, in test_too_large
    self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)
AttributeError: 'module' object has no attribute 'OutOfRangeError'      

----------------------------------------------------------------------
Ran 2 tests in 0.000s

FAILED (errors=1)
  1. 当然テストは失敗すると思っただろうが(このテストをパスするためのコードを何も書いてないからね)、しかし...「Fail」ではなく、代わりに「Error」が返ってくるのだ。これは微妙な差だが、違いは大きい。実は、ユニットテストにはPass、Fail、Errorの三つの戻り値がある。Passというのはもちろん、テストをパスしたことを意味する — 関数が期待通り動いてくれたということだ。「Fail」というのは、一つ前のテストケースが(そのためのコードを書き上げる直前まで)返していたものだ — つまり、コードを実行したが、予期した結果が出なかった場合を表す。「Error」というのは、そもそもコードを正しく実行できなかったことを示している。
  2. どうしてコードを正しく実行できなかったのだろうか? トレースバックを見れば答えが分かる。テストしたモジュールの中にOutOfRangeErrorという名前の例外が存在しなかったのだ。思い出して欲しいのだが、この例外は範囲外の入力値が渡された時に送出されるべき例外としてassertRaises()メソッドに渡したものだった。しかし、そもそもこの例外は存在しなかったので、assertRaises()メソッドの呼び出しが失敗してしまったのだ。ここでは結局、to_roman()関数がテストされることはなかった。そこに行き着きさえしなかったのだ。

この問題を解決するためには、OutOfRangeError例外をroman2.pyの中で定義する必要がある。

class OutOfRangeError(ValueError):  
    pass                            
  1. 例外とはクラスだ。「Out of Range」エラーはValue Errorの一種だと言える — 引数の値が受け取ることのできる範囲を越えているのだ。だから、この例外は組み込みのValueError例外を継承している。これは絶対に必要というわけではないのだが(基底クラスのExceptionを継承してもいい)、こうする方が良さそうだ。
  2. 例外というものは実際には何の処理も行わないのだが、クラスを作るには少なくとも一行のコードが必要だ。passは呼び出してもまったく何もしない文で、しかしそれでもPythonのコード行としての役割は果たしてくれる。だからこのように書くとクラスが作れるわけだ。

それではテストスイートをもう一度実行しよう。

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... FAIL                          

======================================================================
FAIL: to_roman should fail with large input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest2.py", line 78, in test_too_large
    self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)
AssertionError: OutOfRangeError not raised by to_roman                 

----------------------------------------------------------------------
Ran 2 tests in 0.016s

FAILED (failures=1)
  1. この新しいテストをパスするところまでは行かないが、エラーが返されることもなく、その代わりにテストは失敗している。これで一歩前進というわけだ! これは要するに、assertRaises()メソッドが正しく呼び出され、実際にto_roman()関数がこのユニットテストフレームワークにテストされたということを意味しているのだ。
  2. もちろん、to_roman()関数は先ほど定義したOutOfRangeError例外を送出していない。まだそうするように修正を加えていないからだ。こいつは朗報じゃないか! このテストケースが有効なテストケース — パスするためのコードを書くまで通らないようなテストケース — だということだからだ。

これで、このテストをパスするためのコードを書くことができる。

[roman2.pyをダウンロードする]

def to_roman(n):
    '''convert integer to Roman numeral'''
    if n > 3999:
        raise OutOfRangeError('number out of range (must be less than 4000)')  

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  1. 実に分かりやすいコードだ。与えられた入力値(n)が3999より大きければ、OutOfRangeError例外を送出する。ちなみに、このユニットテストは、例外と一緒に出力されるエラー文字列については何もチェックしていないが、これ用のテストはまた別に書くことができる(ただし、文字列の多言語化の問題に注意すること。この問題はユーザーの言語や環境に依存する)。

これでテストをパスするようになっただろうか? 試してみよう。

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok                            

----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK
  1. やった! テストを二つともパスしたぞ。テストとコーディングの間を行ったり来たりしながら、繰り返し作業したので、テストが「Fail」から「Pass」になったのは、さっき書いた二行のコードのおかげだと確信できる。この種の確信を得るのは大変だが、そのコードが使われる年月を総じれば、きっとその元はとれるだろう。

もっと中断させて、もっと発火させる

大きすぎる数字と並んで、小さすぎる数字についてもテストしなくてはならない。機能要件で述べたように、ローマ数字は0や負の数を表すことはできないのだ。

>>> import roman2
>>> roman2.to_roman(0)
''
>>> roman2.to_roman(-1)
''

うーむ。これはあまりよろしくない。それぞれの条件に対するテストを付け加えよう。

[romantest3.pyをダウンロードする]

class ToRomanBadInput(unittest.TestCase):
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 4000)  

    def test_zero(self):
        '''to_roman should fail with 0 input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 0)     

    def test_negative(self):
        '''to_roman should fail with negative input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, -1)    
  1. test_too_large()メソッドは前の段階から変わっていない。このメソッドのコードをここに載せたのは、新しいコードがどこに付け加わったのかを示すためだ。
  2. これが新しいテストのtest_zero()メソッドだ。test_too_large()と同じように、unittest.TestCaseの中に定められているassertRaises()メソッドを用いて、to_roman()を0を引数として呼び出し、適切な例外(OutOfRangeError)が送出されるかどうかを確認している。
  3. test_negative()メソッドもこれとほとんど同じで、-1to_roman()に渡しているというだけの違いしかない。この新しいテストのいずれかにおいてOutOfRangeErrorが送出されなかった場合(その原因としては、関数が実際の値を返したか別の例外を返したかのどちらか)には、テストは失敗したものとみなされる。

それではテストが失敗することを確かめよう:

you@localhost:~/diveintopython3/examples$ python3 romantest3.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... FAIL
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... FAIL

======================================================================
FAIL: to_roman should fail with negative input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest3.py", line 86, in test_negative
    self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, -1)
AssertionError: OutOfRangeError not raised by to_roman

======================================================================
FAIL: to_roman should fail with 0 input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest3.py", line 82, in test_zero
    self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 0)
AssertionError: OutOfRangeError not raised by to_roman

----------------------------------------------------------------------
Ran 4 tests in 0.000s

FAILED (failures=2)

すばらしい。予想通りどちらのテストも失敗している。さて、今度はコードに戻って、どうすればテストをパスできるようになるのかを考えてみよう。

[roman3.pyをダウンロードする]

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 4000):                                              
        raise OutOfRangeError('number out of range (must be 1..3999)')  

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  1. この部分ではPython流のショートカットが上手に使われていて、複数の比較演算子が同時に評価されている。意味的にはif not ((0 < n) and (n < 4000))と同じだが、こちらの方が読みやすいのだ。この一行のコードで、大きすぎる数、負の数、ゼロの三種類の入力値すべてが補足される。
  2. 条件を変えたら、人が読む用のエラー文字列も修正するのを忘れないように。修正しなくてもunittestフレームワークには何の影響も及ぼさないのだが、間違った説明のついた例外が送出されると、手作業でのデバッグが面倒になるからだ。

本筋とは関係のない例をいくつも挙げて、複数の比較演算子を同時に評価する方法が上手く動くことを示してもいいのだが、ここではユニットテストを実行することで直接証明してみせよう。

you@localhost:~/diveintopython3/examples$ python3 romantest3.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.016s

OK

さらにもう一つ……

数字をローマ数字に変換する際の機能要件にはもう一つあった。整数以外の数の扱いだ。

>>> import roman3
>>> roman3.to_roman(0.5)  
''
>>> roman3.to_roman(1.0)  
'I'
  1. こいつは良くない。
  2. こいつはもっと悪い。どちらのケースでも例外を送出するべきなのに、値をでっちあげて返してしまっている。

整数でない数をテストするのは難しいことではない。まず、NotIntegerError例外を定義しよう。

# roman4.py
class OutOfRangeError(ValueError): pass
class NotIntegerError(ValueError): pass

次に、NotIntegerError例外が送出されるかどうかをチェックするテストケースを書く。

class ToRomanBadInput(unittest.TestCase):
    .
    .
    .
    def test_non_integer(self):
        '''to_roman should fail with non-integer input'''
        self.assertRaises(roman4.NotIntegerError, roman4.to_roman, 0.5)

テストがちゃんと失敗するか確かめよう。

you@localhost:~/diveintopython3/examples$ python3 romantest4.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_non_integer (__main__.ToRomanBadInput)
to_roman should fail with non-integer input ... FAIL
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

======================================================================
FAIL: to_roman should fail with non-integer input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest4.py", line 90, in test_non_integer
    self.assertRaises(roman4.NotIntegerError, roman4.to_roman, 0.5)
AssertionError: NotIntegerError not raised by to_roman

----------------------------------------------------------------------
Ran 5 tests in 0.000s

FAILED (failures=1)

テストをパスするようなコードを書こう。

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 4000):
        raise OutOfRangeError('number out of range (must be 1..3999)')
    if not isinstance(n, int):                                          
        raise NotIntegerError('non-integers can not be converted')      

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  1. 組み込みのisinstance()関数は、変数が特定の型(厳密に言えば、ここにはその型を継承した型も含む)かどうかを調べるものだ。
  2. 引数のnintでなかったら、さっき新しく作ったNotIntegerErrorを送出する。

最後に、このコードで本当にテストにパスするようになったかをチェックする。

you@localhost:~/diveintopython3/examples$ python3 romantest4.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_non_integer (__main__.ToRomanBadInput)
to_roman should fail with non-integer input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 5 tests in 0.000s

OK

to_roman()関数はすべてのテストを見事にパスした。もうこれ以上テストを思いつかないので、from_roman()の方に移ることにしよう。

すばらしき対称性

ローマ数字の文字列を整数に直すのは、整数をローマ数字に直すことよりも難しいように見える。もちろん、ここには有効なローマ数字かどうかのチェックの問題が存在する。ある整数が0より大きいかどうかをチェックするのは簡単だが、ある文字列が有効なローマ数字かどうかをチェックするのは少し難しいことだ。しかし、私たちは既にローマ数字をチェックする正規表現を作成している。だから、この部分はもう完成しているのだ。

文字列をどう変換するかという問題自体はまだ残っているが、すぐ後で見るように、ローマ数字と整数値との対応関係を詰め込んだデータ構造があるおかげで、from_roman()関数のコードの核はto_roman()関数と同じくらい単純なものになるのだ。

しかし、まず最初はテストだ。ここでは正確に変換されているかどうかを抜き取り検査する「既知の値」のテストが必要だろう。テストスイートには既に既知の値の対応表が入っているので、こいつを再利用しよう。

    def test_from_roman_known_values(self):
        '''from_roman should give known result with known input'''
        for integer, numeral in self.known_values:
            result = roman5.from_roman(numeral)
            self.assertEqual(integer, result)

ここにはすばらしい対称性がある。to_roman()関数とfrom_roman()関数は互いを逆にしたもので、前者は整数を特定の形式の文字列に変換し、後者は特定の形式の文字列を整数に変換する。理論的には、数を「往復」させることができなくてはならない。つまり、to_roman()関数に渡して文字列に直し、続いてその文字列をfrom_roman()関数に渡して整数に直した場合に、最初の数が戻ってこなくてはならないのだ。

n = from_roman(to_roman(n)) for all values of n

ここでは、「all values」とは1..3999の間のすべての数を指している。これがto_roman()関数の適切な入力値の範囲だからだ。さて、この対称性を用いて、1..3999のすべての値に対してto_roman()を呼び出し、さらにto_roman()で変換を施して、出力された値が元の入力値と同じかをチェックするテストケースを作ることができる。

class RoundtripCheck(unittest.TestCase):
    def test_roundtrip(self):
        '''from_roman(to_roman(n))==n for all n'''
        for integer in range(1, 4000):
            numeral = roman5.to_roman(integer)
            result = roman5.from_roman(numeral)
            self.assertEqual(integer, result)

これらの新しいテストはまだ失敗すらしない。from_roman()をまったく定義していないので、実行してもエラーが送出されるだけだ。

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
E.E....
======================================================================
ERROR: test_from_roman_known_values (__main__.KnownValues)
from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 78, in test_from_roman_known_values
    result = roman5.from_roman(numeral)
AttributeError: 'module' object has no attribute 'from_roman'

======================================================================
ERROR: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 103, in test_roundtrip
    result = roman5.from_roman(numeral)
AttributeError: 'module' object has no attribute 'from_roman'

----------------------------------------------------------------------
Ran 7 tests in 0.019s

FAILED (errors=2)

とりあえず空の関数を定義すれば、この問題は解決する。

# roman5.py
def from_roman(s):
    '''convert Roman numeral to integer'''

(気が付いただろうか? この関数はドキュメンテーション文字列のみで定義されているのだ。これはPythonでは正式に認められていることだ。実際に、これを推奨しているプログラマだっている。「スタブするな、ドキュメントせよ!」)

このテストケースはこれで実際に失敗するようになったはずだ。

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
F.F....
======================================================================
FAIL: test_from_roman_known_values (__main__.KnownValues)
from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 79, in test_from_roman_known_values
    self.assertEqual(integer, result)
AssertionError: 1 != None

======================================================================
FAIL: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 104, in test_roundtrip
    self.assertEqual(integer, result)
AssertionError: 1 != None

----------------------------------------------------------------------
Ran 7 tests in 0.002s

FAILED (failures=2)

それではfrom_roman()関数を書こう。

def from_roman(s):
    """convert Roman numeral to integer"""
    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index:index+len(numeral)] == numeral:  
            result += integer
            index += len(numeral)
    return result
  1. この部分のパターンはto_roman()と同じで、ローマ数字のデータ構造(タプルからなるタプル)をイテレートしていっている。ただし、前のコードではできる限り大きな整数値にマッチするようにしていたが、ここではできる限り「最大の」ローマ数字の文字列にマッチするようにしている。

from_roman()関数がどのように動いているのかよく分からなかったら、print文をwhileループの末尾につけてみるといい:

def from_roman(s):
    """convert Roman numeral to integer"""
    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
            print('found', numeral, 'of length', len(numeral), ', adding', integer)
>>> import roman5
>>> roman5.from_roman('MCMLXXII')
found M of length 1, adding 1000
found CM of length 2, adding 900
found L of length 1, adding 50
found X of length 1, adding 10
found X of length 1, adding 10
found I of length 1, adding 1
found I of length 1, adding 1
1972

テストをもう一度実行しよう。

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
.......
----------------------------------------------------------------------
Ran 7 tests in 0.060s

OK

ここから興味深いことが二つ分かる。一つ目は、from_roman()は適切な入力値に関しては(少なくとも全ての既知の値については)うまく動くということだ。二つ目は、この関数が「往復」テストもパスしているということだ。既知の値に関するテスト結果と合わせると、to_roman()from_roman()は適切な値ならどんなものについても正しく処理できると考えてもよさそうだ。(ただし絶対ではない。理論上は、特定の入力値を間違ったローマ数字に変換するバグがto_roman()に存在していて、かつfrom_roman()にも対応するバグが存在し、to_roman()が誤って生成したローマ数字を元の入力値に誤変換しているということもありうる。アプリケーションや要件によってはこの可能性が問題になるかもしれないが、その場合には問題が解決するまでテストケースをより包括的なものにしていけばよい)。

不適切な入力値

これでfrom_roman()は適切な入力値をうまく処理できるようになった。それでは最後のパズルのピース — 不適切な入力値の処理の問題—に取り組むとしようか。この問題は結局のところ、文字列を調べて有効なローマ数字かどうかを判断する方法を見つけ出すことに帰着する。これはto_roman()関数の数字の入力値をチェックする処理よりも本質的に難しいものだ。しかし、私たちには意のままに使える強力なツールがある。そう、正規表現だ(正規表現になじみがないなら、この折に正規表現の章を読んでおいて欲しい)。

ケーススタディ: ローマ数字で見たように、MDCLXVIの文字を使ってローマ数字を組み立てる場合には、いくつかの単純なルールがある。ルールを見直してみよう:

使えそうなテストとしては、ある数字が繰り返し使われすぎているような文字列をfrom_roman()に渡した場合に例外が送出されるかどうか確かめるというのがあるだろう。どれぐらい繰り返されていれば多すぎると言えるのかは、その数字によって異なる。

class FromRomanBadInput(unittest.TestCase):
    def test_too_many_repeated_numerals(self):
        '''from_roman should fail with too many repeated numerals'''
        for s in ('MMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)

他にも、繰り返すことのできない特定のパターンをチェックするのもテストとして有用だろう。例えば、IX9だがIXIXはローマ数字として有効ではない。

    def test_repeated_pairs(self):
        '''from_roman should fail with repeated pairs of numerals'''
        for s in ('CMCM', 'CDCD', 'XCXC', 'XLXL', 'IXIX', 'IVIV'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)

三つ目のテストでは、数字が正しい順番、つまり大きいものから小さいものへと並んでいるかでチェックしよう。例えば、CL150を表すが、LCは有効な数字ではない。50を表す数字が100を表す数字の前にくることはないからだ。このテストには不適切な数字が前に来ているパターンをランダムに選んで入れるとしよう。Mの前にIがあるとか、Xの前にVがあるとかいう場合だ。

    def test_malformed_antecedents(self):
        '''from_roman should fail with malformed antecedents'''
        for s in ('IIMXCC', 'VX', 'DCM', 'CMM', 'IXIV',
                  'MCMC', 'XCX', 'IVI', 'LM', 'LD', 'LC'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)

これら三つのテストはすべてfrom_roman()InvalidRomanNumeralErrorという新しい例外を送出することを前提としているが、この例外はまだ定義されていない。

# roman6.py
class InvalidRomanNumeralError(ValueError): pass

今のところfrom_roman()関数にはローマ数字の有効性をチェックする部分がないので、この三つのテストはすべて失敗しなくてはならないはずだ(もしこれで失敗しなかったら、一体何のテストになってるっていうんだい?)。

you@localhost:~/diveintopython3/examples$ python3 romantest6.py
FFF.......
======================================================================
FAIL: test_malformed_antecedents (__main__.FromRomanBadInput)
from_roman should fail with malformed antecedents
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 113, in test_malformed_antecedents
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

======================================================================
FAIL: test_repeated_pairs (__main__.FromRomanBadInput)
from_roman should fail with repeated pairs of numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 107, in test_repeated_pairs
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

======================================================================
FAIL: test_too_many_repeated_numerals (__main__.FromRomanBadInput)
from_roman should fail with too many repeated numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 102, in test_too_many_repeated_numerals
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

----------------------------------------------------------------------
Ran 10 tests in 0.058s

FAILED (failures=3)

よし、うまくいった。後は有効なローマ数字かをチェックする正規表現from_roman()関数に組み込めばいい。

roman_numeral_pattern = re.compile('''
    ^                   # beginning of string
    M{0,3}              # thousands - 0 to 3 Ms
    (CM|CD|D?C{0,3})    # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 Cs),
                        #            or 500-800 (D, followed by 0 to 3 Cs)
    (XC|XL|L?X{0,3})    # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 Xs),
                        #        or 50-80 (L, followed by 0 to 3 Xs)
    (IX|IV|V?I{0,3})    # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 Is),
                        #        or 5-8 (V, followed by 0 to 3 Is)
    $                   # end of string
    ''', re.VERBOSE)

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not roman_numeral_pattern.search(s):
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))

    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index : index + len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result

では再びテストを実行するとしよう……

you@localhost:~/diveintopython3/examples$ python3 romantest7.py
..........
----------------------------------------------------------------------
Ran 10 tests in 0.066s

OK

そして今年の拍子抜け賞に輝くのは……「OK」の文字です。これはすべてのテストをパスした際にunittestモジュールが出力したものであります。

© 2001– Mark Pilgrim
© Fukada, Fujimoto(日本語版)