ユニットテスト

飛び込(まない)

この章では、整数とローマ数字を相互変換する一組のユーティリティ関数を書いて、 それをデバッグする。 ローマ数字の組み立て方とその有効性の検証法については、 「ケーススタディ: ローマ数字」で学んだ。

では一度そこに立ち返って、どうやったらそこから双方向なユーティリティを作れるかを考える。

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

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

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

to_roman()関数が望み通りの動きをするかをチェックするテストケースを書く。 大丈夫、読み間違えでもなんでもない。 実際にこれから、まだ書かれてもいないコードをテストするコードを書く。

これは テスト駆動開発 (TDD) と呼ばれている手法。

変換を行うこの二つの関数 — to_roman() と後で出てくる from_roman() は、 これらをインポートする他のもっと大規模なプログラムとは独立に、一つの構成単位として書いたりテストしたりできる。

Pythonには、ユニットテストのためのフレームワーク unittestモジュール がある。

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

  • コードを書く前: ユニットテストを書こうとすれば、要件を実用的な形で練り上げざるをえなくなる。

  • コードを書いている最中: ユニットテストがあれば過剰なコーディングを避けることができる。 すべてのテストケースに通ったなら、その関数はもう完成。

  • リファクタリングする時: 新しいコードが古いバージョンのコードと同じように動くかどうかを確認するのに役立つ。

  • コードを保守する時: 誰かがあなたの所にやって来て「お前が加えた変更のせいで古いコードが壊れたじゃないか」とわめいた時に、ユニットテストがあれば言い逃れができる。

  • チームでコードを書く時: 包括的なテストスイートがあれば、 あなたのコードが別の誰かのコードを壊してしまうといったことが非常に少なくなる。 事前に他の人のユニットテストを走らせることができる。 (コードスプリントでこういうやり方をしているのを見たことがある。 チームに割り当てられた仕事を分割した上で、 各々が仕様書に目を通してユニットテストを書き、 それをチームで共有する。 こうすると、他の部分とうまくかみ合わないようなコードが早いうちに直されるようになる)

一つの問い

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

  • 単独で完全に機能し、人の入力を必要としないものでなくてはならない。 ユニットテストの本質は自動化にある。

  • ある関数がテストをパスしたかどうかを、人の解釈によらず、自ら判定するものでなくてはならない。

  • (同じ関数をテストしているテストケースも含む) 他のいかなるテストケースからも独立し、 単体で動作するものでなくてはならない。

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

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

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

テストケースを書くには、 まずunittestモジュールのTestCaseクラスをサブクラス化(継承)する。 このクラスには、テストケースで特定の条件をテストする時に便利なメソッドがたくさん入っている。

known_values は、整数/数字のペアのリスト。 このリストには、最も小さい10個の数、最も大きい数、一文字のローマ数字で表される数すべて、さらにこれら以外の有効な数字からランダムに抽出された数が含まれている。 ありとあらゆる入力値を試す必要はないが、 境界事例だと分かっているものについてはすべてテストするべき。

In [1]:
import unittest
unittest
Out[1]:
<module 'unittest' from 'C:\\Miniconda3\\lib\\unittest\\__init__.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()

個々のテストはそれぞれメソッド(def test_to_roman_known_values())として表される。 メソッドは引数を取らなければ値も返さないようなものであり、 メソッド名は4文字のtestで始まっていなければならない

もし、メソッドが

  • 例外を送出することなく普通に終了したならば、テストをパスしたものとみなされる。
  • 例外が送出された場合には、テストは失敗したとみなされる。

def test_to_roman_known_values() の中:

最後に残ったステップは、 正しい値が返されているかどうかチェックすること。

これはテストケース一般でチェックされる項目なので、TestCaseクラスに二つの値が等しいかどうかを調べるassertEqual メソッドが用意されている。 to_roman()が返した値(result)が、

  • 返されるべき既知の値(numeral)と一致しなかったなら、assertEqualは例外を送出し、テストは失敗する。

  • 二つの値が等しければ、assertEqualは何もしない。だから、to_roman()のすべての戻り値が、返されるべき既知の値と一致したならば、assertEqualは一度も例外を送出せず、test_to_roman_known_valuesは正常に終了する。

テストケースができたら、 to_roman()関数のコードを書き始めることができる。 だが、まずは最初に中身が空のto_roman()関数を作って、 テストが失敗することを確かめなくてはならない。

もし、何も書いていないのにパスできてしまうようなら、 何のテストにもならない。 通らないようなテストを書いて、パスするまでコーディングする。

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

In [2]:
# roman1.py

def to_roman(n):
    '''convert integer to Roman numeral'''
    pass

コマンドラインでテスト

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

> 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 を継承してさえいればいい。

2行目に、各々のテストについて、unittestモジュールはそのメソッドの docstring とテストの成否を出力する。予想通り、このテストケースは失敗している。

失敗したテストケースについては、unittest は何が起こったのかをトレースして表示してくれる。 このケースでは、assertEqual() を呼び出した際に AssertionError が送出されている。to_roman(1) は 'I' を返すものとされていたのに、 そうならなかったから (この関数にはreturn文が置かれて無いので、Python のNull値にあたるNone が返されている)。

それぞれのテストの詳細を出力した後で、unittest はいくつのテストが実行され、それにどれくらいの時間がかかったのかを表示する。

まとめると、少なくとも一つのテストケースをパスしなかったので、 このテストは失敗したことになる。

なお、テストケースにパスしなかったという場合について、 unittestは

  • Failure
  • Error

を区別する。

Failure とは、assertEqualやassertRaisesといったassertXYZメソッドを呼びだしたが、表明された条件が真でなかったり、期待通りに例外が送出されなかったせいで失敗してしまった場合を指す。

これ以外の、テストしているコードやユニットテストのテストケース自体から送出された例外はすべてErrorとされる。

Jupyter でテストする場合

In [3]:
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 = to_roman(integer)
            self.assertEqual(numeral, result)
        
    

TestLoaderTextTestRunner を使う。

In [4]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(KnownValues)
unittest.TextTestRunner().run(test)
No traceback available to show.
F
======================================================================
FAIL: test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<ipython-input-3-f4de237d18b0>", line 66, in test_to_roman_known_values
    self.assertEqual(numeral, result)
AssertionError: 'I' != None

----------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (failures=1)
Out[4]:
<unittest.runner.TextTestResult run=1 errors=0 failures=1>

インポートしてる場合

In [5]:
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)
        

各々のテストについて、unittestモジュールはそのメソッドのdocstringとテストの成否を出力する。予想通り、このテストケースは失敗している。

In [6]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(KnownValues)
unittest.TextTestRunner().run(test)
No traceback available to show.
F
======================================================================
FAIL: test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<ipython-input-5-6f3417485f53>", line 67, in test_to_roman_known_values
    self.assertEqual(numeral, result)
AssertionError: 'I' != None

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)
Out[6]:
<unittest.runner.TextTestResult run=1 errors=0 failures=1>

ソースコードの出力

In [7]:
%psource roman1.to_roman
In [8]:
import inspect
inspect
Out[8]:
<module 'inspect' from 'C:\\Miniconda3\\lib\\inspect.py'>
In [9]:
print(inspect.getsource(roman1.to_roman))
def to_roman(n):
    '''convert integer to Roman numeral'''
    pass  

to_roman()関数を書いていく

roman_numeral_map はタプルのタプルで、次の三つのものを定めている。 すなわち、

  • 最も基本的なローマ数字の文字表記、
  • ローマ数字の順番(MからIまで、数が大きい順に並べてある)、
  • それぞれのローマ数字が表す値。

この内側のタプルはすべて(numeral, value) というペアからなっている。 ちなみに、ここでは一文字のローマ数字だけではなく、 CM(「1000引く100」)など二文字のものについても定めている。 こうすることで、to_roman()関数のコードがより簡潔なものになる。

In [10]:
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 にデータを詰め込んだことが功を奏していて、 引き算を用いて数を表すルールを扱うための特別なロジックを組む必要がなくなっている。 つまり、ローマ数字に変換するには次のようにすればいいのだ。 まず、単純に roman_numeral_map をイテレートして、 入力値以下の数の中で最大の整数値を探す。

次に、そのような数が見つかったら、 対応するローマ数字を出力値の末尾に付け加え、 さらに入力値からその数を引く。 後はこれを繰り返し、また繰り返してさらに繰り返せばいい。

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

In [11]:
def to_roman(n):
    '''convert integer to Roman numeral'''
    
    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
            print('subtracting {0} from input, adding [{1}] to output, at least n:{2}'.format(integer, numeral, n))
    return result

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

In [12]:
to_roman(1424)
subtracting 1000 from input, adding [M] to output, at least n:424
subtracting 400 from input, adding [CD] to output, at least n:24
subtracting 10 from input, adding [X] to output, at least n:14
subtracting 10 from input, adding [X] to output, at least n:4
subtracting 4 from input, adding [IV] to output, at least n:0
Out[12]:
'MCDXXIV'

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

In [13]:
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


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 = to_roman(integer)
            self.assertEqual(numeral, result)
 
In [14]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(KnownValues)
unittest.TextTestRunner().run(test) 
No traceback available to show.
.
----------------------------------------------------------------------
Ran 1 test in 0.003s

OK
Out[14]:
<unittest.runner.TextTestResult run=1 errors=0 failures=0>

to_roman()関数は「既知の値」のテストケースに通った。

このテストはありうるケースすべてを試しているわけではないが、この関数を多様な入力値、例えば、一文字のローマ数字に対応するすべての数、最大の数(3999)、最も長いローマ数字に対応する数(3888)などでテストしている。

だから、この関数は適切な入力値ならどれでもうまく処理できると考えても差し支えないだろう。 じゃあ「不適切な値」を入力したらどうなるのか。

“Halt And Catch Fire”

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

明らかにこれは意図していない戻り値. そもそも、ちゃんとしたローマ数字ですらない! 実を言うと、ここにある数はすべて入力値の制限範囲を越えているのだが、それでも関数はとりあえず戻り値をでっちあげている。

こっそり不適当な値を返すというのはものすごーく悪いことだ。 どうせ落ちるプログラムなら、早いうちに騒々しく落ちてくれた方がずっと良い。 言い習わされているように「Halt and catch fire」(中断の後、発火せよ)というやつだ。 Pythonでは、例外の送出が Halt and Catch Fire の役目を果たす。

In [15]:
to_roman(4000)
Out[15]:
'MMMM'
In [16]:
to_roman(5000)
Out[16]:
'MMMMM'
In [17]:
to_roman(9000)
Out[17]:
'MMMMMMMMM'

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

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

前のテストケースと同じように、unittest.TestCase を継承したクラスを作る。 一つのクラスに複数のテストを入れてもいいのだが、 ここでは新しいクラスを作ることにする。 このテストは先ほどのテストとは性質が異なったもの。

適切な入力値に対するテストを一つのクラスにまとめて、 不適切な入力値に対するテストはまた別のクラスにまとめる

unittest.TestCaseクラスには、 assertRaisesメソッドが用意されているが、これは次のような引数をとるもの。 すなわち、送出されるべき例外、テスト対象の関数、そして関数に渡す引数 (テスト対象の関数が複数の引数をとる場合には、それらの引数を順に並べてassertRaisesに渡せばいい。そうすれば、そのまま関数に渡してくれる)。

In [18]:
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
In [19]:
class ToRomanBadInput(unittest.TestCase):
    
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(OutOfRangeError, to_roman, 4000)
    

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

後は、assertRaisesメソッドがto_roman() を呼び出して、 OutOfRangeError が送出されるかどうかをチェックしてくれる。

In [20]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(ToRomanBadInput)
unittest.TextTestRunner().run(test) 
No traceback available to show.
E
======================================================================
ERROR: test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<ipython-input-19-4ae99e2f06fb>", line 5, in test_too_large
    self.assertRaises(OutOfRangeError, to_roman, 4000)
NameError: name 'OutOfRangeError' is not defined

----------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (errors=1)
Out[20]:
<unittest.runner.TextTestResult run=1 errors=1 failures=0>

「Fail」ではなく、代わりに「Error」が返ってくる。 これは微妙な差だが、違いは大きい。

実は、ユニットテストには

  • Pass
  • Fail
  • Error

の三つの戻り値がある。

  • Pass:というのはもちろん、テストをパスしたことを意味する — 関数が期待通り動いてくれたということだ。
  • 「Fail」:一つ前のテストケースが(そのためのコードを書き上げる直前まで)返していたものだ — つまり、コードを実行したが、予期した結果が出なかった場合を表す。
  • 「Error」: というのは、そもそもコードを正しく実行できなかったことを示している。

テストしたモジュールの中に OutOfRangeError という名前の例外が存在しなかった。 思い出して欲しいのだが、この例外は範囲外の入力値が渡された時に送出されるべき例外としてassertRaises()メソッドに渡したものだった。 しかし、そもそもこの例外は存在しなかったので、assertRaises()メソッドの呼び出しが失敗してしまったのだ。 ここでは結局、to_roman()関数がテストされることはなかった。そこに行き着きさえしなかった。

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

例外とはクラスでできている。 「Out of Range」エラーは、Value Error の一種だと言える — 引数の値が受け取ることのできる範囲を越えている。 だから、この例外は組み込みのValueError例外を継承している。 これは絶対に必要というわけではない(基底クラスのExceptionを継承してもいい)

In [21]:
class OutOfRangeError(ValueError):
    pass
In [22]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(ToRomanBadInput)
unittest.TextTestRunner().run(test) 
No traceback available to show.
F
======================================================================
FAIL: test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<ipython-input-19-4ae99e2f06fb>", line 5, in test_too_large
    self.assertRaises(OutOfRangeError, to_roman, 4000)
AssertionError: OutOfRangeError not raised by to_roman

----------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (failures=1)
Out[22]:
<unittest.runner.TextTestResult run=1 errors=0 failures=1>

Error が返されることもなく、その代わりにテストは失敗(Failure)している。 これは、assertRaises()メソッドが正しく呼び出され、 実際にto_roman()関数がこのユニットテストフレームワークにテストされたということを意味している。

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

与えられた入力値(n)が3999より大きければ、 OutOfRangeError例外を送出する。

ちなみに、このユニットテストは、例外と一緒に出力されるエラー文字列については何もチェックしていないが、これ用のテストはまた別に書くことができる (ただし、文字列の多言語化の問題に注意すること。 この問題はユーザーの言語や環境に依存する)。

In [23]:
class OutOfRangeError(ValueError):
    pass      


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

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

In [24]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(KnownValues)
unittest.TextTestRunner().run(test) 
test = unittest.TestLoader().loadTestsFromTestCase(ToRomanBadInput)
unittest.TextTestRunner().run(test) 
No traceback available to show.
.
----------------------------------------------------------------------
Ran 1 test in 0.002s

OK
.
----------------------------------------------------------------------
Ran 1 test in 0.002s

OK
Out[24]:
<unittest.runner.TextTestResult run=1 errors=0 failures=0>

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

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

ローマ数字は0や負の数を表すことはできない

In [25]:
to_roman(0)
Out[25]:
''
In [26]:
to_roman(-1)
Out[26]:
''

それぞれの条件に対するテストを付け加える。

新しいテストのtest_zero()メソッド:

unittest.TestCase の中に定められているassertRaises() メソッドを用いて、 to_roman() を0を引数として呼び出し、 適切な例外(OutOfRangeError)が送出されるかどうかを確認している。

test_negative()メソッド:

-1to_roman() に渡しているというだけの違いしかない。 この新しいテストのいずれかにおいてOutOfRangeErrorが送出されなかった場合(その原因としては、関数が実際の値を返したか別の例外を返したかのどちらか)には、 テストは失敗したものとみなされる。

In [27]:
class ToRomanBadInput(unittest.TestCase):
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(OutOfRangeError, to_roman, 4000)
        
    def test_zero(self):
        '''to_roman should fail with 0 input'''
        self.assertRaises(OutOfRangeError, to_roman, 0)
        
    def test_negative(self):
        '''to_roman should fail with negative input'''
        self.assertRaises(OutOfRangeError, to_roman, -1)
    

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

In [28]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(KnownValues)
unittest.TextTestRunner().run(test) 
test = unittest.TestLoader().loadTestsFromTestCase(ToRomanBadInput)
unittest.TextTestRunner().run(test) 
No traceback available to show.
.
----------------------------------------------------------------------
Ran 1 test in 0.002s

OK
F.F
======================================================================
FAIL: test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<ipython-input-27-1ea3a47efc08>", line 12, in test_negative
    self.assertRaises(OutOfRangeError, to_roman, -1)
AssertionError: OutOfRangeError not raised by to_roman

======================================================================
FAIL: test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<ipython-input-27-1ea3a47efc08>", line 8, in test_zero
    self.assertRaises(OutOfRangeError, to_roman, 0)
AssertionError: OutOfRangeError not raised by to_roman

----------------------------------------------------------------------
Ran 3 tests in 0.005s

FAILED (failures=2)
Out[28]:
<unittest.runner.TextTestResult run=3 errors=0 failures=2>

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

この部分ではPython流のショートカットが上手に使われていて、 複数の比較演算子が同時に評価されている。 意味的には if not ((0 < n) and (n < 4000)) と同じだが、 こちらの方が読みやすい。 この一行のコードで、大きすぎる数、負の数、ゼロの三種類の入力値すべてが補足される。

条件を変えたら、人が読む用のエラー文字列も修正するのを忘れないように。

In [29]:
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
In [30]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(KnownValues)
unittest.TextTestRunner().run(test) 
test = unittest.TestLoader().loadTestsFromTestCase(ToRomanBadInput)
unittest.TextTestRunner().run(test) 
No traceback available to show.
.
----------------------------------------------------------------------
Ran 1 test in 0.003s

OK
...
----------------------------------------------------------------------
Ran 3 tests in 0.004s

OK
Out[30]:
<unittest.runner.TextTestResult run=3 errors=0 failures=0>

さらにもう一つ……

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

In [31]:
to_roman(0.5)  
Out[31]:
''

値をでっちあげて返してしまっている。

In [32]:
to_roman(1.0)
Out[32]:
'I'

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

In [33]:
class NotIntegerError(ValueError): 
    pass

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

In [34]:
class ToRomanBadInput(unittest.TestCase):
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(OutOfRangeError, to_roman, 4000)
        
    def test_zero(self):
        '''to_roman should fail with 0 input'''
        self.assertRaises(OutOfRangeError, to_roman, 0)
        
    def test_negative(self):
        '''to_roman should fail with negative input'''
        self.assertRaises(OutOfRangeError, to_roman, -1)
        
    def test_non_integer(self):
        '''to_roman should fail with non-integer input'''
        self.assertRaises(NotIntegerError, to_roman, 0.5)
    

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

In [35]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(KnownValues)
unittest.TextTestRunner().run(test) 
test = unittest.TestLoader().loadTestsFromTestCase(ToRomanBadInput)
unittest.TextTestRunner().run(test) 
No traceback available to show.
.
----------------------------------------------------------------------
Ran 1 test in 0.002s

OK
.F..
======================================================================
FAIL: test_non_integer (__main__.ToRomanBadInput)
to_roman should fail with non-integer input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<ipython-input-34-7868efa3e185>", line 16, in test_non_integer
    self.assertRaises(NotIntegerError, to_roman, 0.5)
AssertionError: NotIntegerError not raised by to_roman

----------------------------------------------------------------------
Ran 4 tests in 0.006s

FAILED (failures=1)
Out[35]:
<unittest.runner.TextTestResult run=4 errors=0 failures=1>

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

組み込みの isinstance()関数は、 変数が特定の型(厳密に言えば、ここにはその型を継承した型も含む)かどうかを調べる。

引数のnが int でなかったら、さっき新しく作った NotIntegerError を送出する。

In [36]:
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

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

In [37]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(KnownValues)
unittest.TextTestRunner().run(test) 
test = unittest.TestLoader().loadTestsFromTestCase(ToRomanBadInput)
unittest.TextTestRunner().run(test) 
No traceback available to show.
.
----------------------------------------------------------------------
Ran 1 test in 0.003s

OK
....
----------------------------------------------------------------------
Ran 4 tests in 0.007s

OK
Out[37]:
<unittest.runner.TextTestResult run=4 errors=0 failures=0>

すばらしき対称性

ローマ数字の文字列を整数に直すのは、整数をローマ数字に直すことよりも難しいように見える。

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

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

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

In [38]:
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 = to_roman(integer)
            self.assertEqual(numeral, result)
            
        
    def test_from_roman_known_values(self):
        '''from_roman should give known result with known input'''
        
        for integer, numeral in self.known_values:
            result = from_roman(numeral)
            self.assertEqual(integer, result)
        
    

ここにはすばらしい対称性がある。

to_roman()関数と from_roman()関数は互いを逆にしたもので、 前者は整数を特定の形式の文字列に変換し、後者は特定の形式の文字列を整数に変換する。

理論的には、数を「往復」させることができなくてはならない。 つまり、to_roman()関数に渡して文字列に直し、

続いてその文字列をfrom_roman()関数に渡して整数に直した場合に、 最初の数が戻ってこなくてはならない。

対称性を用いて、1..3999 のすべての値に対して to_roman() を呼び出し、 さらに to_roman() で変換を施して、 出力された値が元の入力値と同じかをチェックするテストケースを作ることができる。

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

from_roman() をまったく定義していないので、実行してもエラーが送出されるだけ。

In [40]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(KnownValues)
unittest.TextTestRunner().run(test) 
test = unittest.TestLoader().loadTestsFromTestCase(ToRomanBadInput)
unittest.TextTestRunner().run(test) 
test = unittest.TestLoader().loadTestsFromTestCase(RoundtripCheck)
unittest.TextTestRunner().run(test) 
No traceback available to show.
E.
======================================================================
ERROR: test_from_roman_known_values (__main__.KnownValues)
from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<ipython-input-38-fbe19e69e68a>", line 74, in test_from_roman_known_values
    result = from_roman(numeral)
NameError: name 'from_roman' is not defined

----------------------------------------------------------------------
Ran 2 tests in 0.004s

FAILED (errors=1)
....
----------------------------------------------------------------------
Ran 4 tests in 0.006s

OK
E
======================================================================
ERROR: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<ipython-input-39-15de0fba6e80>", line 8, in test_roundtrip
    result = from_roman(numeral)
NameError: name 'from_roman' is not defined

----------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (errors=1)
Out[40]:
<unittest.runner.TextTestResult run=1 errors=1 failures=0>

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

In [41]:
def from_roman(s):
    '''convert Roman numeral to integer'''

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

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

In [42]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(KnownValues)
unittest.TextTestRunner().run(test) 
test = unittest.TestLoader().loadTestsFromTestCase(ToRomanBadInput)
unittest.TextTestRunner().run(test) 
test = unittest.TestLoader().loadTestsFromTestCase(RoundtripCheck)
unittest.TextTestRunner().run(test)
No traceback available to show.
F.
======================================================================
FAIL: test_from_roman_known_values (__main__.KnownValues)
from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<ipython-input-38-fbe19e69e68a>", line 75, in test_from_roman_known_values
    self.assertEqual(integer, result)
AssertionError: 1 != None

----------------------------------------------------------------------
Ran 2 tests in 0.007s

FAILED (failures=1)
....
----------------------------------------------------------------------
Ran 4 tests in 0.005s

OK
F
======================================================================
FAIL: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<ipython-input-39-15de0fba6e80>", line 9, in test_roundtrip
    self.assertEqual(integer, result)
AssertionError: 1 != None

----------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (failures=1)
Out[42]:
<unittest.runner.TextTestResult run=1 errors=0 failures=1>

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

In [43]:
def from_roman(s):
    """convert Roman numeral to integer"""
    
    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        # M
        # 1000:M
        # s[0:0+1]
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result
while s[index:index+len(numeral)] == numeral:

この部分のパターンは to_roman() と同じで、 ローマ数字のデータ構造(タプルからなるタプル)をイテレートしていっている。

ただし、前のコードではできる限り大きな整数値にマッチするようにしていたが、ここではできる限り「最大の」ローマ数字の文字列にマッチするようにしている。

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

In [44]:
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)
        
    return result
In [45]:
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
Out[45]:
1972

テストをもう一度実行する。

In [46]:
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)
    return result

%tb
test = unittest.TestLoader().loadTestsFromTestCase(KnownValues)
unittest.TextTestRunner().run(test) 
test = unittest.TestLoader().loadTestsFromTestCase(ToRomanBadInput)
unittest.TextTestRunner().run(test) 
test = unittest.TestLoader().loadTestsFromTestCase(RoundtripCheck)
unittest.TextTestRunner().run(test)
No traceback available to show.
..
----------------------------------------------------------------------
Ran 2 tests in 0.006s

OK
....
----------------------------------------------------------------------
Ran 4 tests in 0.007s

OK
.
----------------------------------------------------------------------
Ran 1 test in 0.140s

OK
Out[46]:
<unittest.runner.TextTestResult run=1 errors=0 failures=0>

ここから興味深いことが二つ分かる。

  • from_roman() は適切な入力値に関しては(少なくとも全ての既知の値については)うまく動くということ。

  • この関数が「往復」テストもパスしている。 既知の値に関するテスト結果と合わせると、to_roman()とfrom_roman()は適切な値ならどんなものについても正しく処理できると考えてもよさそうだ。

(ただし絶対ではない。 理論上は、特定の入力値を間違ったローマ数字に変換するバグがto_roman()に存在していて、かつ from_roman() にも対応するバグが存在し、 to_roman() が誤って生成したローマ数字を元の入力値に誤変換しているということもありうる。アプリケーションや要件によってはこの可能性が問題になるかもしれないが、 その場合には問題が解決するまでテストケースをより包括的なものにしていけばよい)。

不適切な入力値

これで 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)は三回まで繰り返せる。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」)、VIIIIとは書けないのだ(なぜならIの文字を4回繰り返すことはできないから)。90はXC、900はCMとなる。

  • 5の文字は繰り返すことができない。10は必ずXと表し、VVとすることはできない。100もCであって、LLとはならない。

  • ローマ数字は左から右に読むので、文字の並べ方が非常に重要になる。DCは600だが、CDはそれとは全く異なる数を表すのだ(400、「500引く100」)。また、CIは101だがICは適切なローマ数字ですらない(1を直接100から引くことはできないからだ。代わりにXCIXと書かなくてはならない、「100引く10、加えて10引く1」)。

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

In [47]:
class ToRomanBadInput(unittest.TestCase):
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(OutOfRangeError, to_roman, 4000)
        
    def test_zero(self):
        '''to_roman should fail with 0 input'''
        self.assertRaises(OutOfRangeError, to_roman, 0)
        
    def test_negative(self):
        '''to_roman should fail with negative input'''
        self.assertRaises(OutOfRangeError, to_roman, -1)
        
    def test_non_integer(self):
        '''to_roman should fail with non-integer input'''
        self.assertRaises(NotIntegerError, to_roman, 0.5)

    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(InvalidRomanNumeralError, roman6.from_roman, s)
        
    

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

In [48]:
class ToRomanBadInput(unittest.TestCase):
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(OutOfRangeError, to_roman, 4000)
        
    def test_zero(self):
        '''to_roman should fail with 0 input'''
        self.assertRaises(OutOfRangeError, to_roman, 0)
        
    def test_negative(self):
        '''to_roman should fail with negative input'''
        self.assertRaises(OutOfRangeError, to_roman, -1)
        
    def test_non_integer(self):
        '''to_roman should fail with non-integer input'''
        self.assertRaises(NotIntegerError, to_roman, 0.5)

    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(InvalidRomanNumeralError, roman6.from_roman, s)
    
    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(InvalidRomanNumeralError, roman6.from_roman, s)
        
    

三つ目のテストでは、数字が正しい順番、つまり大きいものから小さいものへと並んでいるかでチェックしよう。

例えば、CLは150を表すが、LCは有効な数字ではない。 50を表す数字が100を表す数字の前にくることはないからだ。このテストには不適切な数字が前に来ているパターンをランダムに選んで入れるとしよう。Mの前にIがあるとか、Xの前にVがあるとかいう場合だ。

In [49]:
class ToRomanBadInput(unittest.TestCase):
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(OutOfRangeError, to_roman, 4000)
        
    def test_zero(self):
        '''to_roman should fail with 0 input'''
        self.assertRaises(OutOfRangeError, to_roman, 0)
        
    def test_negative(self):
        '''to_roman should fail with negative input'''
        self.assertRaises(OutOfRangeError, to_roman, -1)
        
    def test_non_integer(self):
        '''to_roman should fail with non-integer input'''
        self.assertRaises(NotIntegerError, to_roman, 0.5)

    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(InvalidRomanNumeralError, from_roman, s)
    
    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(InvalidRomanNumeralError, from_roman, s)
    
    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(InvalidRomanNumeralError, from_roman, s)
    

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

In [50]:
class InvalidRomanNumeralError(ValueError): 
    pass
In [51]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(KnownValues)
unittest.TextTestRunner().run(test) 
test = unittest.TestLoader().loadTestsFromTestCase(ToRomanBadInput)
unittest.TextTestRunner().run(test) 
test = unittest.TestLoader().loadTestsFromTestCase(RoundtripCheck)
unittest.TextTestRunner().run(test)
No traceback available to show.
..
----------------------------------------------------------------------
Ran 2 tests in 0.004s

OK
F..F.F.
======================================================================
FAIL: test_malformed_antecedents (__main__.ToRomanBadInput)
from_roman should fail with malformed antecedents
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<ipython-input-49-096381ac8b39>", line 35, in test_malformed_antecedents
    self.assertRaises(InvalidRomanNumeralError, from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

======================================================================
FAIL: test_repeated_pairs (__main__.ToRomanBadInput)
from_roman should fail with repeated pairs of numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<ipython-input-49-096381ac8b39>", line 28, in test_repeated_pairs
    self.assertRaises(InvalidRomanNumeralError, from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

======================================================================
FAIL: test_too_many_repeated_numerals (__main__.ToRomanBadInput)
from_roman should fail with too many repeated numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<ipython-input-49-096381ac8b39>", line 22, in test_too_many_repeated_numerals
    self.assertRaises(InvalidRomanNumeralError, from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

----------------------------------------------------------------------
Ran 7 tests in 0.011s

FAILED (failures=3)
.
----------------------------------------------------------------------
Ran 1 test in 0.118s

OK
Out[51]:
<unittest.runner.TextTestResult run=1 errors=0 failures=0>

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

In [52]:
import re

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

再びテストを実行する

In [53]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(KnownValues)
unittest.TextTestRunner().run(test) 
test = unittest.TestLoader().loadTestsFromTestCase(ToRomanBadInput)
unittest.TextTestRunner().run(test) 
test = unittest.TestLoader().loadTestsFromTestCase(RoundtripCheck)
unittest.TextTestRunner().run(test)
No traceback available to show.
..
----------------------------------------------------------------------
Ran 2 tests in 0.007s

OK
.......
----------------------------------------------------------------------
Ran 7 tests in 0.010s

OK
.
----------------------------------------------------------------------
Ran 1 test in 0.174s

OK
Out[53]:
<unittest.runner.TextTestResult run=1 errors=0 failures=0>

参考リンク