このブログを検索

第9章 ユニットテスト

訳注: 原著ではコードのファイル名がroman1.py, roman2.py, ...と数字が変えて説明されていますが、このページでは全て数字は1だけを使っています。

9.1. 飛び込む(飛び込まない)


いまどきの子どもというものは・・・。速いコンピュータや極上の"ダイナミック言語"で甘やかされています。一に書く、二に実行、三に(あれば)デバッグです。私の時代では、訓練が必要でした。あえて言いましょう、訓練と! プログラムは手書きで、パンチカードでコンピュータに入れていました。みんなそれが好きだったんです!

この章では、ローマ数字と数字を変換する一連のユーティリティ関数を書いて、デバッグしていきます。ケーススタディ:ローマ数字(5.3.)では、どのようにしてローマ字を判定するかを試しました。これを、逆の変換もできるように拡張するにはどうすればよいか、考えてみましょう。

ローマ数字のルールを見ていると、面白い発見がいくつもあります。

  1. ある数をローマ数字で表す方法は、一意的です。
  2. 逆もまた然りで、文字列がローマ数字として成立していれば、必ず決まった数字を表します(つまり、解釈は1通りに決まります)
  3. ローマ数字で表現できる数の範囲には限度があります。具体的には、1から3999までです。(実際には、大きな数字を表現する方法がありました。例えば、数字記号の上にバーをつけると1000倍を示します。しかし、この章の目的に合わせてローマ数字は1から3999までと規定しましょう。)
  4. ローマ数字には、0を表す方法がありません。
  5. ローマ数字には、マイナスの数字を表す方法がありません。
  6. ローマ数字には、分数や非整数を表す方法がありません。

まず、roman.pyモジュールがどうあるべきかを考えてみましょう。モジュールにはto_roman()、from_roman()という2つの主要な関数があります。to_roman()関数は1から3999の整数を受け取り、ローマ数字を表す文字列を返し・・・

そこでストップです。少し予想外のことをやってみましょう。to_roman()関数が狙い通りの動作をするか、チェックするテストケースを書くのです。意味が理解できましたか?まだ書いていないコードをテストするためのコードを書くのです。

この方法は、テスト駆動開発(test-driven development, TDD)と呼ばれています。2つの変数関数のセット ― to_roman()、from_roman()をユニットとしてテストします。これらをインポートする、別の大きなプログラムからは切り離しておきます。Pythonにはユニットテストのためのフレームワークがあり、そのモジュールの名前はunittestです。

ユニットテストは、テストを至上とするこの開発方針を通じて重要な部分になります。ユニットテストのコードは先に書いておいて、テスト対象コードと要求仕様が変わったらアップデートし続けることが重要です。多くの人が、テストコードをテスト対象のコードよりも先に書くことを推奨しています。この章でもその形式でデモしていきます。ただし、ユニットテストは、どの段階で書いても役に立ちます。
  • コードを書く前にユニットテストを書くことで、実用的な形でコードを具体化していくことができます。
  • コードを書いている間、ユニットテストによって過剰なコーディングを避けることができます。すべてのテストケースをパスした時点で、その関数は完成です。
  • コードをリファクタリングしているとき、新しいバージョンが以前のものと同様に振る舞うことを証明するためには、ユニットテストが役立ちます。
  • コードをメンテナンスしているとき、誰かが叫びながらやってきて最新の変更で古いコードが壊れたと言ってきても、テストがあれば言い逃れをすることができます。(「ですが、私が調べたときはユニットテストはすべて通ったので」・・・と。)
  • チームでコードを書いている場合、包括的なテストスイートがあると他の人のコードが自分のコードによって破壊される可能性が劇的に減ります。なぜなら、ユニットテストを最初に走らせることができるからです。(コードスプリントでこういう場面を見たことがあります。チームで分担して、それぞれが自分のタスクに対して仕様を受け取ってユニットテストを書きます。そのテストをチームの他のメンバーにシェアします。このやり方なら、他人が動かせない逸脱した開発用コードを書くことがなくなります。)

9.2. 質問は1つ

1つのテストケースは、テスト対象コードに対する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()
①テストケースを書くために、まずunittestモジュールのTestCaseクラスをサブクラスにします。このクラスは役に立つメソッドを数多く提供してくれるので、テストケースで特定の条件をテストするために使うことができます。

②これは手動で確認した数字/文字の組合せのタプルです。最小の10の位、最大の数、1文字のローマ数字に対応するすべての数のほか、ランダムに選んだ有効な数字が入っています。すべての可能性をテストする必要はなく、明らかなエッジケースをテストすればよいのです。

③個々のテストはすべて、自分自身のメソッドです。テストメソッドは、パラメータを受け取らず、値を返さず、名前はtestの4文字で始まっている必要があります。テストメソッドが例外を出さずに通常終了したならば、そのテストはパスしたということになります。例外が出れば、テストは失敗です。

④ここで実際にto_roman()関数を呼び出しています(まだ関数は書かれていませんが、書かれたあと、この行で呼び出します)。この行では、to_roman()関数のためのAPIを定義していることに注意します。整数(変換する数)を受け取って、文字列(ローマ数字表現)を返さなければなりません。APlから文字列が返らなければ、このテストは失敗となります。to_roman()関数を呼び出すときに例外を出さないことも、確認します。これは狙い通りになっています。to_roman()関数は、有効な入力で呼び出すときには例外を出してはいけないので、今回の入力値はすべて有効であるはずだからです。to_roman()関数が例外を出していれば、このテストは失敗です。

⑤to_roman()関数が、正しく定義され、正しく呼び出され、全て成功し、値を返したと仮定します。最後のステップは正しい値を返したかどうかを確認することです。この質問に対しては、TestCaseクラスにあるassertEqualメソッドによって、2つの値が等しいかを確認します。to_roman()から返った結果resultが、期待している既知の値(文字列)に一致しなければ、assertEqualは例外を出してテストは失敗します。2つの値が等しければ、assertEqualは何もしません。to_roman()から返るすべての値が期待した既知の値であれば、assertEqualは例外を一切出さないので、最終的にtest_to_roman_known_valuesは通常終了し、to_roman()はこのテストをパスしたことになります。

テストケースが完成したら、to_roman()関数のコーディングをスタートできます。まず、空の関数を作ってテストが失敗することを確認します。もしもコードを書く前にテストを成功してしまったら、そのテストはコードに対して何もしていないことになります! ユニットテストはダンスです。テストがリードして、コードがフォローします。失敗するテストを書き、通過するまでコードを直します。
# roman1.py

def to_roman(n):
    '''convert integer to Roman numeral'''
    pass           
①この段階でto_roman()関数のAPlを定義したいのですが、まだコードを書きたくありません。(最初のテストは失敗する必要があります。)通り抜けるためには、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)                                                    # ⑤
①スクリプトを実行すると、unittest.main()が各テストケースを走らせます。ここでのテストケースとは、romantest. pyのクラスにあるメソッドです。テストケースのクラスが秩序立っているは必要ありません。それぞれが単独のテストメソッドを持っていてもよいですし、複数のテストメソッドを持つクラスがあってもかまいません。必要なのは、各クラスがunittest.TestCaseから派生していることだけです。

②各テストケースで、unittestモジュールはドキュメント文字列を出力してメソッドの成功・失敗を伝えます。想定通りに、このテストケースは失敗します。

③失敗した各テストケースについて、unittestは実際に何が起こったかを示す履歴情報を表示します。この場合、assertEqual()を呼び出したときにAssertionErrorが起きています。to_roman(1)が'I'を返すことを期待していましたが、返らなかったためです(明確なreturn文はありませんが、関数は Null値Noneを返しています)。

④各テストの詳細に続いて、unittestはテストの数とかかった時間のサマリを表示します。

⑤全体として、少なくとも1つのテストケースが失敗したため、テストは失敗です。1つでも通過しないテストケースがあれば、unittestは失敗とエラーの間だと判断します。失敗(failure)とは、assertEqualやassertRaisesのような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
①roman_numeral_mapはタプルのタプルで、3つのことを定義しています。ローマ数字の基本となる文字、口ーマ数字の順序(降順でMからIまで)、各ローマ数字に対する値です。中のタプルペアは(ローマ数字、値)を表しています。1文字のローマ数字だけではなく、2文字のペア、例えばCM (1000より100少ない)も定義します。これによってto_roman()関数コードがよりシンプルになります。

②ここで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
①やりました! to_roman()関数は"値を知っている"テストケースをパスしました。このテストは包括的ではありませんが、すべての1文字のローマ数字、可能な最大の入力(3999)、ローマ数字の文字数が最大の入力(3888)といったさまざまな入力を関数に入れています。この時点で、有効な入力であればどんな値に対しても関数が動くという根拠のある自信が得られました。

有効な入力ですって?・・・では、有効でない入力とは何でしょうか?

9.3. 停止!やり直し!

有効な入力を受け取ったときに関数が成功するだけではテストとして充分ではありません。つまり、悪い入力を入れたら失敗する、ということをテストしなければなりません。そして、どんな失敗でもよいという訳ではなく、期待した失敗をしなければなりません。
>>> import roman1
>>> roman1.to_roman(4000)
'MMMM'
>>> roman1.to_roman(5000)
'MMMMM'
>>> roman1.to_roman(9000)  # ①
'MMMMMMMMM'
①これは期待していることとは全く違います。ローマ数字としてあり得ないものです。実際、これらの数字は入力として受け付けられないものです。しかし、関数は無茶苦茶な値を無理矢理に返しています。静かに間違った値を返すのは最悪!!です。もしプログラムが失敗するのであれば、早めに騒がしく失敗する方がよいのです。「"Haltand catch fire"(停止してやり直し)」という言葉があるでしょう。Python的な方法では"Halt and catch fire"、は例外を出すことです。

こう自問してください。「どうやったらテスト可能な要件として表現できるか?」と。こう始めてみてはどうでしょうか。

to_roman()関数は3999より大きな数字を入れるとOutOfRangeErrorを出さないといけない。

テストはどうなるでしょうか?
# romantest1.py
import unittest, roman1
class ToRomanBadInput(unittest.TestCase):                                 # ①
    def test_too_large(self):                                             # ②
        '''to_roman should fail with large input'''
        self.assertRaises(roman1.OutOfRangeError, roman1.to_roman, 4000)  # ③
①前のテストケースのように、unittest.TestCaseを継承するクラスをつくります。(このあとで出てきますが)クラスに複数のテストがあってもよいのですが、このテストは先ほどとは違う箇所があるので、新しくクラスを作っています。よい入力テストをまとめて1つのクラスに入れておいて、悪い入力テストをもう1つのクラスに入れます。

②前のテストケースのように、テストはクラス内のメソッドで、名前はtestで始まります。

③unittest.TestCaseクラスにはassertRaisesメソッドがあり、このメソッドが受け取る引数は、出て欲しい例外、テストする関数、関数に渡す引数です(テストする関数が受け取る引数が複数ある場合は、引数すべてをassertRaisesに順番に渡すとassertRaisesがテストしている関数にそのまま渡します)。

コードの最後の行を注意深く見てみると、try...exceptブロックで囲んでto_roman()を直接呼び出して手動で特定の例外を出すか確認する代わりに、assertRaisesメソッドがすべてをカプセル化してくれます。やるべきことは、どういう例外(roman1.OutOfRangeError)、関数(to_roman())、引数(4000)を期待しているかを伝えることです。assertRaisesメソッドはto_roman()の呼び出しのあとに、roman1.OutOfRangeErrorが出るのを確認します。

また、ここでto_roman()関数そのものを引数として渡していることに注意しましょう。関数を呼び出したり、名前を文字列として呼び出しているのではありません。Pythonではすべてがオブジェクトだから便利だって最近言いましたでしょうか?

では、新しいテストを含めたテストスイートを実行すると、何が起こるでしょうか?
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
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 "romantest1.py", line 78, in test_too_large
    self.assertRaises(roman1.OutOfRangeError, roman1.to_roman, 4000)
AttributeError: 'module' object has no attribute 'OutOfRangeError'      # ②

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

FAILED (errors=1)
①これが失敗することを期待していました。(まだコードがパスするようには書いていないからです)。しかし、実際は"失敗"ではなく、"エラー"が出ています。これは小さな違いですが、重要です。実は、ユニットテストには3種類の結果「Pass」、「Fail」、「Error」があります。Passはもちろん、テストがパスしたことを意味します ―今はこれを期待していません。Failは1つ前のテストケースで(パスするようにコードを書き換える前までに)やったことです。コードを実行した結果が期待していたものではない、ということです。Errorは実行さえも上手くいっていないことを意味しています。

②なぜコードは正しく実行されなかったのでしょうか?tracebackにすべて書かれています。テストしているモジュールはOutOfRangeErrorという例外を持っていません。この例外はassertRaises()メソッドに渡したことを覚えていますか?関数に範囲外の入力を渡したときにその例外を出して欲しいからです。例外が存在していなかったので、assertRaises()メソッドの呼び出しは失敗しました。to_roman()関数をテストできませんでした。つまり、そこまで進まなかったのです。

これを解決するためには、OutOfRangeError例外をroman1.pyの中に定義します。
class OutOfRangeError(ValueError):  # ①
    pass                            # ②
①例外はクラスです。この"out of range"エラーは、ValueErrorの一種です。追加された値が範囲外となります。この例外はビルトインのValueError例外から派生しています。これは必要というわけではありませんが(基本となる例外クラスから派生することもできました)、あったほうがよいです。

②今、この例外は何もしませんが、クラスを作るために少なくとも1行が必要です。passを呼び出しても、本当に何もしません。しかしPythonコードの1行ですから、これでクラスになります。

では、テストスイートをもう一度走らせましょう。
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
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 "romantest1.py", line 78, in test_too_large
    self.assertRaises(roman1.OutOfRangeError, roman1.to_roman, 4000)
AssertionError: OutOfRangeError not raised by to_roman                 # ②

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

FAILED (failures=1)
①新しいテストはまだパスしません。エラーも返していません。そうではなく、テストはfailしています。そこが進んだところです。今回はassertRaise()メソッドは成功していて、ユニットテストのフレームワークは実際にto_roman()関数をテストしたということです。

②もちろん、to_roman()関数は先ほど定義したOutOfRangeError例外を出しません。そうするようには命令していないからです。これは最高のニュースです。つまり、このテストケースは有効ということです 一 パスする前は失敗しているからです。

これで、このテストをパスするようにコードを書くことができます。
# roman1.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
①これは簡単です。入力した(n)が3999より大きければ、OutOfRangeError例外を出します。このユニットテストでは例外のあとの人間が読む文字列をチェックしませんが、もう1つのテストを書いてチェックすることはできます(しかし文字列では国際化問題に注意します。言語や環境によって変わるからです)。これでテストはパスするでしょうか?調べてみましょう。
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
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok                            # ①

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

OK
①やりました! 両方のテストにパスしました。テストとコードを行ったり来たりして、今書いた2行のコードが"fail"から"pass"に変わる理由だと確信できました。こういった自信はかんたんには得られませんが、コードの一生で保証されるものです。

9.4. また止まって、やり直し

大きすぎる数でのテストに続いて、小さすぎる数でのテストをしなければなりません。関数の要件で見たように、ローマ数字では0や負の数を表すことができません。
>>> import roman1
>>> roman1.to_roman(0)
''
>>> roman1.to_roman(-1)
''
これではよくありません。テスト関数をclass ToRomanBadInputに追加して対処しましょう。
# romantest1.py, add 2 funcstions to the class
class ToRomanBadInput(unittest.TestCase):
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(roman1.OutOfRangeError, roman1.to_roman, 4000)  # ①

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

    def test_negative(self):
        '''to_roman should fail with negative input'''
        self.assertRaises(roman1.OutOfRangeError, roman1.to_roman, -1)    # ③
①test_too_large()メソッドは前のステップから変わっていません。新しいコードがどこからはじまるかを示すために入れています。

②これが新しいテストとなるtest_zero()メソッドです。test too large()メソッドのようにunittest.TestCaseの中にassertRaises()メソッドを定義しています。パラメータ0でto_roman()関数を呼び出したときは、適切な例外であるOutOf RangeErrorを呼び出します。

③test_negative()メソッドは、-1をto_roman()関数に渡すこと以外はほとんど同じです。両方の新しいテストがOutOfRangeErrorを返さなければ(実際の値を返していたり、他の例外を出していたりすれば)テストは失敗したと見なせます。

それではテストが失敗しているかどうか確認しましょう。
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
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 "romantest1.py", line 86, in test_negative
    self.assertRaises(roman1.OutOfRangeError, roman1.to_roman, -1)
AssertionError: OutOfRangeError not raised by to_roman

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

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

FAILED (failures=2)
最高です。どちらのテストも失敗していて、期待通りです。次は、コードでパスするにはどうしたらよいか考えてみましょう。
# roman1.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
①これはPythonicでかっこいいショートカットです。 これは if not ((0 < n) and (n < 4000)) と同等で1度に複数の比較をしていますが、ずっと読みやすいです。この1行のコードで、大きすぎる値や負、ゼロの入力を判定します。
②条件を変えたら、忘れずに表示するエラー文字列も変更しましょう。忘れていてもunittestフレームワークは気にせず動きますが、間違って記述された例外をコードが出しているとしたら、手動でのデバッグは難しくなります。

別の例を使って同時比較が有効であることを見せることもできたのですが、代わりにユニットテストを走らせることで証明しましょう。
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
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

9.5. そして、もう1つ・・・

整数をローマ数字に変換するときの機能要件はもう1つあります。非整数への対処です。
>>> import roman1
>>> roman1.to_roman(0.5)  # ①
''
>>> roman1.to_roman(1.0)  # ②
'I'
①おっと、これはダメです。

②おっと、これはもっとダメです。どちらも例外を出さないといけないのですが、めちゃくちゃな値になっています。

非整数のテストは難しくはありません。まず、NotlntegerError例外を定義しましょう。
テストが狙い通りに失敗するかを確認します。
class OutOfRangeError(ValueError): pass
class NotIntegerError(ValueError): pass
次に、NotlntegerError例外をチェックするテストケースを書きましょう。
# romantest1.py
class ToRomanBadInput(unittest.TestCase):
    .
    .
    .
    def test_non_integer(self):
        '''to_roman should fail with non-integer input'''
        self.assertRaises(roman1.NotIntegerError, roman1.to_roman, 0.5)
テストが狙い通りに失敗するかを確認します。
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
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 "romantest1.py", line 90, in test_non_integer
    self.assertRaises(roman1.NotIntegerError, roman1.to_roman, 0.5)
AssertionError: NotIntegerError not raised by to_roman

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

FAILED (failures=1)
最後に、テストがパスするようにコードを書きます。
# roman1.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)')
    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
①ビルトイン関数isinstance()は、変数が特定の型かどうかをテストします(厳密には、どのdescendant型でもかまいません)。

②引数nが整数でなければ、新しく作ったNotlntegerError例外を出します。

最終的に、コードは本当にテストをパスするようになります。
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
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()に進みましょう。

9.6. 嬉しい対称性

文字列をローマ数字から整数に変換するのは、その逆よりも難しそうに思えます。有効性を判定しなくてはならないという問題があるのは確実です。整数が0より大きいかを確認するのは簡単ですが、文字列が有効なローマ数字であるかを確認することは少し難しいです。しかし、すでにローマ数字を判定する正規表現を作り上げていますから、その部分は完了しています。

残っているのは文字列そのものの変換です。すぐにわかるように、個々のローマ数字と整数値を対応付けるリッチデータストラクチャを定義しているので、from_roman()関数の核心はto_roman()関数と同じです。

まず先にテストをしましょう。正確を期すために、"知っている値"のテストをして局所チェックをすることが必要です。テストスイートには既知の値の対応表が含まれています。それを再利用しましょう。
# romantest1.py
# class KnownValues()
    .
    .
    .
    def test_from_roman_known_values(self):
        '''from_roman should give known result with known input'''
        for integer, numeral in self.known_values:
            result = roman1.from_roman(numeral)
            self.assertEqual(integer, result)
ここに嬉しい対称性があります。to_roman()とfrom_roman()関数はお互いに反対になっています。to_roman()では整数を特別なフォーマットの文字列に変換して、from_roman()では特別なフォーマットの文字列を整数に変換しています。理論的には、整数をro_roman()関数に渡して文字列を得て、その文字列をfrom_roman()関数に渡して最初と同じ整数を受け取るという"往復"ができます。
n = from_roman(to_roman(n)) for all values of n
この場合"all values"というのは1...3999の間のどの数字でもかまいません。to_roman()関数の有効な入力の範囲だからです。この対称性を使って、テストケースをこのように表現できます。1...3999のすべての数字で走らせてto_roman() と from_roman()を呼び出して、出力が元の入力と同じかを確認します。
# romantest1.py
class RoundtripCheck(unittest.TestCase):
    def test_roundtrip(self):
        '''from_roman(to_roman(n))==n for all n'''
        for integer in range(1, 4000):
            numeral = roman1.to_roman(integer)
            result = roman1.from_roman(numeral)
            self.assertEqual(integer, result)
この新しいテストは、まだ失敗さえしていません。from_roman()関数は全く定義していないので、例外を出すだけです。
you@localhost:~/diveintopython3/examples$ python3 romantest1.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 "romantest1.py", line 78, in test_from_roman_known_values
    result = roman1.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 "romantest1.py", line 103, in test_roundtrip
    result = roman1.from_roman(numeral)
AttributeError: 'module' object has no attribute 'from_roman'

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

FAILED (errors=2)
短いスタブ関数が問題を解決してくれます。
# roman1.py
def from_roman(s):
    '''convert Roman numeral to integer'''
(さて、気がつきましたか?ドキュメンテーション文字列しかない関数を定義したのです。これはPythonでは合法です。実際、このように宣言するプログラマもいます。“Don’t stub; document!”(これはドキュメント!)

これでテストケースは確かに失敗するようになります。
you@localhost:~/diveintopython3/examples$ python3 romantest1.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 "romantest1.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 "romantest1.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
①ここでのパターンは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 roman1
>>> roman1.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 romantest1.py
.......
----------------------------------------------------------------------
Ran 7 tests in 0.060s

OK
エキサイティングなニュースが2つあります。1つ目は、 from_roman()関数は、少なくとも既知のすべての値を入力したときには問題なく動作するということです。2つ目は、"往復"のテストをパスしたことです。既知の値のテストに組み合わせることで、to_roman()関数とfrom_roman()関数が考えられる全ての適切な値に対して正常に働くことを裏付けることができます(これは保証されているわけではありません。理論的にはある種の入力に対してto_roman()に間違ったローマ数字を作るとバグが起こり得ます。from_roman()でも、to_roman()が失敗するときの整数値で同様にバグが起こり得ます。アプリケーションや要求次第で、現実的な問題となるかもしれません。そのときは、問題にならなくなるまで、より包括的なテストケースを書くことになります)。

9.7. もっと悪い入力

これでfrom_roman()関数は正しい入力に対して正確に動くようになったので、パズルの最後のピースをはめるときが来ました。間違った入力に対して適切に動くようにするということです。つまり、文字列を見て有効なローマ数字かどうか判定する方法を作ります。これはto_roman()関数への数字の入力を評価するよりも本質的に難しいものです。しかし、正規表現という強力な武器が用意されています(正規表現に慣れていないならば、正規表現の章を読むよい機会です)。

ケーススタディ(ローマ数字)で見たように、ローマ数字はいくつかのシンプルなルールでできていて文字M, D, C, L, X, V, Iを使います。このルールを振り返ってみましょう。
  • 文字を追加できる場合があります。Iは1、IIは2、IIIは3です。VIは6(文字通り"5と1")、VIIは7、VIIIは8です。
  • 10の位の文字(I, X, C, M)は最大3回まで繰り返すことができます。4回目では、次に大きい5の文字から引かなくてはなりません。4をIIIIのように繰り返すことはできませんので、IVのように表します(5より1少ない)。40はXL(50より10少ない)、41はXLI、42はXLII、43はXLIII、44はXLIV(50より10少なく、5より1小さい)
  • 文字を追加することが反対の意味になることもあります。ある文字を別の文字の後に置くことで、最終の値から引くことになります。例えば、9では次の最大の10の位の数から引くことになります。8はVIIIですが、9はIXとなり(10より1小さい)、XIIIIではありません(文字は4回繰り返すことはできないため)。
  • 90はXCで、900はCMとなります。
  • 5を表す文字は繰り返すことができません。10は常にXであってVVではありません。100は常にCであってLLではありません。
  • ローマ数字は左から右に読むので、文字の順が重要です。DCは600ですが、CDは全く別の数字400である(500より100小さい)。CIは101ですが、ICはローマ数字として成立しない(100から1を直接引くことはできません。「100より10小さいものに、10より1小さいものを加えて」XCIXとなります。

以下のように、役に立つテストはroman()関数に数の繰り返しが多すぎる文字列を渡したときには失敗することを約束してくれます。"多すぎる"というのは数字によります。
# romantest1.py
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(roman1.InvalidRomanNumeralError, roman1.from_roman, s)
もう1つ便利なテストは、あるパターンが繰り返されていないかを確認することです。例えば、IXは9ですが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(roman1.InvalidRomanNumeralError, roman1.from_roman, s)
3番目のテストは、数字が正しい順かどうか、つまり大きい値から小さい値の順に現れているかを確認することができます。例えば、CLは150ですが、LCは無効です。
なぜなら50という数字は決して100の後には現れないからです。
このテストはランダムに選ばれた無効な逆順のセットを含んでいます。IがMの前、VがXの前、などです。
    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(roman1.InvalidRomanNumeralError, roman1.from_roman, s)
これらのテストはfrom_roman()関数が新しい例外を出すことを期待しています。まだ定義していないInvalidRomanNumeralErrorという例外です。
# roman1.py
class InvalidRomanNumeralError(ValueError):
    pass
from_roman()関数には有効性の判定が今は何もないので、これらの3つのテストはすべて失敗するはずです(もしこの段階で失敗しないなら、いったい何をテストしているのでしょうか?)
you@localhost:~/diveintopython3/examples$ python3 romantest1.py
FFF.......
======================================================================
FAIL: test_malformed_antecedents (__main__.FromRomanBadInput)
from_roman should fail with malformed antecedents
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest1.py", line 113, in test_malformed_antecedents
    self.assertRaises(roman1.InvalidRomanNumeralError, roman1.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 "romantest1.py", line 107, in test_repeated_pairs
    self.assertRaises(roman1.InvalidRomanNumeralError, roman1.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 "romantest1.py", line 102, in test_too_many_repeated_numerals
    self.assertRaises(roman1.InvalidRomanNumeralError, roman1.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
もう1度、テストを走らせてみましょう。
you@localhost:~/diveintopython3/examples$ python3 romantest1.py
..........
----------------------------------------------------------------------
Ran 10 tests in 0.066s

OK
今年の「非」流行語大賞は・・・unittestモジュールがすべてのテストをパスしたときにだけ出力される、"OK"です。

0 件のコメント:

コメントを投稿