☜ 제 13 장 유닛테스트 """ Dive Into Python """
다이빙 파이썬
제 15 장 리팩토링 ☞

제 14 장 테스트-먼저 프로그래밍

14.1. roman.py, 1 단계

유닛 테스트가 완성되었으므로, 이제 테스트 사례로 테스트할 코드를 작성할 시간입니다. 단계별로 작성해 보겠습니다. 그래서 모든 유닛 테스트가 실패하고, 그 다음에 하나하나 roman.py에 벌어진 틈새를 채워 넣어 보겠습니다.

예제 14.1. roman1.py

이 파일은 예제 디렉토리의 py/roman/stage1/에 있습니다.

아직 그렇게 하지 못했다면 이 책에 사용된 다음 예제와 기타 예제들을 내려받을 수 있습니다.

"""로마 숫자를 변환한다"""

# 예외를 정의한다.
class RomanError(Exception): pass                
class OutOfRangeError(RomanError): pass          
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass 

def toRoman(n):
    """정수를 로마 숫자로 변환한다"""
    pass                                         

def fromRoman(s):
    """로마 숫자를 정수로 변환한다"""
    pass
이는 파이썬에서 맞춤 예외를 정의하는 법입니다. 예외는 클래스이며, 기존의 예외를 상속받으면 자신만의 예외를 만들 수 있습니다. 특히 (필수는 아니지만) Exception 클래스를 상속받기를 권장합니다. 이 예외 클래스는 모든 내장 예외의 바탕 클래스입니다. 여기에서 RomanError가 (Exception를 상속받아) 다른 모든 예외가 따를 바탕 클래스로 행위하도록 정의합니다. 이는 스타일의 문제입니다; 예외마다 직접 Exception 클래스로부터 아주 쉽게 상속받을 수 있었습니다.
OutOfRangeError 예외와 NotIntegerError 예외는 결국 toRoman이 사용해서 ToRomanBadInput에 지정된대로, 다양한 형태의 무효한 입력을 판별합니다.
InvalidRomanNumeralError 예외는 결국 fromRoman이 사용하여 FromRomanBadInput예 지정된대로, 무효한 입력을 판별합니다.
이 단계에서는 함수의 API는 지정하고, 그러나 아직 코드하고 싶지는 않습니다. 그래서 파이썬의 파이썬 예약어 pass를 사용하여 임시로 함수를 만듭니다.

가슴 뛰는 순간입니다 (드럼 소리 두두두): 마침내 이 임시의 작은 모듈에 대해서 유닛 테스트를 실행하게 되었습니다. 이 시점에서 모든 테스트 사례는 실패해야 합니다. 사실, 1 단계에서 테스트 사례가 하나라도 통과하면 다시 romantest.py로 돌아가서 왜 아무것도 하지 않는 함수로 통과하도록 테스트를 쓸모없이 코딩했는지 재평가해야 합니다.

명령어 줄에서 -v 옵션을 가지고 romantest1.py를 실행해 보세요. 그러면 출력이 더 상세해서, 테스트 사례가 실행될 때마다 정확하게 무슨 일이 진행되고 있는지 볼 수 있습니다. 운이 좋으면 출력은 다음과 같이 보일 것입니다:

예제 14.2.  roman1.py에 대한 romantest1.py의 출력

fromRoman should only accept uppercase input ... ERROR
toRoman should always return uppercase ... ERROR
fromRoman should fail with malformed antecedents ... FAIL
fromRoman should fail with repeated pairs of numerals ... FAIL
fromRoman should fail with too many repeated numerals ... FAIL
fromRoman should give known result with known input ... FAIL
toRoman should give known result with known input ... FAIL
fromRoman(toRoman(n))==n for all n ... FAIL
toRoman should fail with non-integer input ... FAIL
toRoman should fail with negative input ... FAIL
toRoman should fail with large input ... FAIL
toRoman should fail with 0 input ... FAIL

======================================================================
ERROR: fromRoman should only accept uppercase input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 154, in testFromRomanCase
    roman1.fromRoman(numeral.upper())
AttributeError: 'None' object has no attribute 'upper'
======================================================================
ERROR: toRoman should always return uppercase
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 148, in testToRomanCase
    self.assertEqual(numeral, numeral.upper())
AttributeError: 'None' object has no attribute 'upper'
======================================================================
FAIL: fromRoman should fail with malformed antecedents
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 133, in testMalformedAntecedent
    self.assertRaises(roman1.InvalidRomanNumeralError, roman1.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with repeated pairs of numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 127, in testRepeatedPairs
    self.assertRaises(roman1.InvalidRomanNumeralError, roman1.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with too many repeated numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 122, in testTooManyRepeatedNumerals
    self.assertRaises(roman1.InvalidRomanNumeralError, roman1.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 99, in testFromRomanKnownValues
    self.assertEqual(integer, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: 1 != None
======================================================================
FAIL: toRoman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 93, in testToRomanKnownValues
    self.assertEqual(numeral, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: I != None
======================================================================
FAIL: fromRoman(toRoman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 141, in testSanity
    self.assertEqual(integer, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: 1 != None
======================================================================
FAIL: toRoman should fail with non-integer input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 116, in testNonInteger
    self.assertRaises(roman1.NotIntegerError, roman1.toRoman, 0.5)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: NotIntegerError
======================================================================
FAIL: toRoman should fail with negative input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 112, in testNegative
    self.assertRaises(roman1.OutOfRangeError, roman1.toRoman, -1)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: OutOfRangeError
======================================================================
FAIL: toRoman should fail with large input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 104, in testTooLarge
    self.assertRaises(roman1.OutOfRangeError, roman1.toRoman, 4000)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: OutOfRangeError
======================================================================
FAIL: toRoman should fail with 0 input                                 
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage1\romantest1.py", line 108, in testZero
    self.assertRaises(roman1.OutOfRangeError, roman1.toRoman, 0)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: OutOfRangeError                                        
----------------------------------------------------------------------
Ran 12 tests in 0.040s                                                 

FAILED (failures=10, errors=2)                                         
스크립트를 실행하면 unittest.main()이 실행되는데, 여기에서 각 테스트 사례가 실행되며, romantest.py의 각 클래스 안에 정의된 메쏘드를 하나하나 보고합니다. 테스트 사례마다, 그 메쏘드의 문서화 문자열(Doc string)이 인쇄되고 테스트가 통과했는지 실패했는지 인쇄됩니다. 예상대로, 모든 테스트 사례가 실패했습니다.
테스트 사례가 실패할 때마다, unittest는 정확하게 무슨 일이 일어났는지 추적 정보를 화면에 보여줍니다. 이 경우, assertRaises를 호출했더니 (failUnlessRaises라고도 불리우는) AssertionError가 일어났습니다. 왜냐하면 toRomanOutOfRangeError를 일으킬 거라고 예상했는데 그렇지 않았기 때문입니다.
상세 정보가 출력된 후, unittest는 얼마나 많은 테스트가 수행되었는지 그리고 얼마나 오래 걸렸는지 요약하여 화면에 표시합니다.
전반적으로, 유닛 테스트는 적어도 하나의 테스트 사례는 통과하지 못했기 때문에 실패했습니다. 테스트 사례가 통과하지 못하면 unittest는 실패와 에러를 구별합니다. 실패는 예를 들어 assertEqualassertRaises 같은 assertXYZ 메쏘드를 호출할 때, 표명된 조건이 참이 아니거나 예상한 예외가 일어나지 않기 때문에 일어납니다. 에러는 테스트중인 코드에서 또는 유닛 테스트 자체에서 일어난 나머지 모든 예외입니다. 예를 들면 (“fromRoman은 오직 대문자만 입력받기 때문에”) testFromRomanCase 메쏘드는 에러였습니다. 왜냐하면 numeral.upper()를 호출하면 AttributeError 예외가 일어났기 때문입니다. toRoman은 문자열을 돌려주도록 되어있지만 그렇지 않았습니다. 그러나 testZero는 (“toRoman은 0이 입력되면 실패해야 하므로”) 실패했습니다. 왜냐하면 fromRoman를 호출하더라도 assertRaises가 찾고 있는 InvalidRomanNumeral 예외가 일어나지 않았기 때문입니다.

14.2. roman.py, 2 단계

이제 roman 모듈 작업틀의 기본 그림을 그렸으므로, 코드를 작성하고 테스트 사례를 통과시켜 볼 시간입니다.

예제 14.3. roman2.py

이 파일은 예제 디렉토리의 py/roman/stage2/에 있습니다.

아직 그렇게 하지 못했다면 이 책에 사용된 다음 예제와 다른 예제를 내려 받을 수 있습니다.

"""로마 숫자를 변환한다"""

# 예외를 정의한다.
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass

# 자리 짝짓기를 정의한다.
romanNumeralMap = (('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 toRoman(n):
    """정수를 로마 숫자로 변환한다"""
    result = ""
    for numeral, integer in romanNumeralMap:
        while n >= integer:      
            result += numeral
            n -= integer
    return result

def fromRoman(s):
    """로마 숫자를 정수로 변환한다"""
    pass
romanNumeralMap은 터플로 구성된 터플로서 세 가지가 정의되어 있습니다:
  1. 가장 기본적인 로마 숫자의 문자 표현이 정의되어 있습니다. 이는 단순히 한개짜리 로마 숫자만이 아님에 주목하세요; CM와 같은 문자-두개짜리 쌍도 정의하고 있습니다 (“천보다 백이 작은 수”); 이렇게 하면 나중에 toRoman 코드가 더 단순해 집니다.
  2. 로마 숫자의 순서가 정의되어 있습니다. M에서부터 I까지 내림차순으로 나열됩니다.
  3. 로마 숫자의 값이 정의되어 있습니다. 안쪽 터플은 하나하나가 한 쌍의 (numeral, value)입니다.
여기에서 풍부한 데이터 구조가 효과를 발휘합니다. 왜냐하면 빼기 규칙을 처리하기 위하여 특별한 로직이 필요없기 때문입니다. 로마 숫자로 변환하기 위하여, 그냥 단순히 romanNumeralMap을 회돌이하면서 입력 이하의 가장 큰 정수 값을 찾습니다. 발견되면 로마 숫자 표현을 출력의 끝에 더하고, 상응하는 정수 값을 입력에서 빼는 일을 계속 반복합니다.

예제 14.4. 어떻게 toRoman는 작동하는가

toRoman이 어떻게 작동하는지 잘 모르겠다면 print 서술문을 while 회돌이의 끝에 추가하세요:

        while n >= integer:
            result += numeral
            n -= integer
            print 'subtracting', integer, 'from input, adding', numeral, 'to output'
>>> import roman2
>>> roman2.toRoman(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'

그래서 toRoman은 작동하는 것처럼 보입니다. 적어도 이렇게 수작업으로 위치를 점검하면 말입니다. 그러나 유닛 테스트는 통과할까요? 아닙니다. 완전히는 아닙니다.

예제 14.5.  roman2.py에 대한 romantest2.py의 출력

잊지말고 -v 명령어-줄 플래그를 가지고 romantest2.py를 실행시켜서 상세 모드를 활성화 시키세요.

fromRoman should only accept uppercase input ... FAIL
toRoman should always return uppercase ... ok                  
fromRoman should fail with malformed antecedents ... FAIL
fromRoman should fail with repeated pairs of numerals ... FAIL
fromRoman should fail with too many repeated numerals ... FAIL
fromRoman should give known result with known input ... FAIL
toRoman should give known result with known input ... ok       
fromRoman(toRoman(n))==n for all n ... FAIL
toRoman should fail with non-integer input ... FAIL            
toRoman should fail with negative input ... FAIL
toRoman should fail with large input ... FAIL
toRoman should fail with 0 input ... FAIL
사실 toRoman은 언제나 대문자를 돌려줍니다. 왜냐하면 romanNumeralMap에 로마 숫자 표현이 대문자로 정의되어 있기 때문입니다. 그래서 이 테스트는 이미 통과했습니다.
다음은 굉장한 뉴스입니다: 이 버전의 toRoman 함수는 알려진 값 테스트를 통과합니다. 기억하세요. 이 테스트는 종합적이지는 않지만 함수를 다양한 좋은 입력에 맞추어 시행합니다. 여기에는 문자-한개짜리 로마 숫자 입력과 가장 큰 입력(3999) 그리고 가장 긴 로마숫자 입력(3888)이 포함됩니다. 이 시점에서 어떤 값이든 좋은 값을 던져 넣으면 함수는 제대로 작동한다고 충분히 확신할 수 있습니다.
그렇지만 함수는 나쁜 값에 대하여 “작동하지 않습니다”; 한개짜리 나쁜 입력 테스트마다 실패합니다. 나쁜 입력을 점검하지 않았기 때문에 당연합니다. 그런 테스트 사례는 (assertRaises를 통하여) 특정한 예외가 일어나기를 기대하는데, 전혀 그런 예외는 일어나지 않습니다. 다음 단계에서 그렇게 해 보겠습니다.

다음은 유닛 테스트의 나머지 출력으로서, 모든 실패를 자세하게 나열하고 있습니다. 실패는 10개까지 내려갑니다.


======================================================================
FAIL: fromRoman should only accept uppercase input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 156, in testFromRomanCase
    roman2.fromRoman, numeral.lower())
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with malformed antecedents
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 133, in testMalformedAntecedent
    self.assertRaises(roman2.InvalidRomanNumeralError, roman2.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with repeated pairs of numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 127, in testRepeatedPairs
    self.assertRaises(roman2.InvalidRomanNumeralError, roman2.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with too many repeated numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 122, in testTooManyRepeatedNumerals
    self.assertRaises(roman2.InvalidRomanNumeralError, roman2.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 99, in testFromRomanKnownValues
    self.assertEqual(integer, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: 1 != None
======================================================================
FAIL: fromRoman(toRoman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 141, in testSanity
    self.assertEqual(integer, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: 1 != None
======================================================================
FAIL: toRoman should fail with non-integer input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 116, in testNonInteger
    self.assertRaises(roman2.NotIntegerError, roman2.toRoman, 0.5)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: NotIntegerError
======================================================================
FAIL: toRoman should fail with negative input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 112, in testNegative
    self.assertRaises(roman2.OutOfRangeError, roman2.toRoman, -1)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: OutOfRangeError
======================================================================
FAIL: toRoman should fail with large input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 104, in testTooLarge
    self.assertRaises(roman2.OutOfRangeError, roman2.toRoman, 4000)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: OutOfRangeError
======================================================================
FAIL: toRoman should fail with 0 input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage2\romantest2.py", line 108, in testZero
    self.assertRaises(roman2.OutOfRangeError, roman2.toRoman, 0)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: OutOfRangeError
----------------------------------------------------------------------
Ran 12 tests in 0.320s

FAILED (failures=10)

14.3. roman.py, 3 단계

이제 toRoman은 좋은 입력 (1에서 3999 까지의 정수)에 대하여 올바르게 행위하므로, (다른 무엇이든) 나쁜 입력에 대해서 올바르게 작동하도록 만들 시간입니다.

예제 14.6. roman3.py

이 파일은 예제 디렉토리의 py/roman/stage3/에 있습니다.

아직 그렇게 하지 못했다면 이 책에 사용된 다음 예제와 다른 예제를 내려 받을 수 있습니다.

"""로마 숫자를 변환한다"""

# 예외를 정의한다.
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass

# 자리 짝짓기를 정의한다.
romanNumeralMap = (('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 toRoman(n):
    """정수를 로마 숫자로 변환한다"""
    if not (0 < n < 4000):                                             
        raise OutOfRangeError, "number out of range (must be 1..3999)" 
    if int(n) <> n:                                                    
        raise NotIntegerError, "non-integers can not be converted"

    result = ""                                                        
    for numeral, integer in romanNumeralMap:
        while n >= integer:
            result += numeral
            n -= integer
    return result

def fromRoman(s):
    """로마 숫자를 정수로 변환한다"""
    pass
이는 파이썬스러운 멋진 지름길입니다: 여러 비교를 한 번에 행합니다. 이는 'if not ((0 < n) and (n < 4000))'과 동등하지만 훨씬 더 읽기 좋습니다. 이것은 범위 점검이고, 너무 크거나 음수 또는 0인 입력을 잡아야 합니다.
raise 서술문으로 직접 예외를 일으킵니다. 내장 예외를 모두 일으킬 수 있으며, 손수 정의한 맞춤 예외를 일으킬 수도 있습니다. 두 번째 매개변수인 에러 메시지는 선택적입니다; 두 번째 매개변수가 주어지면 예외가 처리되지 못할 경우 인쇄되는 역추적 메시지 안에 표시됩니다.
이것은 비-정수 점검입니다. 비-정수는 로마 숫자로 변환할 수 없습니다.
나머지 함수는 그대로입니다.

예제 14.7.  toRoman이 어떻게 나쁜 입력을 다루는지 살펴보기

>>> import roman3
>>> roman3.toRoman(4000)
Traceback (most recent call last):
  File "<interactive input>", line 1, in ?
  File "roman3.py", line 27, in toRoman
    raise OutOfRangeError, "number out of range (must be 1..3999)"
OutOfRangeError: number out of range (must be 1..3999)
>>> roman3.toRoman(1.5)
Traceback (most recent call last):
  File "<interactive input>", line 1, in ?
  File "roman3.py", line 29, in toRoman
    raise NotIntegerError, "non-integers can not be converted"
NotIntegerError: non-integers can not be converted

예제 14.8. roman3.py에 대한 romantest3.py의 출력

fromRoman should only accept uppercase input ... FAIL
toRoman should always return uppercase ... ok
fromRoman should fail with malformed antecedents ... FAIL
fromRoman should fail with repeated pairs of numerals ... FAIL
fromRoman should fail with too many repeated numerals ... FAIL
fromRoman should give known result with known input ... FAIL
toRoman should give known result with known input ... ok 
fromRoman(toRoman(n))==n for all n ... FAIL
toRoman should fail with non-integer input ... ok        
toRoman should fail with negative input ... ok           
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok
toRoman 함수도 알려진 값 테스트를 통과합니다. 고맙군요. 2 단계를 통과한 테스트도 모두 통과합니다. 그래서 최신 코드는 아무것도 망가트리지 않습니다.
더 신나는 것은 이제 나쁜 입력 테스트가 모두 통과한다는 것입니다. 이 testNonInteger 테스트는 int(n) <> n 점검 때문에 통과합니다. 비-정수가 toRoman에 건네지면 int(n) <> n 점검은 그것을 알아채고 NotIntegerError 예외를 일으키는데, 이 예외는 testNonInteger가 기대하고 있는 것입니다.
testNegative 테스트는 not (0 < n < 4000) 점검 때문에 통과하는데, 이는 OutOfRangeError 예외를 일으키고, 이 예외는 testNegative가 기대하고 있는 것입니다.

======================================================================
FAIL: fromRoman should only accept uppercase input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage3\romantest3.py", line 156, in testFromRomanCase
    roman3.fromRoman, numeral.lower())
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with malformed antecedents
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage3\romantest3.py", line 133, in testMalformedAntecedent
    self.assertRaises(roman3.InvalidRomanNumeralError, roman3.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with repeated pairs of numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage3\romantest3.py", line 127, in testRepeatedPairs
    self.assertRaises(roman3.InvalidRomanNumeralError, roman3.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with too many repeated numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage3\romantest3.py", line 122, in testTooManyRepeatedNumerals
    self.assertRaises(roman3.InvalidRomanNumeralError, roman3.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage3\romantest3.py", line 99, in testFromRomanKnownValues
    self.assertEqual(integer, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: 1 != None
======================================================================
FAIL: fromRoman(toRoman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage3\romantest3.py", line 141, in testSanity
    self.assertEqual(integer, result)
  File "c:\python21\lib\unittest.py", line 273, in failUnlessEqual
    raise self.failureException, (msg or '%s != %s' % (first, second))
AssertionError: 1 != None
----------------------------------------------------------------------
Ran 12 tests in 0.401s

FAILED (failures=6) 
실패가 6 개까지 내려왔습니다. 그리고 그 모두가 fromRoman에 관련되어 있습니다: 알려진 값 테스트, 세 개로 나뉜 나쁜 입력 테스트, 대소문자 점검, 그리고 위생 점검이 그것입니다. 그것은 toRoman이 스스로 통과할 수 있는 모든 테스트를 통과했다는 뜻입니다. (위생 점검에 관련되어 있을 뿐만 아니라 또한 아직 작성되지 않은 fromRoman이 이미 작성되어 있기를 요구합니다.) 이는 이제 toRoman의 코딩을 멈추어야 한다는 뜻입니다. “사례마다 딱 맞게” 더 손 댈 것도, 더 조작할 것도, 추가로 더 점검할 필요도 없습니다. 이제 멈추세요. 키보드로부터 멀리 떨어져 앉으세요.
종합적인 유닛 테스트가 알려주어야 할 가장 중요한 일은 코딩을 멈추어야 할 때를 알려주는 것입니다. 한 함수에 대하여 모든 유닛 테스트가 통과하면 코딩을 멈추세요. 전체 모듈에 대하여 유닛 테스트가 모두 통과하면 바로 코딩을 멈추세요.

14.4. roman.py, 4 단계

이제 toRoman이 완성되었으므로, fromRoman을 코딩할 차례입니다. 로마 숫자를 정수 값을 짝지어 주는 풍부한 데이터 구조 덕분에, toRoman 함수보다 어렵지 않게 코딩할 수 있습니다.

예제 14.9. roman4.py

다음 파일은 예제 디렉토리의 py/roman/stage4/에 있습니다.

아직 그렇게 하지 못했다면 이 책에 사용된 다음 예제와 기타 예제를 내려받을 수 있습니다.

"""로마 숫자를 변환한다"""

# 예외를 정의한다.
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass

# 자리 짝짓기를 정의한다
romanNumeralMap = (('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))

# 깨끗하게 toRoman 함수는 생략됨 (바뀌지 않음)

def fromRoman(s):
    """로마 숫자를 정수로 변환한다"""
    result = 0
    index = 0
    for numeral, integer in romanNumeralMap:
        while s[index:index+len(numeral)] == numeral: 
            result += integer
            index += len(numeral)
    return result
여기에서 패턴은 toRoman와 똑같습니다. 로마 숫자 데이터 구조(터플로 구성된 터플)를 회돌이하면서, 그리고 가능하면 가장 큰 정수 값을 일치시키는 대신에, 가능하면 “가장 큰” 로마 숫자 문자열을 일치시킵니다.

예제 14.10. 어떻게 fromRoman은 작동하는가

fromRoman이 어떻게 작동하는지 잘 이해가 안 가신다면 print 서술문을 while 회돌이의 끝에 추가하세요:

        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
            print 'found', numeral, 'of length', len(numeral), ', adding', integer
>>> import roman4
>>> roman4.fromRoman('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

예제 14.11. roman4.py에 대한 romantest4.py의 출력

fromRoman should only accept uppercase input ... FAIL
toRoman should always return uppercase ... ok
fromRoman should fail with malformed antecedents ... FAIL
fromRoman should fail with repeated pairs of numerals ... FAIL
fromRoman should fail with too many repeated numerals ... FAIL
fromRoman should give known result with known input ... ok 
toRoman should give known result with known input ... ok
fromRoman(toRoman(n))==n for all n ... ok                  
toRoman should fail with non-integer input ... ok
toRoman should fail with negative input ... ok
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok
여기에서 두 가지 기쁜 소식이 있습니다. 첫 소식은 fromRoman이 좋은 입력에 대하여 작동하며, 적어도 테스트된 모든 알려진 값에 대하여 작동한다는 것입니다.
두번째 소식은 위생 점검도 통과했다는 사실입니다. 알려진 값 테스트와 결합하면 toRomanfromRoman가 모든 가능한 좋은 값에 대하여 적절하게 작동한다고 충분히 확신할 수 있습니다. (이는 보증되지는 않습니다; 이론적으로 특정한 입력 집합에 대하여 그릇된 로마 숫자를 산출하는 버그가 toRoman에 있을 수 있기 때문입니다. 그리고 toRoman이 엉터리로 생성한 똑 같은 로마 숫자 집합에 대하여 서로 그릇된 정수 값을 산출하는 버그가 fromRoman에도 있을 수 있기 때문입니다. 어플리케이션과 요구조건에 따라, 이 가능성 때문에 곤란을 겪을 수도 있습니다; 그렇다면 괴롭히지 않을 때까지 더 종합적인 테스트 사례를 작성하세요.)

======================================================================
FAIL: fromRoman should only accept uppercase input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage4\romantest4.py", line 156, in testFromRomanCase
    roman4.fromRoman, numeral.lower())
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with malformed antecedents
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage4\romantest4.py", line 133, in testMalformedAntecedent
    self.assertRaises(roman4.InvalidRomanNumeralError, roman4.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with repeated pairs of numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage4\romantest4.py", line 127, in testRepeatedPairs
    self.assertRaises(roman4.InvalidRomanNumeralError, roman4.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
======================================================================
FAIL: fromRoman should fail with too many repeated numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage4\romantest4.py", line 122, in testTooManyRepeatedNumerals
    self.assertRaises(roman4.InvalidRomanNumeralError, roman4.fromRoman, s)
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
----------------------------------------------------------------------
Ran 12 tests in 1.222s

FAILED (failures=4)

14.5. roman.py, 5 단계

이제 fromRoman은 좋은 입력에 대하여 적절하게 작동하므로, 마지막 퍼즐 조각을 맞출 시간입니다: 나쁜 입력에 작동하도록 만들 시간입니다. 즉, 문자열을 보고 유효한 로마 숫자인지 결정하는 방법을 찾는다는 뜻입니다. 이는 근본적으로 toRoman에서 숫치 입력을 평가하는 것보다 어렵지만 강력한 도구가 준비되어 있습니다: 정규 표현식이 바로 그것입니다.

정규 표현식을 잘 모르고 제 7 장, 정규 표현식을 읽지 못하셨다면 지금이 좋은 기회입니다.

섹션 7.3, “사례 연구: 로마 숫자”에서 보셨듯이, 로마 숫자를 만드는 방법에는 간단한 규칙이 여럿 있습니다. M, D, C, L, X, V, 그리고 I라는 기호를 사용합니다. 규칙을 복습해 보겠습니다:

  1. 문자는 부가적이다. I1이며, II2이고, III3이다. VI6이고 (문자 그대로, “51”임), VII7이며, VIII8이다.
  2. 십단위 문자(I, X, C, 그리고 M)는 세 번까지 반복될 수 있다. 4에 이르면 다음 높은 오 단위 문자에서 뺄 필요가 있다. 4IIII로 나타낼 수 없다; 대신에, IV로 표현된다 (“5보다 1 작은 수”). 40XL로 표기되며 (“50보다 10 작은 수”), 41XLI로, 42XLII로, 43XLIII로 표기되고, 그리고 44XLIV로 표기된다 (“50보다 10 작고, 5보다 1 작음”).
  3. 비슷하게 9에서, 다음 10 단위 문자에서 뺄 필요가 있다: 8VIII이지만 9IX이다 (“10보다 1 작음”). VIIII이 아니다 (I 문자는 네 번 반복할 수 없기 때문이다). 90XC이고, 900CM이다.
  4. 오 단위 문자는 반복할 수 없다. 10은 언제나 X로 표현되지, VV로 표현되지 않는다. 100은 언제나 C이지, 절대로 LL이 되지 않는다.
  5. 로마 숫자는 언제나 높은 자리에서 낮은 자리 순서로 표기된다. 그리고 왼쪽에서 오른쪽으로 읽는다. 그래서 문자의 문서가 대단히 중요하다. DC600이며; CD는 완전히 다른 숫자이다 (“ 500보다 100 작은400임). CI101이다; IC는 로마 숫자로 유효하지도 않다 (100에서 직접적으로 1을 뺄 수 없기 때문이다;“100보다 10 작고, 10보다 1 작게XCIX로 작성할 필요가 있다).

예제 14.12. roman5.py

이 파일은 예제 디렉토리의 py/roman/stage5/에 있습니다.

아직 그렇게 하지 못했다면 이 책에 사용된 다음 예제와 기타 예제를 내려받을 수 있습니다.

"""로마 숫자를 변환한다"""
import re

# 예외를 정의한다.
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass

# 자리수 짝짓기를 정의한다.
romanNumeralMap = (('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 toRoman(n):
    """정수를 로마 숫자로 변환한다"""
    if not (0 < n < 4000):
        raise OutOfRangeError, "number out of range (must be 1..3999)"
    if int(n) <> n:
        raise NotIntegerError, "non-integers can not be converted"

    result = ""
    for numeral, integer in romanNumeralMap:
        while n >= integer:
            result += numeral
            n -= integer
    return result

# 유효한 로마 숫자를 탐지하기 위한 패턴을 정의한다.
romanNumeralPattern = '^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$' 

def fromRoman(s):
    """로마 숫자를 정수로 변환한다"""
    if not re.search(romanNumeralPattern, s):                                    
        raise InvalidRomanNumeralError, 'Invalid Roman numeral: %s' % s

    result = 0
    index = 0
    for numeral, integer in romanNumeralMap:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result
이 패턴은 섹션 7.3, “사례 연구: 로마 숫자”에서 연구한 바와 똑 같이 계속됩니다. 십의 자리는 XC (90)이거나 XL (40) 또는 선택적인 L 다음에 0에서 3개까지의 선택적인 X 문자가 옵니다. 일의 자리는 IX (9)이거나 IV (4) 또는 선택적인 V 다음에 0에서 3개까지의 선택적인 I 문자가 옵니다.
모든 로직을 정규 표현식 안으로 넣었으므로, 로마 숫자가 유효한지 점검나는 코드는 전혀 어려울 게 없습니다. re.search이 객체를 하나 돌려주면 일치된 정규 표현식과 그 입력은 유효합니다. 그렇지 않으면 그 입력은 유효하지 않습니다.

이 시점에서, 이 못생긴 커다란 정규 표현식이 과연 모든 유형의 무효한 로마 숫자를 잡아낼 수 있을지 의심이 드는 것은 당연합니다. 그러나 내 말을 곧이 곧대로 믿지 마시고, 그 결과를 살펴보세요:

예제 14.13. roman5.py에 대한 romantest5.py의 출력


fromRoman should only accept uppercase input ... ok          
toRoman should always return uppercase ... ok
fromRoman should fail with malformed antecedents ... ok      
fromRoman should fail with repeated pairs of numerals ... ok 
fromRoman should fail with too many repeated numerals ... ok
fromRoman should give known result with known input ... ok
toRoman should give known result with known input ... ok
fromRoman(toRoman(n))==n for all n ... ok
toRoman should fail with non-integer input ... ok
toRoman should fail with negative input ... ok
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 12 tests in 2.864s

OK                                                           
정규 표현식에 관하여 언급하지 않은 한가지는 기본적으로 정규 표현식이 대소문자를 구별한다는 것입니다. 정규 표현식 romanNumeralPattern는 대문자로 표현되었으므로, re.search 점검은 완전히 대문자가 아니면 어떤 입력도 거부합니다. 그래서 대분자 입력 테스트는 통과합니다.
더 중요한 것은 나쁜 입력 테스트가 통과한다는 것입니다. 예를 들면 모양 나쁜 조상 테스트는 MCMC와 같은 사례를 점검합니다. 보시다시피, 이는 정규 표현식에 부합하지 않습니다. 그래서 fromRomanInvalidRomanNumeralError 예외를 일으키고, 이는 모양이 나쁜 선조 테스트 사례가 기대하고 있던 것입니다. 그래서 테스트는 통과합니다.
실제로, 나쁜 입력 테스트가 모두 통과합니다. 이 정규 표현식은 테스트 사례를 만들 때 생각할 수 있는 어떤 것이든 잡아냅니다.
그리고 올해의 대종상은 “OK”라는 단어에 돌아갑니다.“OK”, 이 단어는 모든 테스트가 통과하면 unittest 모듈에 의하여 인쇄됩니다.
테스트가 모두 통과하면 코딩을 멈추세요.
☜ 제 13 장 유닛테스트 """ Dive Into Python """
다이빙 파이썬
제 15 장 리팩토링 ☞