10章 リファクタリング

飛び込む

包括的なユニットテストを書いても、バグは出てくる。 「バグ」とは一体何だろうか? バグとはまだ書かれていないテストケースになる。

In [1]:
import re

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)
)

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

これはバグ。 空白文字列に対しては、有効なローマ数字となっていない他の文字列の場合と同じように、 InvalidRomanNumeralError例外が送出されなくてはならない。

In [2]:
from_roman('')
Out[2]:
0

バグを再現できたら、それを修正するのに先立って、パスしないようなテストケースを書かなければならない。こうしてバグをはっきりと示す。

In [3]:
import unittest

class InvalidRomanNumeralError(ValueError): 
    pass


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(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)
        
    
    # 新しく追加
    def testBlank(self):
        '''from_roman should fail with blank string'''
        self.assertRaises(InvalidRomanNumeralError, from_roman, '')
    

すなわち、from_roman()を空白文字列とともに呼び出し、 InvalidRomanNumeralError例外が送出されるかどうかを確認する。 難しいのはバグを見つけ出す段階であって、一度分かってしまえば、そのバグをテストするのはごく簡単。

In [4]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(FromRomanBadInput)
unittest.TextTestRunner().run(test) 
No traceback available to show.
F...
======================================================================
FAIL: testBlank (__main__.FromRomanBadInput)
from_roman should fail with blank string
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<ipython-input-3-f620bb1328bb>", line 31, in testBlank
    self.assertRaises(InvalidRomanNumeralError, from_roman, '')
AssertionError: InvalidRomanNumeralError not raised by from_roman

----------------------------------------------------------------------
Ran 4 tests in 0.011s

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

これでようやくこのバグを修正できる。

たった二行のコードを追加するだけで足りる。 一行目で明示的に空白文字列をチェックし、二行目にはraise文 をあてる。

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

class NotIntegerError(ValueError):
    pass

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 [6]:
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 s:
        raise InvalidRomanNumeralError('Input can not be blank')
    if not re.search(roman_numeral_pattern, s):
        raise InvalidRomanNumeralError('Invalid Roman numeral: {}'.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
'Invalid Roman numeral: {}'.format(s)

Python 3.1からは、フォーマット指定子の中でインデクスを使用する場合に数字を省略できるようになっている。 つまり、format()メソッドの最初の引数を参照する場合に、フォーマット指定子の{0}を使わなくとも、 単純に {} とすればPython が自動で適当なインデクスを埋めてくれる。

これは引数がいくつあっても機能するもので、 最初の{}は{0} に、次の{}は{1} というように解釈されていく。

In [7]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(FromRomanBadInput)
unittest.TextTestRunner().run(test)
No traceback available to show.
....
----------------------------------------------------------------------
Ran 4 tests in 0.009s

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

空白文字列のテストケースをパスしていることから、このバグが修正されたと分かる。

このようにコーディングしても、別にバグが修正しやすくなるわけではない。 (このバグのように)簡単なバグには簡単なテストケースで足りるが、 複雑なバグには複雑なテストケースが必要となる。

もしかしたら、テスト中心の開発環境だとバグを修正するのに長い時間がかかるように見えるかもしれない。 まずもって、(テストケースを書いて)何がバグなのかをコードの中で明確に示してからバグを修正しなくてはならないし、 しかもそこでテストケースをパスしなかったら、修正が間違っていたのか、 それともテストケース自体にバグがあるのかを調べなくてはならない。

しかし、長い目で見れば、テストコードとそのテストを受けるコードの間を行ったり来たりすることは十分割に合うことだろう。 こうすれば、バグが最初の一回で正しく修正されやすくなるし、 新しいテストケースと一緒に他のすべてのテストケースを再実行することも簡単にできるので、 新しいコードを加える際に古いコードを壊してしまうということが非常に少なくなる。 今日のユニットテストは、明日の回帰テストとなる。

要件の変更に対処する

どれだけ顧客から厳密な要件を引き出しても、要件というのは変わってしまう。 そもそも、ほとんどの顧客は実際に見てみるまで、自分が何を求めているのかを理解しないもの。

たとえ理解していたとしても、顧客はどういうものがあれば十分なのかをはっきりと伝えるのが上手ではない。 仮に明確に伝えてきたとしても、どのみち次のリリースの時には別の機能も欲しがるようになっている。 だから、要件の変更に合わせてテストケースをアップデートできるようにしておく。

例えば、このローマ数字の変換関数が扱える値の範囲を拡張したいと思ったとする。

普通なら、ローマ数字のどの文字も連続して4回以上繰り返すことはできないのだが、 実のところローマ人は時に「4000を表すために、Mの文字を四個続けることができる」という例外を設けていた。 この変更を施せば、変換できる数の範囲を 1..3999 から 1..4999 に拡張できることになる。 しかし、まず最初にテストケースに修正を加える必要がある。

すでにタプルに入っている既知の値については何も変更を加えないが(これらはまだテストする意味のある値だ)、 4000台の数をいくつか加える必要がある。 ここでは

  • 4000(最も短い数)、
  • 4500(二番目に短い数)、
  • 4888(最も長い数)、
  • 4999(最大の数)

を加えてある。

In [8]:
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'),
                    (4000, 'MMMM'),
                    (4500, 'MMMMD'),
                    (4888, 'MMMMDCCCLXXXVIII'),
                    (4999, 'MMMMCMXCIX')
    )
    
    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() を4000と共に呼び出してエラーが出るかを確認するものだったが、 4000-4999は有効な入力値となったので、引数の値を5000まで引き上げなくてはならない。

In [9]:
class ToRomanBadInput(unittest.TestCase):
    
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(OutOfRangeError, to_roman, 5000)
    

「繰り返され過ぎている数字」の定義も変わっている。 このテストは from_roman() を'MMMM'とともに呼び出してエラーが出るかを確認するものだったが、 MMMMは有効なローマ数字となったので、引数の値を'MMMMM'まで引き上げなくてはならない。,

In [10]:
class FromRomanBadInput(unittest.TestCase):
    
    def test_too_many_repeated_numerals(self):
        '''from_roman should fail with too many repeated numerals'''
        for s in ('MMMMM', '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)
        
    
    # 新しく追加
    def testBlank(self):
        '''from_roman should fail with blank string'''
        self.assertRaises(InvalidRomanNumeralError, from_roman, '')
    

この動作確認テストでは 1 から 3999 までのすべての数をループしていたが、 値の範囲が拡張されたのに合わせて、このforループも4999 までの数をテストするように修正しなければならない。

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

from_roman() の既知の値のテストは'MMMM'に突き当たったところで失敗する。 from_roman() がまだこのローマ数字を無効なものとしているからだ。

to_roman() の既知の値のテストは4000に突き当たったところで失敗する。 to_roman() がまだこれを範囲外の数としているため。

往復テスト( RoundTripTest )も4000に突き当たったところで失敗する。 to_roman() がまだこれを範囲外の数としているため。

In [12]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(KnownValues)
unittest.TextTestRunner().run(test)
test = unittest.TestLoader().loadTestsFromTestCase(FromRomanBadInput)
unittest.TextTestRunner().run(test)
test = unittest.TestLoader().loadTestsFromTestCase(RoundtripCheck)
unittest.TextTestRunner().run(test)
No traceback available to show.
EE
======================================================================
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-8-051340391a19>", line 76, in test_from_roman_known_values
    result = from_roman(numeral)
  File "<ipython-input-6-0ddb24c33a8a>", line 18, in from_roman
    raise InvalidRomanNumeralError('Invalid Roman numeral: {}'.format(s))
InvalidRomanNumeralError: Invalid Roman numeral: MMMM

======================================================================
ERROR: test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<ipython-input-8-051340391a19>", line 68, in test_to_roman_known_values
    result = to_roman(integer)
  File "<ipython-input-5-8a6a496128ad>", line 11, in to_roman
    raise OutOfRangeError('number out of range (must be 1..3999)')
OutOfRangeError: number out of range (must be 1..3999)

----------------------------------------------------------------------
Ran 2 tests in 0.008s

FAILED (errors=2)
....
----------------------------------------------------------------------
Ran 4 tests in 0.009s

OK
E
======================================================================
ERROR: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<ipython-input-11-cce19637b0ef>", line 7, in test_roundtrip
    numeral = to_roman(integer)
  File "<ipython-input-5-8a6a496128ad>", line 11, in to_roman
    raise OutOfRangeError('number out of range (must be 1..3999)')
OutOfRangeError: number out of range (must be 1..3999)

----------------------------------------------------------------------
Ran 1 test in 0.128s

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

これで新しい要件によって失敗するテストケースができ上がったので、これらのテストをパスするようにコードを修正する作業に移ることができる

(ユニットテストをコーディングし始めた最初のうちは、テスト対象のコードが決してユニットテストの「先」にこないことに違和感を覚えるかもしれない。コードが追いついていないのに、 まだやるべき作業があって、 そしてコードがテストケースに追いついたら、 即座にコーディングをやめる。)。

In [13]:
roman_numeral_pattern = re.compile('''
    ^                   # beginning of string
    M{0,4}              # thousands - 0 to 4 Ms  MMMMまで許すよう変更
    (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 to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 5000): # 4999まで許容
        raise OutOfRangeError('number out of range (must be 1..4999)')
    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

# fromは変更しない
def from_roman(s):
    '''convert Roman numeral to integer'''
    if not s:
        raise InvalidRomanNumeralError('Input can not be blank')
    if not re.search(roman_numeral_pattern, s):
        raise InvalidRomanNumeralError('Invalid Roman numeral: {}'.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 [14]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(KnownValues)
unittest.TextTestRunner().run(test)
test = unittest.TestLoader().loadTestsFromTestCase(FromRomanBadInput)
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
....
----------------------------------------------------------------------
Ran 4 tests in 0.006s

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

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

テストケースをすべてパスした。

リファクタリング

ユニットテストの最も良い点は、容赦なく リファクタリング を施す自由を与えてくれることにある。

リファクタリング とは、動作するコードを取り上げて、より良く動くようにしていくプロセスのこと。 普通、「より良い」とは「もっと速い」ということを意味するのだが、 状況によっては「メモリの使用が少ない」とか「使用ディスクスペースが小さい」とかいうことを指す場合もあるし、 あるいは単純に「よりエレガントだ」ということでもありうる。

この言葉があなたにとって、あるいはそのプロジェクトや環境においてどんな意味を持つのであれ、 リファクタリングはおよそプログラムの長期的な健全性を保つのに重要なものだと言える。

ここでは「より良い」を「より速い」と「より保守しやすい」の二つの意味 で使う。

要するに、from_roman()関数は私が期待するよりも遅くて複雑になってしまっているということなのだが、 その原因は巨大で扱いにくい正規表現を使ってローマ数字の有効性を検証していることにある。 ここまで読むと、あなたはこのように考えるかもしれない: 「確かにこの正規表現は大きくて不調法だけど、それ以外に任意の文字列が有効なローマ数字かどうかを検証する方法があるだろうか?」

答え: たった5000個しかないんだから、参照テーブルを作ってみたらどうだろう? しかも、こうすれば正規表現をまったく使う必要がないということに気がつけば、このアイデアはさらに魅力なものになるだろう。 つまり、整数をローマ数字に変換する参照テーブルを組み立てるときに、 ローマ数字を整数に変換する逆の参照テーブルも作ることができるので、 任意の文字列が有効なローマ数字かどうかを判別する時には、 有効なローマ数字の一覧表が出来上がっていることになる。

ここにおいて「有効性の検証」は一つの辞書を参照するという作業に帰着することになる。

そして何といっても、 完全なユニットテストのセットが既に全部そろっている。 仮にモジュールのコードを半分以上変更したとしても、 ユニットテストは前のまま変わらないでいてくれる。 だから、新しいコードが元のコードと同じように機能すると —自分に対しても他人に対しても— 証明できる。

In [15]:
# roman10.py
class OutOfRangeError(ValueError): 
    pass
class NotIntegerError(ValueError): 
    pass
class InvalidRomanNumeralError(ValueError): 
    pass

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)
                    )

to_roman_table = [ None ]
from_roman_table = {}

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 5000):
        raise OutOfRangeError('number out of range (must be 1..4999)')
    if int(n) != n:
        raise NotIntegerError('non-integers can not be converted')
    return to_roman_table[n]

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not isinstance(s, str):
        raise InvalidRomanNumeralError('Input must be a string')
    if not s:
        raise InvalidRomanNumeralError('Input can not be blank')
    if s not in from_roman_table:
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
    return from_roman_table[s]

def build_lookup_tables():
    def to_roman(n):
        result = ''
        for numeral, integer in roman_numeral_map:
            if n >= integer:
                result = numeral
                n -= integer
                break
        if n > 0:
            result += to_roman_table[n]
        return result

    for integer in range(1, 5000):
        roman_numeral = to_roman(integer)
        to_roman_table.append(roman_numeral)
        from_roman_table[roman_numeral] = integer
    

build_lookup_tables()

コードを小さく分割してみる。

build_lookup_tables()

ここで関数が呼び出されているが、これは if __name__ == '__main__' ブロックではない。 従って、この関数はモジュールがインポートされた時に呼び出されることになる (モジュールは一度しかインポートされず、あとはキャッシュされるということをちゃんと理解しておこう。既にインポートされているモジュールを再びインポートしても、何も実行されないのだ。このコードも最初にモジュールをインポートした時にのみ呼び出されることになる)。

build_lookup_tables()関数は一体どんな処理を行うものなのか。

  1. to_roman()関数は上の方でも定義されているが、これは参照テーブルで値を探しだし、 その値を返すというものでしかない。 一方、build_lookup_tables()関数は to_roman()関数を再定義していて、 この関数に(参照テーブルを加える前のコードがしていたような)実際の処理を行わせている。 この build_lookup_tables() 中で to_roman() を呼び出すと、 再定義された方の to_roman()関数が呼び出されることになる。

そして、build_lookup_tables() の実行が終わると、 この再定義されたバージョンは消えてしまう — この関数はあくまでもbuild_lookup_tables()のローカルスコープ内で定義されたもの。

to_roman_table = [ None ]
from_roman_table = {}
.
.
.
def build_lookup_tables():
    def to_roman(n):                                ①
        result = ''
        for numeral, integer in roman_numeral_map:
            if n >= integer:
                result = numeral
                n -= integer
                break
        if n > 0:
            result += to_roman_table[n]
        return result

    for integer in range(1, 5000):
        roman_numeral = to_roman(integer)          ②
        to_roman_table.append(roman_numeral)       ③
        from_roman_table[roman_numeral] = integer

2.再定義した to_roman()関数を呼び出してローマ数字を実際に組み立てている。

3.(再定義したto_roman()関数から)値が返ってきたら、整数と対応するローマ数字の二つを両方の参照テーブルに加えていく。

参照テーブルができあがってしまえば、後のコードは簡潔で高速なものに仕上がる。

to_roman()関数は、前と同じように入力値をチェックしたあとは、単に適切な値を参照テーブルから探し出してその値を返すという処理だけを行う。

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 5000):
        raise OutOfRangeError('number out of range (must be 1..4999)')
    if int(n) != n:
        raise NotIntegerError('non-integers can not be converted')
    return to_roman_table[n]                                            ①

同様に、from_roman()関数も入力値をチェックする部分の他に一行のコードが置かれているだけになっている。 ここには正規表現は使われていないし、ループもない。 あるのはローマ数字と整数を相互変換する O(1) のコードだけ。

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not isinstance(s, str):
        raise InvalidRomanNumeralError('Input must be a string')
    if not s:
        raise InvalidRomanNumeralError('Input can not be blank')
    if s not in from_roman_table:
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
    return from_roman_table[s]                                          ②

O(1)により、このコードは処理も速い。 現に処理速度はほとんど10倍になっている。もちろん、これは完全にフェアな比較とは言えない。このバージョンだと(参照テーブルを作り上げるので)インポートするのに長い時間がかかるからだ。 しかし、インポートされるのは一回だけなので、この起動にかかるコストはto_roman()とfrom_roman()の呼び出しごとに償却されてゆくことになる。このテストは何千回もこれらの関数を呼び出すので(往復テストだけで一万回呼び出している)、この最初のコストはすぐに回収される。

In [16]:
# test
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'),
                    (4000, 'MMMM'),
                    (4500, 'MMMMD'),
                    (4888, 'MMMMDCCCLXXXVIII'),
                    (4999, 'MMMMCMXCIX')
    )
    
    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)
        
    
class ToRomanBadInput(unittest.TestCase):
    
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(OutOfRangeError, to_roman, 5000)
    

class FromRomanBadInput(unittest.TestCase):
    
    def test_too_many_repeated_numerals(self):
        '''from_roman should fail with too many repeated numerals'''
        for s in ('MMMMM', '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)
        
    
    # 新しく追加
    def testBlank(self):
        '''from_roman should fail with blank string'''
        self.assertRaises(InvalidRomanNumeralError, from_roman, '')
    

class RoundtripCheck(unittest.TestCase):
    
    def test_roundtrip(self):
        '''from_roman(to_roman(n))==n for all n'''
        
        for integer in range(1, 5000):
            numeral = to_roman(integer)
            result = from_roman(numeral)
            self.assertEqual(integer, result)
In [17]:
%tb
test = unittest.TestLoader().loadTestsFromTestCase(KnownValues)
unittest.TextTestRunner().run(test)
test = unittest.TestLoader().loadTestsFromTestCase(FromRomanBadInput)
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.011s

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

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

この話の教訓は:

  • 単純さは善
  • 特に正規表現が関わってくる時には。ユニットテストは大規模なリファクタリングを行う自信を与えてくれる

まとめ

ユニットテストは強力なコンセプトで、正しく実行できればどんな長期プロジェクトにおいても、保守にかかるコストを減らし、柔軟性も高めることができる。

ただし、ユニットテストは優れたテストケースを書くのは難しいことだし、 テストケースをたえず更新していくのには自律心が要る(とりわけ、顧客がクリティカルなバグの修正を求めてわめいている状況では)。

加えて、ユニットテストは、機能テストや統合テスト、アセプタンステストなどの他の形式のテストに取って代わるものでもない。

しかし、このユニットテストは実行可能な手法で、しかもちゃんと機能してくれるものだ。 一度このやり方がうまくいくと分かったら、ユニットテスト無しで今までどうしてやってこれたのだろうといぶかしく思うようになる。

ユニットテストのフレームワークには様々な言語向けのものが存在するし、そのどれについてもここで述べたのと同じ基本的なコンセプトを理解する必要がある:

  • 明確かつ独立であり、しかも自動で実行できるようなテストケースを設計する。
  • テストするコードの前にテストケースを書く
  • 有効な入力値を試して、適切な結果が返ってくるかをチェックするテストを書く。
  • 有効でない入力値を試して、適切に失敗するかをチェックするテストを書く。
  • 新しい要件を反映するように、テストケースを書いたり修正したりする。
  • 容赦なくリファクタリングを施し、機能性や拡張性、可読性、保守性、あるいは何であれコードに欠けている「~性」を向上させていく。

参考リンク