제 4 장 HTML 처리 목 차 부 록 >>

제 5 장. 유닛 테스트

5.1. 다이빙해 들어가기

이 전 장에서는 “다이빙해 들어가서” 직접적으로 코드를 살펴보고 되도록이면 빨리 이해하려고 노력하였다. 이제 허리춤에 파이썬을 차게 되었으므로, 뒤로 돌아가 그 코드가 작성되기 전의(before) 단계들을 살펴 보겠다.

이 장에서는 로마 숫자로부터 그리고 로마숫자로 변환하는 유틸리티 집합을 작성하려고 한다. 혹시 인식하지는 못한다고 할지라도, 분명히 로마 숫자들을 보았을 것이다. 오래된 영화나 텔레비젼 쇼에서 복사권리 표식을 보았을 것이다 ( “Copyright 1946”대신에 “Copyright MCMXLVI”을), 혹은 도서관이나 혹은 대학의 봉헌 벽에서 ( “established 1888” 대신에 “established MDCCCLXXXVIII” )을 보았으리라. 또한 요약목록과 전기 참조서에서 보았을 수도 있다. 로마 숫자는 숫자 표현의 시스템으로서 고대로 거슬로 올라가 로마 시대에 실제로 사용되었다 (거기에서 그 이름이 유래한다).

로마 숫자는 7개의 문자가 있어서 여러가지 방식으로 반복되고 조합되어 숫자들을 표현한다.

  1. I = 1
  2. V = 5
  3. X = 10
  4. L = 50
  5. C = 100
  6. D = 500
  7. M = 1000

로마 숫자를 구성하는데는 몇가지 규칙이 있다:

  1. 문자들은 부가적이다. I1이고, II2이며, 그리고 III3이다. VI6이고 (문자적으로, “51”), VII7이며, 그리고 VIII8이다.
  2. 10 단위의 문자들 (I, X, C, 그리고 M)은 3 번까지 반복될 수 있다. 4에서, 그 다음의 가장 높은 5 자리 문자로부터 빼야 한다. 4IIII로 나타낼 수 없다; 대신에, 그것은 IV로 나타내어 진다 (“5보다 1 작다”). 40XL로 씌여진다 (“50보다 10작다”), 41XLI로, 42XLII로, 43XLIII으로, 그리고 44는 (“50보다 10작고, 그리고 5보다 1작으므로”) XLIV로 씌여진다.
  3. 비슷하게, 9에서 그 다음의 가장 높은 10자리 문자로 부터 빼야 한다: 8VIII이지만, 9는 (“10보다 1 작으므로”) IX이지, VIIII가 아니다 (왜냐하면 I 문자는 4 번 반복될 수 없기 때문이다). 90XC이다. 900CM이다.
  4. 5 자리 문자들은 반복될 수 없다. 10VV가 아니라 항상 X로 표현된다. 100은 항상 C이지, 절대로 LL이 아니다.
  5. 로마 숫자는 항상 위에서 아래로, 그리고 왼쪽에서 오른쪽으로 씌여지므로 문자의 순서가 대단히 중요하다. DC600이다; CD는 완전히 다른 숫자이다 (400인데, “ 500 보다 100 작다 ”). CI101이다; IC는 유효한 로마 숫자조차도 아니다 (왜냐하면 1100으로 부터 직접적으로 뺄수도 없기 때문이다; 그것을 XCIX로 써야 할지도 모른다 (“ 100보다 10작고, 그리고 10보다 1 작으므로”).

이러한 규칙들을 보면 수 많은 재미있는 사실들을 관찰할 수 있다:

  1. 숫자 하나를 로마 숫자로 나타내는 데에는 오직 한개의 정확한 방법만이 있다.
  2. 그 역도 역시 참이다: 만약 일련의 문자열이 유효한 로마 숫자라면, 그것은 오직 하나의 숫자만을 나타낸다 (즉, 다시 말하면 그것은 오직 한 가지 방식으로만 읽힐 수 있다).
  3. 제한된 범위의 숫자들만이 로마 숫자로 표현될 수 있다, 특히 1 에서 3999까지 말이다. (로마인들은 몇 가지의 방법으로 더 큰 숫자들을 표현했는데, 예를 들어 한 숫자의 위에 막대를 그어 놓음으로써 그 정상적 값은 1000으로 곱해져야 한다는 것을 나타내었다. 그러나 여기에서 그것을 다루지는 않을 것이다. 이 장의 목적을 위하여, 로마 숫자들은 1에서 3999까지 다룰 것이다.)
  4. 0은 로마 숫자에서 표현할 방법이 없다. (놀랍게도, 고대 로마인들은 0을 숫자로 생각하는 개념이 없었다. 숫자는 가지고 있는 물건을 세기 위한 것이었다; 가지고 있지 않은 것을 어떻게 세겠는가?)
  5. 로마 숫자에는 음수를 표현할 방법이 없다.
  6. 로마 숫자에는 소수나 분수를 표현할 방법이 없다.

이러한 모든 것을 전제로 하여, 로마숫자로 그리고 로마숫자로부터 변환하는 함수의 모임에서 무엇을 예상할 수 있는가?

roman.py의 요구조건

  1. toRoman1에서 3999까지 모든 정수에 대한 로마 숫자의 표현을 반환해야 한다.
  2. toRoman1에서 3999까지 범위 밖의 정수가 주어지면 실패해야 한다.
  3. toRoman은 비-정수 소수가 주어지면 실패해야 한다.
  4. fromRoman은 유효한 로마 숫자를 취해야 하고 그것이 표현하는 숫자를 반환해야 한다.
  5. fromRoman은 무효한 로마 숫자가 주어지면 실패해야 한다.
  6. 만약 숫자를 취하면, 그것을 로마 숫자로 변환시켜라. 그리고 나서 그것을 다시 숫자로 변환하라. 여러분이 시작한 그 숫자로 끝나야 한다. 그래서 1..3999사이의 모든 n에 대하여 fromRoman(toRoman(n)) == n이다.
  7. toRoman은 항상 로마 숫자를 대문자를 사용하여 반환하여야 한다.
  8. fromRoman은 오직 대문자를 로마 숫자로 허용한다. (즉 다시 말하면, 소문자가 주어진다면 실패해야 한다).

더 읽어야 할 것

5.2. romantest.py를 소개하기

이제 변환 함수로부터 예상할 수 있는 행위를 완전하게 정의하였으므로, 약간은 예상치 못한 어떤 일들을 하려고 한다: 테스트 모둠을 작성하여 이러한 함수들이 자기의 길을 가도록 하고 우리가 원하는 대로 그들이 움직이는 것을 확인하려고 한다. 여러분이 읽은 대로다: 지금까지 작성하지 않았던 테스트 코드를 작성하려고 한다.

이것을 유닛 테스트라고 부르는데, 왜냐하면 두 개의 변환 함수들의 모임이, 커다란 프로그램으로부터 분리되어져, 유닛으로 작성되어지고 테스트 될 수 있기 때문이다. 유닛은 이후에 그 큰 프로그램의 일 부분이 될 것이다. 파이썬은 유닛 테스트를 위한 작업틀을 가지고 있다. 그 이름에 걸맞게 unittest라는 모듈을 가진다.

Note
unittest는 파이썬 2.1 이후에서 사용가능하다. 파이썬 2.0 사용자는 pyunit.sourceforge.net로부터 내려받을 수있다.

유닛 테스트는 전체 테스트-중심 개발 전략의 가장 중요한 부분이다. 유닛 테스트를 작성한다면, 빨리 작성하는 것이 중요하다 (특히 테스트할 코드를 작성하기 전이라면 더욱 좋다). 그리고 코드와 요구사항이 변함에 따라서 그것들을 계속하여 갱신하라. 유닛 테스트는 고-수준의 기능적 혹은 시스템 테스트를 위한 대체물이 아니라, 오히려 모든 개발의 국면에서 중요하다:

이것은 로마 숫자 변환 함수들을 위한 완전한 테스트 모둠이다. 아직 작성되지 않았지만 결국 roman.py에 씌여지게 될 것이다. 어떻게 그 모든 것들이 조립될 지는 직접적으로 확실하게 보이지는 않는다; 이러한 클래스 혹은 메쏘드중 어느 것도 다른 것을 참조하지 않는다. 이러한 것에는 충분한 이유가 있으며, 그것을 간략하게 살펴 보겠다.

Example 5.1. romantest.py

아직 그렇게 하지 못했다면, 이 예제와 함께 이 책에서 사용된 다른 예제들을 내려 받을 수 있다 (Windows, UNIX, Mac OS).

"""Unit test for roman.py"""

import roman
import unittest

class KnownValues(unittest.TestCase):
    knownValues = ( (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 testToRomanKnownValues(self):
        """toRoman should give known result with known input"""
        for integer, numeral in self.knownValues:
            result = roman.toRoman(integer)
            self.assertEqual(numeral, result)

    def testFromRomanKnownValues(self):
        """fromRoman should give known result with known input"""
        for integer, numeral in self.knownValues:
            result = roman.fromRoman(numeral)
            self.assertEqual(integer, result)

class ToRomanBadInput(unittest.TestCase):
    def testTooLarge(self):
        """toRoman should fail with large input"""
        self.assertRaises(roman.OutOfRangeError, roman.toRoman, 4000)

    def testZero(self):
        """toRoman should fail with 0 input"""
        self.assertRaises(roman.OutOfRangeError, roman.toRoman, 0)

    def testNegative(self):
        """toRoman should fail with negative input"""
        self.assertRaises(roman.OutOfRangeError, roman.toRoman, -1)

    def testDecimal(self):
        """toRoman should fail with non-integer input"""
        self.assertRaises(roman.NotIntegerError, roman.toRoman, 0.5)

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

    def testRepeatedPairs(self):
        """fromRoman should fail with repeated pairs of numerals"""
        for s in ('CMCM', 'CDCD', 'XCXC', 'XLXL', 'IXIX', 'IVIV'):
            self.assertRaises(roman.InvalidRomanNumeralError, roman.fromRoman, s)

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

class SanityCheck(unittest.TestCase):
    def testSanity(self):
        """fromRoman(toRoman(n))==n for all n"""
        for integer in range(1, 4000):
            numeral = roman.toRoman(integer)
            result = roman.fromRoman(numeral)
            self.assertEqual(integer, result)

class CaseCheck(unittest.TestCase):
    def testToRomanCase(self):
        """toRoman should always return uppercase"""
        for integer in range(1, 4000):
            numeral = roman.toRoman(integer)
            self.assertEqual(numeral, numeral.upper())

    def testFromRomanCase(self):
        """fromRoman should only accept uppercase input"""
        for integer in range(1, 4000):
            numeral = roman.toRoman(integer)
            roman.fromRoman(numeral.upper())
            self.assertRaises(roman.InvalidRomanNumeralError,
                              roman.fromRoman, numeral.lower())

if __name__ == "__main__":
    unittest.main()   

더 읽어야 할 것

5.3. 성공을 위한 테스트

유닛 테스트의 가장 기본적인 부분은 개별적인 테스트 사례를 구성하는 것이다. 하나의 테스트 사례는 테스트되고 있는 코드에 관한 한 개의 질문에 답해 준다.

테스트 사례는 ....를 할 수 있어야 한다

이것을 전제로 하여, 첫 번째 테스트 사례를 구축해보자. 요구조건은 다음과 같다:

  1. toRoman1에서 3999까지의 모든 정수에 대하여 로마 숫자 표현을 반환해야 한다.

Example 5.2. testToRomanKnownValues

class KnownValues(unittest.TestCase):
1
    knownValues = ( (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'))                        2

    def testToRomanKnownValues(self):                           3
        """toRoman should give known result with known input"""
        for integer, numeral in self.knownValues:
            result = roman.toRoman(integer)                     4 5
            self.assertEqual(numeral, result)                   6
1 테스트 사례를 작성하기 위해서, 제일 먼저 할 일은 unittest 모듈의 TestCase 클래스를 하부클래스화 하는 것이다. 이 클래스는 많은 유용한 메쏘드가 있어서 여러분의 테스트 사례에 사용하면 여러분은 특별한 조건들을 테스트할 수 있다.
2 이것은 내가 수동으로 확인한 정수/숫자 쌍의 리스트이다. 그것에는 가장 낮은 10 개의 숫자, 가장 높은 숫자, 한개의-문자로 된, 로마 숫자로 변환되는 모든 숫자, 그리고 무작위로 뽑은 다른 유효한 숫자들이 포함된다. 유닛 테스트의 요점은 입력 가능한 모든 것을 테스트하는 것이 아니라, 대표적인 샘플을 테스트하는 것이다.
3 모든 개별적인 테스트는 자기 자신의 메쏘드이다, 그것은 아무런 인자도 반환 값도 가지지 않는다. 만약 그 메쏘드가 예외를 발생시키지 않고 정상적으로 존재한다면, 그 테스트는 통과된 것으로 간주된다; 만일 그 메쏘드가 예외를 일으키면, 그 테스트는 실패한 것으로 간주된다.
4 여기에서 우리는 그 실제의 toRoman 함수를 호출한다는 것을 주목하라. (자, 그 함수는 아직 작성되지 않았다, 그러나 작성되었다면, 이 줄에서 바로 그것을 호출할 것이다.) 우리는 지금 toRoman 함수를 위한 API를 정의하였다: 그것은 (변환해야할 숫자인) 한 개의 정수를 취해야만 한다 그리고 (로마 숫자 표현) 문자열을 반환해야 한다. 만약 API 가 그것과 다르다면, 이 테스트는 실패한 것으로 간주된다.
5 또한 주목할 것은 우리는 toRoman을 호출할 때 어떠한 예외도 낚아 채지 않는다는 것이다. 이것은 의도적이다. 우리가 그것을 유효한 입력으로 호출할 때, toRoman은 예외를 발생시켜서는 안된다 그리고 이러한 입력 값들은 모두 유효하다. 만약 toRoman이 예외를 일으킨다면, 이 테스트는 실패한 것으로 간주되다.
6 toRoman 함수는 정확하게 정의되어졌고, 정확하게 호출되었으며, 성공적으로 완료되고, 그리고 하나의 값을 반환했다고 가정해보자, 가장 마지막 단계는 그 함수가 반환한 값이 옳은 (right) 값인가를 점검하는 것이다. 이것은 흔한 질문이다, 그리고 TestCase 클래스는 메쏘드, assertEqual를 제공하는데, 두 개의 값이 같은가를 점검한다. 만약 toRoman으로부터 반환된 그 결과(result)가 우리가 예상하고 있는 그 알려진 값(numeral)과 일치하지 않는다면, assertEqual는 예외를 일으킬 것이고 그 테스트는 실패할 것이다. 만약 두 개의 값이 같다면, assertEqual는 아무것도 하지 않을 것이다. 만약 toRoman으로부터 반환된 모든 값이 우리가 예상한 그 알려진 값과 같다면, assertEqual는 절대로 예외를 일으키지 않는다, 그래서 testToRomanKnownValues는 결국 정상적으로 종료한다, 그것은 toRoman이 이 테스트를 통과하였다는 것을 의미한다.

5.4. 실패를 위한 테스트

함수에 좋은 입력이 주어질 때 성공하는 것을 테스트하는 것만으로는 충분하지 않다; 또한 나쁜 입력이 주어질 때도 실패한다는 것을 테스트 해야 한다. 그리고 단지 어떤 종류의 실패가 아니라; 예상한 방식대로 실패해야한다.

toRoman를 위한 다른 요구 조건을 기억하라 :

  1. toRoman1에서 3999까지의 범위 밖에 있는 정수가 주어진다면 실패해야 한다.
  2. toRoman는 비-정수 소수가 주어지면 실패해야 한다.

파이썬에서 함수는 예외를 일으킴으로써, 실패를 나타낸다. 그리고 unittest 모듈은 함수에 나쁜 입력값이 주어지면 특별한 예외를 일으키는지 테스트를 하기 위한 메쏘드를 제공한다.

Example 5.3. Testing bad input to toRoman

class ToRomanBadInput(unittest.TestCase):
    def testTooLarge(self):
        """toRoman should fail with large input"""
        self.assertRaises(roman.OutOfRangeError, roman.toRoman, 4000) 1

    def testZero(self):
        """toRoman should fail with 0 input"""
        self.assertRaises(roman.OutOfRangeError, roman.toRoman, 0)    2

    def testNegative(self):
        """toRoman should fail with negative input"""
        self.assertRaises(roman.OutOfRangeError, roman.toRoman, -1)

    def testDecimal(self):
        """toRoman should fail with non-integer input"""
        self.assertRaises(roman.NotIntegerError, roman.toRoman, 0.5)  3
1 unittestTestCase 클래스는 assertRaises 메쏘드를 제공하는데, 다음의 인자들을 취한다: 예상되는 예외, 테스트하려는 함수, 그리고 함수에 건네주는 인자들. (테스트하려는 함수가 한개 이상의 인자를 가지면, 순서대로 그들 모두를 assertRaises에 넘겨 주면 된다. 그러면 그들을 정확히 연이어서 테스트하고 있는 그 함수에 건네 줄 것이다.) 여기에서 하고 있는 것에 주의를 기울여 보라: toRoman을 직접적으로 호출하고 (그것을 try...except 블록에 싸 넣음으로써) 수작업으로 그것이 특별한 예외를 일으키는지 점검하는 대신에, assertRaises는 그 모든 것들을 우리 대신에 캡슐화한다. 우리가 하는 모든 것은 그것에 예외(roman.OutOfRangeError), 함수(toRoman), 그리고 toRoman의 인자(4000)들을 주는 것이다. assertRaises는 주의하여 toRoman을 호출하고 그리고 그것이 roman.OutOfRangeError를 일으키는지 확실하게 점검한다. (함수 그리고 예외를 포함하여 파이썬에서 모든 것은 객체이다라는 것이 얼마나 유용한지 내가 최근에 언급한 일을 기억하는가?)
2 아주 큰 숫자들을 테스트하면서, 아주 작은 숫자들을 테스트할 필요가 있다. 기억하라. 로마 숫자는 0 또는 음의 정수를 표현할 수 없다. 그래서 그러한 각각에 대하여 테스트 사례를 가진다 (testZero 그리고 testNegative). testZero에서 toRoman0으로 호출될 때 roman.OutOfRangeError 예외를 일으키는지 테스트한다; 만약 roman.OutOfRangeError를 일으키지 않는다면 (그것이 실제의 값을 반환하거나 혹은 어떤 다른 예외를 일으키기 때문에), 이 테스트는 실패로 간주된다.
3 요구조건 #3toRoman이 비-정수 소수를 받아들일 수 없다고 가정한다. 그래서 여기에서 toRoman이 소수(0.5)로 호출될 때 roman.NotIntegerError 예외를 일으키는지 확인하는 테스트를 한다. 만약 toRomanroman.NotIntegerError를 일으키지 않는다면, 이 테스트는 실패로 간주된다.

다음의 두 요구조건들toRoman대신에 fromRoman에 적용된다는 점만 빼고는, 처음 세 개와 비슷하다:

  1. fromRoman는 유효한 로마 숫자를 취해야만 하고 그리고 그것이 표현하는 숫자를 반환해야 한다.
  2. fromRoman은 무효한 로마 숫자가 주어지면 실패해야 한다.

요구조건 #4는 requirement #1과 같은 방식으로 처리되어, 알려진 값들의 샘플을 반복하여서 순서대로 각각을 테스트한다. 요구조건 #5는 요구조건 #2, #3과 같은 방식으로 처리되어, 일련의 나쁜 입력을 테스트하고 fromRoman이 그 적절한 예외를 일으키는지 확인한다.

Example 5.4. Testing bad input to fromRoman

class FromRomanBadInput(unittest.TestCase):
    def testTooManyRepeatedNumerals(self):
        """fromRoman should fail with too many repeated numerals"""
        for s in ('MMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):
            self.assertRaises(roman.InvalidRomanNumeralError, roman.fromRoman, s) 1

    def testRepeatedPairs(self):
        """fromRoman should fail with repeated pairs of numerals"""
        for s in ('CMCM', 'CDCD', 'XCXC', 'XLXL', 'IXIX', 'IVIV'):
            self.assertRaises(roman.InvalidRomanNumeralError, roman.fromRoman, s)

    def testMalformedAntecedent(self):
        """fromRoman should fail with malformed antecedents"""
        for s in ('IIMXCC', 'VX', 'DCM', 'CMM', 'IXIV',
                  'MCMC', 'XCX', 'IVI', 'LM', 'LD', 'LC'):
            self.assertRaises(roman.InvalidRomanNumeralError, roman.fromRoman, s)
1 이것에 관하여 논하는 것은 그렇게 새롭지 않다; 이 패턴은 toRoman에 나쁜 입력을 테스트하는데 사용했던 패턴과 정확하게 똑 같다. 짧게 말해 그것은 또 다른 예외를 가진다고 말하겠다: roman.InvalidRomanNumeralError이라고 말이다. (roman.OutOfRangeError 그리고 roman.NotIntegerError와 함께) 이것은 roman.py안에 정의될 필요가 있는 총 세 개의 맞춤 예외를 만든다. 이러한 맞춤 예외를 정의하는 법을 이 장의 후반부에서 살펴 보겠다. 그 때 실제로 roman.py를 쓰기 시작할 것이다.

5.5. 청결을 위한 테스트

한 단위의 코드가 보통 변환함수들의 형태에서 상호호출적인 함수들의 집합을 포함하고 있는 것을 발견하는 경우가 자주 있다. 거기에서 하나는 A를 B로 변환하고 다른 하나는 B를 A로 변환한다. 이러한 경우에, 유용한 것은 “청결 점검(sanity check)”를 작성하여 A에서 B로 그리고 역으로 A로, 소수의 정밀도를 잃어버리지 않고, 버림 에러를 일으키지 않고, 혹은 다른 어떤 종류의 버그도 촉발시키지 않고, 변환할 수 있다는 것을 확인하는 것이다.

이런 요구조건을 생각해 보라

  1. 숫자 하나를 취해, 그것을 로마 숫자로 변환한다면, 그리고서 다시 그것을 숫자로 변환한다면, 여러분이 시작했던 그 숫자로 결과가 나와야 한다. 그래서 1..3999에 있는 모든n에 대하여 'fromRoman(toRoman(n)) == n '이다.

Example 5.5. Testing toRoman against fromRoman

class SanityCheck(unittest.TestCase):
    def testSanity(self):
        """fromRoman(toRoman(n))==n for all n"""
        for integer in range(1, 4000):        1 2
            numeral = roman.toRoman(integer)
            result = roman.fromRoman(numeral)
            self.assertEqual(integer, result) 3
1 전에 range 함수 를 본 일이 있지만, 여기에서는 두 개의 인자로 호출된다. 정수 리스트를 반환하는데 첫 번째 인자 (1)에서 시작하여 연속적으로 두 번째 인자(4000)까지 그러나 포함하지 않고서 센다. 그리하여, 1..3999이 로마 숫자로 변환하기 위한 유효 범위이다.
2 꼭 언급하고 싶은 것은 (인자) 넘겨주기에서 정수(integer)는 파이썬에서 키워드가 아니라는 것이다; 여기에서는 단지 다른 어떤 것과 마찬가지로 변수이름일 뿐이다.
3 여기에서 실제적인 테스트 논리는 알기 쉽다: 하나의 숫자(integer)를 취해서, 그것을 로마 숫자(numeral)로 변환한다. 그리고선 다시 그것을 숫자(result)로 변환하고 그리고 처음에 시작했던 같은 숫자로 끝났는지를 확인한다. 그렇지 않다면, assertEqual는 예외를 일으키고 그 테스트는 즉시 실패로 간주될 것이다. 만약 그 모든 숫자가 일치하면, assertEqual는 항상 조용히 돌아올 것이다. 그 전체 testSanity 메쏘드는 결국 조용하게 돌아올 것이고, 테스트는 통과된 것으로 간주 될 것이다.

마지막 두 개의 요구조건은 다른 것들과는 다른데 왜냐하면 그것들은 둘 다 임의적이고 사소하기 때문이다:

  1. toRoman는 항상 대문자를 사용하여 로마 숫자를 반환해야 한다. 그리고 fromRoman는 오직 대문자의 로마 숫자만을 받아 들여야 한다 (즉, 다시 말하면 소문자 입력이 주어지면 실패해야 한다).
  2. fromRoman은 오직 대문자의 로마 숫자만을 받아 들여야 한다 (즉, 다시 말하면 소문자 입력이 주어지면 실패해야 한다).

사실은 약간은 임의적이다. 예를 들어, fromRoman은 소문자와 대소문자가 섞인 입력을 받아들인다고 가정한다. 그러나 완전히 임의적인 것은 아니다; 만약 toRoman이 항상 대문자로 출력을 반환한다면, fromRoman은 적어도 대문자의 입력을 받아들여야만 한다. 그렇지 않으면 “청결 점검(sanity check)” (요구조건 #6)은 실패할 것이다. '그것은 오직(only) 대문자의 입력만을 받아 들인다'라고 하는 사실은 임의적이다. 그러나 모든 시스템 통합회로가 경고하듯이, 대소문자는 항상 문제거리이다. 그래서 그 행위를 전면에 지정할 가치가 있다. 그리고 지정할 만한 가치가 있다면, 테스트할 가치도 있다

Example 5.6. Testing for case

class CaseCheck(unittest.TestCase):
    def testToRomanCase(self):
        """toRoman should always return uppercase"""
        for integer in range(1, 4000):
            numeral = roman.toRoman(integer)
            self.assertEqual(numeral, numeral.upper()) 1

    def testFromRomanCase(self):
        """fromRoman should only accept uppercase input"""
        for integer in range(1, 4000):
            numeral = roman.toRoman(integer)
            roman.fromRoman(numeral.upper())           2 3
            self.assertRaises(roman.InvalidRomanNumeralError,
                              roman.fromRoman, numeral.lower())
1 이 테스트 사례에 관하여 가장 흥미로운 것은 그것이 테스트하지 않는 모든 것이라는 것이다. toRoman으로부터 반환된 그 값이 옳은지 또는 일관성이 있는지조차도 테스트하지 않는다; 그러한 질문들은 별개의 테스트 사례로 답한다. 단지 대문자-인것 만을 테스트하는 온전한 테스트 사례를 가진다. 이것을 그 전체 범위의 값들을 실행하고 toRoman을 호출함으로써, 청결 점검 (sanity check)과 결합하고 싶은 유혹에 빠질 지도 모르겠다.[11] 그러나 그것은 기본적인 규칙들을 위반할 것이다: 각 테스트 사례는 오직 한개의 질문에만 답하여야 한다. 이 사례 점검을 그 청결 점검과 결합하고, 그리고 그 테스트 사례가 실패한다고 상상해 보라. 더 깊이 분석을 해서 그 테스트 사례의 어느 부분이 어떤 문제가 있는지를 결정하는데 실패했는지 연구하여야 할 것이다. 유닛 테스트의 결과를 분석하여 단지 그것들이 무엇을 뜻하는지를 연구해야 한다면, 그것은 테스트 사례를 잘못-디자인했다는 확실한 표시이다.
2 여기에서 배워야할 비슷한 교훈이 있다: toRoman는 항상 대문자를 반환한다는 것을 “알고 있음에도” 불구하고, 명시적으로 그것의 반환 값을 대문자로 변환하여 fromRoman이 대문자 입력을 받아들이는 지 테스트한다. 왜인가? 왜냐하면 'toRoman이 항상 대문자를 반환한다는 것'은 독립적인 요구조건이기 때문이다. 예를 들어, 항상 소문자를 반환하도록 요구조건을 변경하면, testToRomanCase 테스트 사례는 변경되어야 할 것이다. 그러나 이 테스트 사례는 여전히 작동할 것이다. 이것은 또 다른 기본적인 규칙이다: 각 테스트 사례는 다른 모든 것과는 별개로 작동할 수 있어야 한다. 모든 테스트 사례는 고립된 섬이다.
3 fromRoman의 반환 값을 어디에도 할당하지 않는다는 것을 주목하라. 파이썬에서 이것은 적법한 구문이다; 만약 한 함수가 값을 반환함에도 불구하고 아무도 관심을 기울이지 않으면, 파이썬은 단지 그 반환 값을 버릴 뿐이다. 이 경우에 그것이 우리가 원한 바이다. 이 테스트 사례는 반환 값에 대하여 아무 것도 테스트하지 않는다; 단지 fromRoman이 아무런 예외없이 대문자 입력을 받아들이는지를 테스트할 따름이다.

5.6. roman.py, 제 1 단계

이제 유닛 테스트가 완성되었으므로, 테스트 사례가 테스트하기를 시도하고 있는 바로 그 코드를 작성하기 시작할 시간이다. 단계별로 이렇게 하려고 한다. 그래서 모든 유닛 테스트가 실패하는 것을 볼 수 있다. 우리가 roman.py안의 공백들을 메꾸어 감에 따라서 하나씩 하나씩 그것들이 통과하는 것을 볼 수 있다.

Example 5.7. roman1.py

아직 그렇게 하지 않았다면, 이 예제와 이 책에서 사용되는 다른 예제들을 내려 받을 수 있다 (Windows, UNIX, Mac OS).

"""Convert to and from Roman numerals"""

#Define exceptions
class RomanError(Exception): pass                1
class OutOfRangeError(RomanError): pass          2
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass 3

def toRoman(n):
    """convert integer to Roman numeral"""
    pass                                         4

def fromRoman(s):
    """convert Roman numeral to integer"""
    pass
1 이것이 바로 자신만의 맞춤 예외를 파이썬에서 정의하는 방법이다. 예외는 클래스이다. 그리고 존재하는 예외들을 하부클래스화 함으로써 자신만의 예외를 생성한다. (필수적인 것은 아니지만) Exception을 하부클래스화 하기를 강력히 권장한다. 그것은 모든 내장 예외들이 상속되는 기본 클래스이다. 여기에서는 (Exception으로부터 상속된) RomanError를 정의하는데, 그것은 기본 클래스로 작용하여 다른 나의 모든 맞춤 예외들이 따라온다. 이것은 스타일의 문제이다; 나는 Exception 클래스로부터 직접적으로 아주 쉽게 개별적인 예외를 따로따로 상속받을 수 있었다.
2 OutOfRangeErrorNotIntegerError 예외는 결국 toRoman에 의하여 사용되어져, ToRomanBadInput에 지정된 대로, 다양한 형태의 무효한 입력을 들어올려 신호할 것이다.
3 InvalidRomanNumeralError 예외는 결국 fromRoman에 의해 사용되어져서, FromRomanBadInput에 지정된 대로, 무효한 입력을 들어올려 신호할 것이다.
4 이 단계에서, 함수 각각을 위한 API를 작성하기를 원하지만, 아직 코딩하기를 원하지는 않는다. 그래서 파이썬의 예약어 pass를 사용하여 임시로 세운다.

이제 그 결정적인 순간 (드럼을 쳐주세요 두두두~): 마침내 유닛 테스트를 이 임시적인 작은 모듈에 대하여 실행하려고 한다. 이 시점에서, 모든 테스트 사례는 실패해야 한다. 사실, 어떤 테스트라도 제 1 단계를 통과한다면, romantest.py로 돌아가야만 하고 그리고 왜 아무것도-하지-않는 함수들을 가지고 통과할 정도로 쓸모 없는 테스트를 작성했는지를 재평가해야 한다.

romantest1.py-v 명령어-줄 선택사항으로 실행하라. 더욱 자세한 출력을 보여 줄 것이고 그래서 각 테스트 사례가 실행되는 동안 정확하게 무엇이 진행되고 있는 볼 수 있다. 운이 좋다면, 출력은 다음과 비슷해야 한다:

Example 5.8. Output of romantest1.py against roman1.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 testDecimal
    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                                 1
----------------------------------------------------------------------
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                                        2
----------------------------------------------------------------------
Ran 12 tests in 0.040s                                                 3

FAILED (failures=10, errors=2)                                         4
1 스크립트를 실행하면 unittest.main()을 실행하고, 그것은 각 테스트 사례를 실행한다. 테스트 사례란 romantest.py안의 각 클래스 안에 정의된 각 메쏘드를 말한다. 각 테스트 사례에 대하여 메쏘드의 문서화 문자열(doc string)과 테스트가 실패했는지 통과했는지를 출력한다. 예상한 바와 같이, 테스트 사례 어느 것도 통과하지 못했다.
2 실패한 각 테스트 사례에 대하여, unittest는 정확하게 무슨 일이 일어났는지를 보여주는 추적 정보를 출력한다. 이 경우에, (failUnlessRaises라고도 불리는) assertRaises에 대한 호출은 AssertionError를 일으킨다. 왜냐하면 toRomanOutOfRangeError을 일으키기를 예상하고 있었으나 일어나지 않았기 때문이다.
3 자세한 정보를 출력한 후에, unittest는 얼마나 많은 테스트가 수행되었는지 그리고 얼마나 많은 시간이 걸렸는지에 관한 요약정보를 보여준다.
4 전체적으로, 유닛 테스트는 실패한다. 왜냐하면 적어도 하나의 테스트 사례는 통과하지 않기 때문이다. 테스트 사례가 통과하지 않을 때, unittest 는 실패와 에러 사이를 구별한다. 실패는 assertEqual 또는 assertRaises와 같은 assertXYZ 메쏘드에 대한 호출로서, 선언된 조건이 참이 아니거나 혹은 그 예상된 예외가 발생하지 않았기 때문에 실패한다. 에러는 테스트하고 있는 코드 혹은 유닛 테스트 사례 그 자체에서 일어나는 모든 종류의 예외이다. 예를 들어, testFromRomanCase 메쏘드는 (“fromRoman은 오직 대문자 입력만을 접수해야만 하므로”) 에러였다. 왜냐하면 numeral.upper()에 대한 호출이 AttributeError 예외를 일으켰기 때문인데, toRoman은 문자열을 반환하기로 되어 있으나 그렇지 않았기 때문이다. 그러나 testZero는 (“toRoman은 0 입력으로는 실패해야 하므로”) 실패였다. 왜냐하면 fromRoman에 대한 호출이 assertRaises가 찾고 있는 InvalidRomanNumeral 예외를 일으키지 않았기 때문이다.

5.7. roman.py,제 2 단계

이제 roman 모듈의 작업틀이 준비되었으므로, 코드를 작성하고 테스트 사례를 넘겨줄 시간이다.

Example 5.9. roman2.py

아직 그렇게 하지 못하였다면, 이 예제와 함께 이 책에서 사용된 다른 예제들을 내려 받을 수 있다 (Windows, UNIX, Mac OS).

"""Convert to and from Roman numerals"""

#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass

#Define digit mapping
romanNumeralMap = (('M',  1000), 1
                   ('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):
    """convert integer to Roman numeral"""
    result = ""
    for numeral, integer in romanNumeralMap:
        while n >= integer:      2
            result += numeral
            n -= integer
    return result

def fromRoman(s):
    """convert Roman numeral to integer"""
    pass
1 romanNumeralMap는 터플의 터플로서 세 가지를 정의한다:
  1. 가장 기본적인 로마 숫자의 문자 표현들. 주목할 것은 이것이 단지 한개짜리-문자로 된 로마 숫자가 아니라는 것이다; 또, CM과 같은, 두-문자로 된 쌍을 정의한다 (“1000보다 100작은 수”); 이후에 toRoman 코드를 더욱 단순하게 만들 것이다.
  2. 로마 숫자들의 순서. M으로부터 쭉 I까지 내림차순 값의 순서로 나열된다.
  3. 각 로마 숫자의 값. 내부의 각 터플은 (numeral, value)의 쌍이다.
2 여기가 바로 풍부한 데이타 구조가 힘을 발휘하는 곳이다. 왜냐하면 빼기 규칙을 처리하기 위하여 어떤 특별한 논리도 필요하지 않기 때문이다. 로마 숫자로 변환하기 위해서, 단순하게 romanNumeralMap을 반복하여 입력보다 작거나 같은 가장 큰 정수 값을 찾기만 하면 된다. 발견되면, 그 로마 숫자 표현을 출력의 마지막에 추가하고, 그 입력으로부터 상응하는 정수를 뺀 다음 다시 반복한다.

Example 5.10. How toRoman works

어떻게 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는 적어도 우리의 임시 수동 점검에서는 작동하는 듯이 보인다. 그러나 유닛 테스트를 통과할까? 음 아니다, 전적으로는 아니다.

Example 5.11. Output of romantest2.py against roman2.py

fromRoman should only accept uppercase input ... FAIL
toRoman should always return uppercase ... ok                  1
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       2
fromRoman(toRoman(n))==n for all n ... FAIL
toRoman should fail with non-integer input ... FAIL            3
toRoman should fail with negative input ... FAIL
toRoman should fail with large input ... FAIL
toRoman should fail with 0 input ... FAIL
1 toRoman은 사실 언제나 대문자를 반환한다. 왜냐하면 romanNumeralMap이 로마 숫자 표현들을 대문자로 정의하기 때문이다. 그래서 이 테스트는 이미 통과한 거나 다름없다.
2 여기에 엄청난 뉴스가 있다: toRoman 함수의 이 버전은 알려진 값 테스트를 통과한다. 기억하라. 일반적이지는 않지만, 다양한 좋은 입력을 가지고 그 함수의 능력을 평가한다. 그 입력에 포함되는 것은 한개짜리-문자의 모든 로마 숫자를 산출하는 입력들, 가능한 가장 큰 입력 (3999), 그리고 가능한 가장 긴 로마 숫자 (3888)를 산출하는 입력이다. 이 시점에서는 그 함수가 던져 넣는 어떠한 좋은 입력값에도 작동한다고 논리적으로 확신할 수 있다.
3 그렇지만 그 함수는 나쁜 값에 대하여는“작동하지” 않는다; 모든 개별적 나쁜 입력 테스트에 실패한다. 그것은 당연하다. 왜냐하면 나쁜 입력에 대하여 어떤 점검부도 포함하지 않았기 때문이다. 그러한 테스트 사례는 (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 testDecimal
    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)

5.8. roman.py, 제 3 단계

이제 toRoman이 (1에서 3999까지의 정수의) 좋은 입력으로는 정확하게 행동하므로, (다른 모든) 나쁜 입력값으로도 정확하게 행동하도록 만들 시간이다.

Example 5.12. roman3.py

아직 그렇게 하지 못하였다면, 이 예제와 더불어 이 책에서 사용된 다른 예제들을 내려 받을 수 있다 (Windows, UNIX, Mac OS).

"""Convert to and from Roman numerals"""

#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass

#Define digit mapping
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):
    """convert integer to Roman numeral"""
    if not (0 < n < 4000):                                             1
        raise OutOfRangeError, "number out of range (must be 1..3999)" 2
    if int(n) <> n:                                                    3
        raise NotIntegerError, "decimals can not be converted"

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

def fromRoman(s):
    """convert Roman numeral to integer"""
    pass
1 이것은 멋진 파이썬다운 지름길이다: 한번에 다중 비교를 수행한다. 이것은 'if not ((0 < n) and (n < 4000))'과 동등하다. 그러나 이것이 훨씬 더 읽기에 쉽다. 이것은 범위 점검으로서, 너무 큰 값, 음수, 또는 0인 입력값을 나포해야만 한다.
2 여러분은 스스로 raise 서술문을 가지고 예외를 일으킬 수 있다. 어떠한 내장 예외도 일으킬 수 있으며 또는 정의한 어떠한 맞춤 예외도 일으킬 수 있다. 두 번째 매개변수인 에러 메시지는 선택적이다; 만약 주어진다면, 예외가 처리되지 않을 경우에 출력되는 역 추적 메시지에 출력될 것이다.
3 이것은 소수 점검부이다. 소수는 로마숫자로 변환될 수 없다.
4 이 함수의 나머지는 변경되지 않는다.

Example 5.13. Watching toRoman handle bad input

>>> 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, "decimals can not be converted"
NotIntegerError: decimals can not be converted

Example 5.14. Output of romantest3.py against roman3.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 1
fromRoman(toRoman(n))==n for all n ... FAIL
toRoman should fail with non-integer input ... ok        2
toRoman should fail with negative input ... ok           3
toRoman should fail with large input ... ok
toRoman should fail with 0 input ... ok
1 toRoman은 여전히 알려진 값 테스트를 통과하는데, 참으로 즐거운 일이다. 제 2 단계에서 통과된 모든 테스트도 여전히 통과한다. 그래서 마지막 코드는 아무것도 망가트리지 않았다.
2 더욱 신나는 일은 나쁜 입력 테스트가 모두 이제 통과한다는 것이다. 이 테스트 testDecimalint(n) <> n의 점검부 때문에 통과한다. 소수가 toRoman에 넘겨질 때, int(n) <> n 점검부는 그것을 알아채고 NotIntegerError 예외를 일으키는데, 그것이 바로 testDecimal이 기다리고 있는 것이다.
3 이 테스트 testNegativenot (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) 1
1 6 개의 에러를 맞이한다. 그리고 그들 모두는 fromRoman에 연관된다: 알려진 값 테스트, 세개의 개별적인 나쁜 입력 테스트, 대소문자 점검, 그리고 청결 점검이 바로 그것이다. 그것은 toRoman이 스스로 통과할 수 있는 모든 테스트를 통과하였다는 것을 의미한다 (그것은 청결 점검과 연관되지만, 또한 아직 작성되지 않은 fromRoman이 작성되기를 요구한다.). 그것은 지금 toRoman을 코딩하기를 멈추어야 한다는 것을 의미한다. 어떠한 비틀기도, 어떠한 조작도, “만일에 대비한” 어떠한 추가적인 점검도 모두 다 금지하라. 중지하라. 지금 당장. 키보드로부터 멀리 떨어져라.
Note
일반적인 유닛 테스트가 여러분에게 알려줄 수 있는 가장 중요한 것은 언제 코딩을 멈추는가이다. 한 함수를 위한 모든 유닛 테스트가 통과될 때, 그 함수를 코딩하는 것을 중지하라. 전체 모듈에 대한 모든 유닛 테스트가 통과될 때, 그 모듈을 코딩하는 것을 중지하라.

5.9. roman.py, 제 4 단계

이제 toRoman이 완성되었으므로, fromRoman을 코딩할 시간이다. 개별적인 로마 숫자를 정수 값으로 짝지워 주는 풍부한 데이타 구조 덕분에, 이것은 toRoman 함수와 마찬가지로 어렵지 않다.

Example 5.15. roman4.py

아직 그렇게 하지 못했다면, 이 예제와 그리고 이 책에서 사용된 다른 예제들을 내려 받을 수 있다 (Windows, UNIX, Mac OS).

"""Convert to and from Roman numerals"""

#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass

#Define digit mapping
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 function omitted for clarity (it hasn't changed)

def fromRoman(s):
    """convert Roman numeral to integer"""
    result = 0
    index = 0
    for numeral, integer in romanNumeralMap:
        while s[index:index+len(numeral)] == numeral: 1
            result += integer
            index += len(numeral)
    return result
1 여기에서의 패턴은 toRoman과 동일하다. 로마 숫자 데이타 구조(터플들의 터플)를 회돌이 하여, 가능한 한 가장 높은 정수 값에 짝지우는 대신에, 가능한 한 “가장 높은” 로마 숫자 문자열에 짝지운다.

Example 5.16. How fromRoman works

어떻게 fromRoman이 작동하는지 이해하지 못하겠다면, print 서술문을 while 회돌이의 뒤에다 추가하라:

        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
            print 'found', numeral, ', adding', integer
>>> import roman4
>>> roman4.fromRoman('MCMLXXII')
found M , adding 1000
found CM , adding 900
found L , adding 50
found X , adding 10
found X , adding 10
found I , adding 1
found I , adding 1
1972

Example 5.17. Output of romantest4.py against roman4.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 1
toRoman should give known result with known input ... ok
fromRoman(toRoman(n))==n for all n ... ok                  2
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
1 여기에 두 개의 신나는 소식이 있다. 첫 째는 fromRoman이 좋은 입력에, 적어도 우리가 테스트하는 그 알려진 값들에 대하여 작동한다는 것이다.
2 둘 째는 청결 점검(sanity check) 역시 통과했다는 것이다. 알려진 값 테스트와 결합해보면, toRomanfromRoman 둘 다 가능한 모든 좋은 값들에 대하여 적절히 작동할 것이라고 논리적으로 확신할 수 있다. (이것이 보장되지는 않는다; 이론적으로 가능한 것은 toRoman이 버그를 가지고 있어서 특별한 입력의 집합에 대하여 잘못된 로마 숫자를 산출할 수도 있다는 것이며, 그리고 fromRoman이 상호호출적인 버그를 가지고 있어서, toRoman이 부적절하게 생성한 로마 숫자의 집합과 정확하게 똑같이, 잘못된 정수 값을 산출할지도 모른다는 것이다. 여러분의 어플리케이션과 요구조건에 따라서, 이 가능성은 여러분을 괴롭힐 수도 있다; 만약 그렇다면, 그것이 여러분을 괴롭히지 않을 때까지 보다 더 포괄적인 테스트 사례를 작성하라.)

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

5.10. roman.py, 제 5 단계

이제 fromRoman이 좋은 입력으로는 적절하게 작동하므로, 그 퍼즐의 마지막 조각을 맞추어 넣어야 할 시간이다: 나쁜 입력으로 적절히 작동하도록 할 시간이다. 그것은 문자열을 보는 방법을 찾는 것을 의미하며 그리고 유효한 로마 숫자인지를 결정하는 방법을 찾는 것을 의미한다. 이것은 toRoman에서 수치 입력을 평가하는 것보다 본질적으로 더 어렵다. 그러나 우리에게는 마음대로 사용할 수 있는 강력한 도구가 있다: 정규 표현식이라는 도구가 있다.

만약 정규 표현식에 익숙하지 못하고 그리고 정규 표현식 101을 읽지 않았다면, 지금이 바로 좋은 기회가 될 것이다.

이 장의 서반부에서 본 바와 같이, 로마 숫자 하나를 구성하는 데에는 약간의 간단한 규칙이 있다. 첫 번째는 천단위 자리는, 만약 있다면, 일련의 M 문자에 의해서 표현된다.

Example 5.18. Checking for thousands

>>> import re
>>> pattern = '^M?M?M?$'       1
>>> re.search(pattern, 'M')    2
<SRE_Match object at 0106FB58>
>>> re.search(pattern, 'MM')   3
<SRE_Match object at 0106C290>
>>> re.search(pattern, 'MMM')  4
<SRE_Match object at 0106AA38>
>>> re.search(pattern, 'MMMM') 5
>>> re.search(pattern, '')     6
<SRE_Match object at 0106F4A8>
1 이 패턴은 세 개의 부분을 가진다:
  1. ^ - 문자열의 맨 앞에 따라오는 것에 일치한다. 이것이 지정되지 않으면, 그 패턴은 M 문자가 어디에 있든지 간에 일치할 것이고, 그것은 우리가 원하는 것이 아니다. M 문자가 존재하기만 한다면, 그 문자열의 맨 앞에 있다는 것을 확인하고 싶다.
  2. M? - 선택적으로 한개의 M 문자에 일치한다. 이것이 세 번 반복되므로, 우리는 한 줄에 0에서 3개까지의 M 문자가 있는 곳이면 어느 곳이나 일치시킨다.
  3. $ - 문자열의 맨 뒤에서 앞서 있는 것에 일치한다. 맨 앞의 ^ 문자와 결합되면, 그 패턴이 그 전체 문자열에서 M 문자의 앞이나 뒤에 아무런 문자없이 일치해야 한다는 것을 뜻한다.
2 re 모듈의 핵심은 search 함수이다. 이 함수는 정규 표현식(pattern)과 문자열('M')을 취해서 그 정규 표현식과 짝지어 보려고 시도한다. 만약 일치가 발견되면, search는 객체를 하나 반환한다; 그 객체는 일치를 자세히 기술해 주는 다양한 메쏘드를 가진다; 만약 일치가 발견되지 않으면, search는 파이썬의 무효 값인, None을 반환한다. (흥미있기는 하지만), search가 반환하는 그 객체에 관하여 세부적으로 들어가지는 않겠다. 왜냐하면 이 순간에 우리의 관심은 오로지 그 패턴이 일치하는가의 여부이기 때문이다. 그냥 search의 반환값을 살펴보면 그 여부를 알 수 있다. 'M'는 이 정규 표현식에 일치한다. 왜냐하면 그 첫 번째 선택적인 M이 일치하고 그리고 그 두번째 그리고 그 세번째의 선택적인 M 문자들은 무시되기 때문이다.
3 'MM'는 일치한다. 왜냐하면 그 첫번째 그리고 두 번째 선택적인 M 문자가 일치하고 그 세번째 M은 무시되기 때문이다.
4 'MMM'은 일치한다 왜냐하면 세 개의 모든 M 문자가 일치하기 때문이다.
5 'MMMM'은 일치하지 않는다. 세 개의 모든 M 문자는 일치하지만, 그 다음 그 정규 표현식은 ($ 문자 때문에) 그 문자열의 끝을 고집하고, 그 문자열은 (네번째의 M 때문에) 아직 끝나지 않는다. 그래서 searchNone을 돌려준다.
6 재미있게도, 빈 문자열 또한 이 정규 표현식에 일치한다. 왜냐하면 모든 M 문자들이 선택적이기 때문이다. 이 사실을 명심하자; 다음 섹션에서 더욱 중요하게 될 것이다.

백의 자리는 천의 자리보다 더욱 어렵다. 왜냐하면, 그 값에 따라서 상호 배타적으로 표현될 수 있는 여러 방법들이 있기 때문이다.

그래서 네 개의 가능한 패턴이 있다:

  1. CM
  2. CD
  3. 0 에서 3개 까지의 C 문자들 (만약 백의 자리가 0이면 0개)
  4. D, 다음에 0에서 3개 까지의 C 문자들이 따른다

마지막 두 개의 패턴은 조합될 수 있다:

Example 5.19. Checking for hundreds

>>> import re
>>> pattern = '^M?M?M?(CM|CD|D?C?C?C?)$' 1
>>> re.search(pattern, 'MCM')            2
<SRE_Match object at 01070390>
>>> re.search(pattern, 'MD')             3
<SRE_Match object at 01073A50>
>>> re.search(pattern, 'MMMCCC')         4
<SRE_Match object at 010748A8>
>>> re.search(pattern, 'MCMC')           5
>>> re.search(pattern, '')               6
<SRE_Match object at 01071D98>
1 이 패턴은 문자열의 맨 앞을 점검하고 (^), 그리고는 백의 자리 (M?M?M?)를 점검하는 이전의 우리의 패턴과 똑 같이 시작한다. 그리고 괄호안에 새로운 부분을 가지는데, 일단의 세개의 상호 배타적인 패턴이 수직 막대로 분리되어 정의된다: CM, CD, 그리고 D?C?C?C? (선택적인 D로서 다음에 0에서 3개까지의 선택적인 C 문자가 따른다). 정규 표현식 해석기(PARSER)는 이러한 패턴의 각각을 (왼쪽에서 오른쪽으로) 순서대로 점검하여, 일치되는 첫 번째 것을 취하고, 그 나머지는 무시한다.
2 'MCM'은 일치한다. 왜냐하면 첫 번째 M이 일치하고, 두번째와 세번째 M 문자들은 무시되고 다음 CM이 일치하기 때문이다 (그래서 CDD?C?C?C? 패턴은 고려조차 되지 않는다). MCM1900의 로마숫자 표현이다.
3 'MD'가 일치한다. 왜냐하면 첫번째 M이 일치하고, 두 번째와 세 번째 M 문자들은 무시되며, D?C?C?C? 패턴은 D에 일치하기 때문이다 (3개의 C 문자들은 선택적이고 무시된다). MD1500의 로마 숫자 표현이다.
4 'MMMCCC'가 일치한다. 왜냐하면 3개의 M 문자 모두가 일치하고, D?C?C?C? 패턴은 CCC에 일치하기 때문이다 (D는 선택적이어서 무시된다) MMMCCC3300의 로마 숫자 표현이다.
5 'MCMC'는 일치하지 않는다. 첫 번째 M은 일치하고, 두번째와 세번째 M 문자는 무시되며, 그리고 CM이 일치한다. 그러나 그러면 $는 일치하지 않는데. 왜냐하면 문자열의 마지막에 아직 도달하지 않았기 때문이다 (여전히 일치되지 않은 C 문자를 가진다). CD?C?C?C? 패턴의 부분으로 일치하지 않는다. 왜냐하면 상호 배제적인 CM 패턴이 이미 일치했기 때문이다.
6 재미있게도, 빈 문자열은 여전히 이 패턴에 일치한다. 왜냐하면 모든 M 문자들은 선택적이어서 무시되기 때문이다. 그리고 빈 문자열은 D?C?C?C? 패턴에 일치한다. 거기에서는 모든 문자들이 선택적이고 무시되기 때문이다.

휴우! 정규 표현식이 얼마나 빨리 골치아파질 수 있는지 알겠는가? 단지 백의 자리와 천의 자리만을 다루었을 뿐이다. (이 장의 후반부에서 정규 표현식을 작성하는 약간 다른 구문을 보게 될 것이다. 똑 같이 복잡하기는 하겠지만, 적어도 그것은 그 표현식의 다른 부분에 줄-안 문서화를 허용한다.) 다행스럽게도, 그 모든 것을 따라간다면, 십의 자리와 일의 자리는 쉽다. 왜냐하면 정확히 같은 패턴이기 때문이다.

Example 5.20. roman5.py

아직 그렇게 하지 않았다면, 이 예제와 함께 이 책에서 사용된 다른 예제들을 내려 받을 수 있다 (Windows, UNIX, Mac OS).

"""Convert to and from Roman numerals"""
import re

#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass

#Define digit mapping
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):
    """convert integer to Roman numeral"""
    if not (0 < n < 4000):
        raise OutOfRangeError, "number out of range (must be 1..3999)"
    if int(n) <> n:
        raise NotIntegerError, "decimals can not be converted"

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

#Define pattern to detect valid Roman numerals
romanNumeralPattern = '^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$' 1

def fromRoman(s):
    """convert Roman numeral to integer"""
    if not re.search(romanNumeralPattern, s):                                    2
        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
1 이것은 단지 우리가 본, 천의 자리와 백의 자리를 처리하던 그 패턴의 연속일 뿐이다. 십의 자리는 XC (90), XL (40)이거나, 또는 선택적인 L로서 다음에 0개에서 3개까지의 선택적인 X 문자가 따른다. 일의 자리는 IX (9), IV (4)이거나, 혹은 선택적인 V로서 다음에 0개에서 3개까지의 선택적인 I 문자가 따른다.
2 모든 논리를 정규 표현식에 코딩해 넣었으므로, 무효한 로마 숫자를 점검하는 코드는 사소한 일이 된다. re.search가 객체를 반환하면, 정규 표현식은 일치하고 입력은 유효한 것이다; 그렇지 않으면, 입력은 무효이다.

이 시점에서 거대하고 못생긴 정규 표현식이 모든 종류의 무효한 로마 숫자를 나포할 수 있다는 것을 의심해도 좋다. 그러나 내 말을 곧이 곧대로 믿지 마라. 그 결과를 살펴 보라:

Example 5.21. Output of romantest5.py against roman5.py


fromRoman should only accept uppercase input ... ok          1
toRoman should always return uppercase ... ok
fromRoman should fail with malformed antecedents ... ok      2
fromRoman should fail with repeated pairs of numerals ... ok 3
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                                                           4
1 정규 표현식에 관하여 언급하지 않은 한 가지는 기본적으로 정규 표현식은 대소문자에-민감 하다는 것이다. 정규 표현식 romanNumeralPattern이 대문자로 표현되었으므로, re.search 점검은 완전하게 대문자가 아닌 어떤 입력도 거부할 것이다. 그래서 대문자 입력 테스트는 통과한다.
2 더욱 중요한 것은 나쁜 입력 테스트는 통과한다는 것이다. 예를 들어, 그 못생긴 조상 테스트 사례는 MCMC와 같은 사례를 점검한다. 보시다시피, 이것은 정규 표현식에 일치하지 않는다. 그래서 fromRomanInvalidRomanNumeralError 예외를 일으킨다. 그것은 그 못생긴 조상 테스트 사례가 기다리던 것이고, 그래서 그 테스트는 통과한다.
3 사실 모든 나쁜 입력 테스트는 통과한다. 이 정규 표현식은 테스트 사례를 만들 때 생각할 수 있었던 모든 것을 나포한다 .
4 그리고 올해의 최종 영광은 단어 “OK”에게로 돌아 간다; 모든 테스트가 통과될 때 unittest 모듈에 의하여 출력된다.
Note
모든 테스트가 통과될 때, 코딩을 중지하라.

5.11. 버그를 처리하기

최선을 다하여 포괄적인 유닛 테스트를 작성하려고 하였음에도 불구하고, 버그는 일어난다. “bug”란 무엇인가? 버그는 여러분이 아직까지 작성한 적이 없는 테스트 사례이다.

Example 5.22. The bug

>>> import roman5
>>> roman5.fromRoman("") 1
0
1 기억하는가? 이전의 섹션에서 빈 문자열이 유효한 로마 숫자를 점검하기 위한 정규 표현식에 일치하는 것을 보았다. 자, 이것은 그 정규 표현식의 최종 버전에 대하여도 역시 참이라는 것이다. 그리고 그것은 버그이다; 유효한 로마 숫자를 표현하지 않는 다른 어떤 연속적인 문자들과 마찬가지로 똑 같이 InvalidRomanNumeralError 예외를 일으킬, 빈 문자열을 원한다.

버그를 재생산한 후에, 그리고 그것을 수정하기 전에, 실패를 하는 그리하여 그 버그를 보여 주는 테스트 사례를 작성하여야 한다.

Example 5.23. Testing for the bug (romantest61.py)

class FromRomanBadInput(unittest.TestCase):

    # previous test cases omitted for clarity (they haven't changed)

    def testBlank(self):
        """fromRoman should fail with blank string"""
        self.assertRaises(roman.InvalidRomanNumeralError, roman.fromRoman, "") 1
1 여기에 대단히 간단한 재료가 있다. fromRoman을 빈 문자열로 호출하고 그리고 그것이 InvalidRomanNumeralError 예외를 일으키는지 확인하라. 어려운 부분이라면 버그를 찾는 것이다; 이제 그것에 관하여 알고 있으므로, 그것을 위한 테스트는 쉬운 부분이다.

우리의 코드는 버그를 가지고 있으므로, 그리고 이제 이 버그를 테스트하는 테스트 사례를 가지고 있으므로 테스트 사례는 실패할 것이다:

Example 5.24. Output of romantest61.py against roman61.py

fromRoman should only accept uppercase input ... ok
toRoman should always return uppercase ... ok
fromRoman should fail with blank string ... FAIL
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

======================================================================
FAIL: fromRoman should fail with blank string
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage6\romantest61.py", line 137, in testBlank
    self.assertRaises(roman61.InvalidRomanNumeralError, roman61.fromRoman, "")
  File "c:\python21\lib\unittest.py", line 266, in failUnlessRaises
    raise self.failureException, excName
AssertionError: InvalidRomanNumeralError
----------------------------------------------------------------------
Ran 13 tests in 2.864s

FAILED (failures=1)

이제 버그를 고칠 수 있다.

Example 5.25. Fixing the bug (roman62.py)

def fromRoman(s):
    """convert Roman numeral to integer"""
    if not s: 1
        raise InvalidRomanNumeralError, 'Input can not be blank'
    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
1 오직 두 줄의 코드만 있으면 된다: 빈 문자열을 위한 명시적인 점검, 그리고 raise 서술문이 바로 그것이다.

Example 5.26. Output of romantest62.py against roman62.py

fromRoman should only accept uppercase input ... ok
toRoman should always return uppercase ... ok
fromRoman should fail with blank string ... ok 1
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 13 tests in 2.834s

OK 2
1 공백 문자열 테스트 사례는 이제 통과한다. 그래서 버그는 수정되었다.
2 다른 모든 테스트 사례들은 여전히 통과한다, 그것은 이 버그 수정이 다른 어떤 것도 망가뜨리지 않았음을 의미한다. 코딩을 중지하라.

이런 방식으로 코딩한다고 해서 버그를 고치는 것이 더 쉽게 되지는 않는다. (이와 같이) 간단한 버그들은 간단한 테스트 사례를 요구한다; 복잡한 버그들은 복잡한 테스트 사례를 요구할 것이다. 테스트-중심 환경에서는, 버그를 수정하는 것이 시간이 많이 걸리는 듯이 보일지도 모른다. 왜냐하면 (테스트 사례를 작성하기 위하여) 그 버그가 무엇인지 코드로 조목조목 나열해야만 하고 버그 자체를 수정해야하기 때문이다. 그리고 나서 만약 테스트 사례가 즉각 통과하지 않는다면, 그 수정이 잘못되었는지 아니면 그 테스트 사례 자체가 버그를 가지고 있는지 알아 보아야만 한다. 그렇지만, 결국 이 테스트하는 코드와 테스트 받는 코드 사이의 앞으로-그리고-뒤로 전략은 그 자체로 이익이 있다. 왜냐하면 버그들이 한 번에 정확하게 수정될 가능성이 더 많도록 하여 주기 때문이다. 또한, 모든 테스트 사례를 새로운 사례로, 쉽게 재-실행할 수 있으므로 새로운 코드를 수정할 때 예전의 코드를 망가트릴 가능성이 훨씬 더 적게된다. 오늘의 유닛 테스트는 내일의 회귀 테스트이다.

5.12. 요구조건 변경을 처리하기

고객을 땅 바닥에 쓰러뜨리고 가위 그리고 펄펄 끓는 왁스 같은 소름끼치게 무서운 것들로 고문하여 정확한 요구조건을 끌어내려는 최선의 노력에도 불구하고, 요구조건은 변경된다. 대부분의 고객들은 실제로 보기 전까지는 원하는 것을 알지 못한다. 그리고 알지라도, 원하는 것을 조목조목 정확하게 사용할 만한 정도로 나열하는 것에 익숙하지 못하다. 그리고 익숙하다고 할지라도, 다음 배포 때에는 어쨋든 더 많은 것을 원할 것이다. 그래서 요구조건이 변경됨에 맞추어 테스트 사례를 갱신할 수 있도록 준비하라.

예를 들어, 로마 숫자 변환 함수의 범위를 확장하기를 원한다고 가정해보라. 어떠한 문자도 세번을 초과하여 반복될 수 없다는 그 규칙을 기억하는가? 자, 그 로마인들은 기꺼이 그 규칙에 예외를 만들어서 4개의 M 문자를 일렬로 나열하여 4000을 표현하려고 하였다. 이렇게 변경을 하면, 변환가능한 숫자의 범위를 1..3999에서 1..4999으로 확장할 수 있을 것이다. 그러나 먼저 테스트 사례에 약간의 변경을 할 필요가 있다.

Example 5.27. Modifying test cases for new requirements (romantest71.py)

아직 그렇게 하지 못했다면, 이 예제와 이 책에서 사용된 다른 예제들을 내려 받을 수 있다 (Windows, UNIX, Mac OS).

import roman71
import unittest

class KnownValues(unittest.TestCase):
    knownValues = ( (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'),                                       1
                    (4500, 'MMMMD'),
                    (4888, 'MMMMDCCCLXXXVIII'),
                    (4999, 'MMMMCMXCIX'))

    def testToRomanKnownValues(self):
        """toRoman should give known result with known input"""
        for integer, numeral in self.knownValues:
            result = roman71.toRoman(integer)
            self.assertEqual(numeral, result)

    def testFromRomanKnownValues(self):
        """fromRoman should give known result with known input"""
        for integer, numeral in self.knownValues:
            result = roman71.fromRoman(numeral)
            self.assertEqual(integer, result)

class ToRomanBadInput(unittest.TestCase):
    def testTooLarge(self):
        """toRoman should fail with large input"""
        self.assertRaises(roman71.OutOfRangeError, roman71.toRoman, 5000) 2

    def testZero(self):
        """toRoman should fail with 0 input"""
        self.assertRaises(roman71.OutOfRangeError, roman71.toRoman, 0)

    def testNegative(self):
        """toRoman should fail with negative input"""
        self.assertRaises(roman71.OutOfRangeError, roman71.toRoman, -1)

    def testDecimal(self):
        """toRoman should fail with non-integer input"""
        self.assertRaises(roman71.NotIntegerError, roman71.toRoman, 0.5)

class FromRomanBadInput(unittest.TestCase):
    def testTooManyRepeatedNumerals(self):
        """fromRoman should fail with too many repeated numerals"""
        for s in ('MMMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):     3
            self.assertRaises(roman71.InvalidRomanNumeralError, roman71.fromRoman, s)

    def testRepeatedPairs(self):
        """fromRoman should fail with repeated pairs of numerals"""
        for s in ('CMCM', 'CDCD', 'XCXC', 'XLXL', 'IXIX', 'IVIV'):
            self.assertRaises(roman71.InvalidRomanNumeralError, roman71.fromRoman, s)

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

    def testBlank(self):
        """fromRoman should fail with blank string"""
        self.assertRaises(roman71.InvalidRomanNumeralError, roman71.fromRoman, "")

class SanityCheck(unittest.TestCase):
    def testSanity(self):
        """fromRoman(toRoman(n))==n for all n"""
        for integer in range(1, 5000):                                    4
            numeral = roman71.toRoman(integer)
            result = roman71.fromRoman(numeral)
            self.assertEqual(integer, result)

class CaseCheck(unittest.TestCase):
    def testToRomanCase(self):
        """toRoman should always return uppercase"""
        for integer in range(1, 5000):
            numeral = roman71.toRoman(integer)
            self.assertEqual(numeral, numeral.upper())

    def testFromRomanCase(self):
        """fromRoman should only accept uppercase input"""
        for integer in range(1, 5000):
            numeral = roman71.toRoman(integer)
            roman71.fromRoman(numeral.upper())
            self.assertRaises(roman71.InvalidRomanNumeralError,
                              roman71.fromRoman, numeral.lower())

if __name__ == "__main__":
    unittest.main()
1 존재하는 알려진 값들은 변경되지 않는다 (그것들은 여전히 테스트될 이유가 있는 값들이다). 그러나 4000 범위에서 약간 더 추가할 필요가 있다. 여기에 나는 (가장 짧은) 4000, (두번재로 짧은) 4500, (가장 긴) 4888, 그리고 (가장 큰) 4999를 포함 시켰다.
2 “큰값 입력”의 정의가 변경되었다. 이 테스트는 toRoman4000으로 호출하고 그리고 에러를 기다렸었다; 이제 4000-4999는 좋은 값들이므로, 이것을 5000까지 밀어 올릴 필요가 있다.
3 “너무 많은 반복된 숫자들”의 정의 또한 변경되었다. 이 테스트는 fromRoman'MMMM'으로 호출하고 에러를 기다렸었다; 이제 MMMM는 유효한 로마 숫자로 여겨지므로, 이것을 'MMMMM'까지 밀어 올릴 필요가 있다.
4 청결 점검과 대소문자 점검은, 1에서 3999까지의 범위안에 있는 모든 숫자들을 회돌이한다. 그 범위가 이제 확장되었으므로, 이러한 for 회돌이들은 또한 4999까지 올라가도록 갱신될 필요가 있다.

이제 우리의 테스트 사례는 새로운 요구조건으로 새로워졌다. 그러나 코드는 그렇지 않다. 그래서 테스트 사례중 약간은 실패하리라고 예상한다.

Example 5.28. Output of romantest71.py against roman71.py


fromRoman should only accept uppercase input ... ERROR        1
toRoman should always return uppercase ... ERROR
fromRoman should fail with blank string ... 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 ... ERROR 2
toRoman should give known result with known input ... ERROR   3
fromRoman(toRoman(n))==n for all n ... ERROR                  4
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
1 우리의 대소문자 점검은 이제 실패한다. 왜냐하면 1부터 4999까지 회돌이 하지만, toRoman은 오직 1에서 3999까지의 숫자만을 받아들이기 때문이다. 그래서 테스트 사례가 4000에 도달하자 마자 실패할 것이다.
2 fromRoman의 알려진 값 테스트는 'MMMM'에 도달하자 마자 실패할 것이다. 왜냐하면 fromRoman은 여전히 이것이 무효한 로마 숫자라고 간주하기 때문이다.
3 toRoman의 알려진 값 테스트는 4000에 도달하자 마자 실패할 것이다. 왜냐하면 toRoman은 여전히 이것이 범위를 벗어난 것이라고 생각하지 때문이다.
4 청결 점검 역시 4000에 도달하자 마자 실패할 것이다. 왜냐하면 toRoman은 여전히 이것이 범위를 벗어난 것이라고 생각하기 때문이다.

======================================================================
ERROR: fromRoman should only accept uppercase input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage7\romantest71.py", line 161, in testFromRomanCase
    numeral = roman71.toRoman(integer)
  File "roman71.py", line 28, in toRoman
    raise OutOfRangeError, "number out of range (must be 1..3999)"
OutOfRangeError: number out of range (must be 1..3999)
======================================================================
ERROR: toRoman should always return uppercase
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage7\romantest71.py", line 155, in testToRomanCase
    numeral = roman71.toRoman(integer)
  File "roman71.py", line 28, in toRoman
    raise OutOfRangeError, "number out of range (must be 1..3999)"
OutOfRangeError: number out of range (must be 1..3999)
======================================================================
ERROR: fromRoman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage7\romantest71.py", line 102, in testFromRomanKnownValues
    result = roman71.fromRoman(numeral)
  File "roman71.py", line 47, in fromRoman
    raise InvalidRomanNumeralError, 'Invalid Roman numeral: %s' % s
InvalidRomanNumeralError: Invalid Roman numeral: MMMM
======================================================================
ERROR: toRoman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage7\romantest71.py", line 96, in testToRomanKnownValues
    result = roman71.toRoman(integer)
  File "roman71.py", line 28, in toRoman
    raise OutOfRangeError, "number out of range (must be 1..3999)"
OutOfRangeError: number out of range (must be 1..3999)
======================================================================
ERROR: fromRoman(toRoman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\docbook\dip\py\roman\stage7\romantest71.py", line 147, in testSanity
    numeral = roman71.toRoman(integer)
  File "roman71.py", line 28, in toRoman
    raise OutOfRangeError, "number out of range (must be 1..3999)"
OutOfRangeError: number out of range (must be 1..3999)
----------------------------------------------------------------------
Ran 13 tests in 2.213s

FAILED (errors=5)

이제 새로운 요구조건때문에 실패하는 테스트 사례를 가지고 있으므로, 코드를 수정하여 테스트 사례와 조화시키는 것에 관하여 생각할 수 있다. (처음으로 유닛 테스트를 코딩하기 시작할 때 기억해야할 한 가지는 테스트 될 코드는 테스트 사례보다 절대로 “먼저(ahead)”가 아니라는 것이다. 코드가 여전히 뒤이기는 하지만, 여전히 뭔가를 작동시키고 있으며, 코드가 테스트 사례를 따라 잡는 순간, 코딩을 중지한다.)

Example 5.29. Coding the new requirements (roman72.py)

"""Convert to and from Roman numerals"""
import re

#Define exceptions
class RomanError(Exception): pass
class OutOfRangeError(RomanError): pass
class NotIntegerError(RomanError): pass
class InvalidRomanNumeralError(RomanError): pass

#Define digit mapping
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):
    """convert integer to Roman numeral"""
    if not (0 < n < 5000):                                                         1
        raise OutOfRangeError, "number out of range (must be 1..4999)"
    if int(n) <> n:
        raise NotIntegerError, "decimals can not be converted"

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

#Define pattern to detect valid Roman numerals
romanNumeralPattern = '^M?M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$' 2

def fromRoman(s):
    """convert Roman numeral to integer"""
    if not s:
        raise InvalidRomanNumeralError, 'Input can not be blank'
    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
1 toRoman은 범위 점검에서 오직 하나의 작은 변경만을 필요로 한다. 0 < n < 4000을 점검하던 곳에서, 이제 0 < n < 5000를 점검한다. 그리고 발생한(raise) 에러 메시지를 변경하여 새로운 접수가능한 범위를 반영한다 (1..3999대신에 1..4999를 반영함). 함수의 나머지 코드에 대해서는 아무런 변경도 할 필요가 없다; 이미 새로운 사례들을 처리한다. (자신이 발견한 각각의 천에 대하여 신나게 'M'을 추가한다; 4000이 되면, 'MMMM'을 뱉아 낼 것이다. 이전에 그것이 이렇게 하지 않은 유일한 이유는 범위 점검으로 그것을 명시적으로 중지하였기 때문이다.)
2 fromRoman에 전혀 변경을 가할 필요가 없다. 유일한 변경이라면 romanNumeralPattern에 대한 것인데; 자세히 살펴보면, 또 다른 선택적인 M을 정규 표현식의 첫 번째 섹션에 추가했다는 것을 알게 될 것이다. 이렇게 하면 3개 대신에 4개의 M 문자까지 허용할 것이다. 그것은 3999 대신에 4999과 동등한 로마 숫자를 허용할 것이라는 것을 뜻한다. 실제의 fromRoman 함수는 완전히 일반적이다; 단지 반복되는 로마 숫자들을 기다려서, 얼마나 많이 반복되는지 고민하지 않고서, 추가해 넣는다. 그것이 이전에는 'MMMM'을 처리하지 않은 유일한 이유는 정규 표현식 패턴 짝짓기로 명시적으로 그것을 중지하였기 때문이다.

이 두 개의 작은 변경이 필요한 모든 것이라는 것이 의심스러울 수 있다. 자, 내 말을 곧이 곧대로 믿지 마라; 여러분 스스로 알아보라:

Example 5.30. Output of romantest72.py against roman72.py

fromRoman should only accept uppercase input ... ok
toRoman should always return uppercase ... ok
fromRoman should fail with blank string ... 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 13 tests in 3.685s

OK 1
1 모든 테스트 사례는 통과한다. 코딩을 멈추어라.

포괄적인 유닛 테스트가 뜻하는 것은 “나를 믿으시오”라고 말하는 프로그래머에게 의존할 필요가 없다는 것이다.

5.13. 요소화

포괄적인 유닛 테스트에 관하여 가장 좋은 것은 모든 테스트 사례가 통과하였을 때 느끼는 감정이 아니며, 어떤 다른 사람이 그들의 코드를 망쳐놓았다고 여러분을 비난할 때에 그리고 실제로 그런 것이 아니라는 것을 여러분이 증명할 수 있었을 때 느끼는 그 감정도 아니다. 유닛 테스트에 관하여 가장 좋은 것은 냉혹하게 요소화할 수 있는 자유를 여러분에게 준다는 것이다.

요소화는 작동하고 있는 코드를 취해서, 그것을 더욱 잘 작동하도록 하는 처리과정이다. 보통, “더 좋은 (better)” 이라는 것은 “더 빠른 (faster)”을 의미하는데, 그렇지만 그것은 또한 “더 적은 메모리를 사용함”을 의미할 수도 있고, 또는 “더 적은 디스크 용량을 사용함”을 의미하거나, 혹은 단순하게 “더 우아하게”를 의미할 수 있다. 여러분에게, 프로젝트에, 개발환경에, 그것이 어떤 의미이든지 간에, 요소화는 모든 프로그램의 장기적인 건강에 중요하다.

여기에서 “더 좋은 (better)”는 “더 빠른 (faster)”을 의미한다. 구체적으로 말하면, fromRoman 함수는 실제로 필요한 속도 보다 느리다. 로마 숫자를 검증하기 위하여 사용한, 그 거대하고 성가신 정규 표현식 때문이다. 정규 표현식을 아예 없애 버리려고 노력하는 것은 아마도 가치가 없을 것 같다 (고단한 작업일 수도 있고, 성과가 더 빠르게 된다는 보장도 없다). 그러나 정규 표현식을 미리 편집함(precompile)으로써 함수의 속도를 높일 수 있다.

Example 5.31. Compiling regular expressions

>>> import re
>>> pattern = '^M?M?M?$'
>>> re.search(pattern, 'M')               1
<SRE_Match object at 01090490>
>>> compiledPattern = re.compile(pattern) 2
>>> compiledPattern
<SRE_Pattern object at 00F06E28>
>>> dir(compiledPattern)                  3
['findall', 'match', 'scanner', 'search', 'split', 'sub', 'subn']
>>> compiledPattern.search('M')           4
<SRE_Match object at 01104928>
1 이것은 이전에 본 구문이다: re.search는 정규 표현식을 하나의 문자열(pattern)로 그리고 그것과 일치시켜볼 문자열('M')을 취한다. 패턴이 일치하면, 그 함수는 일치 객체를 반환하는데 그 객체에게 물어보면 정확하게 무엇이 일치되었고 어떻게 일치되었는지 알아 볼 수 있다.
2 이것은 새로운 구문이다: re.compile은 정규 표현식을 문자열로 취하고 패턴 객체를 반환한다. 여기에는 일치하는 아무런 문자열도 없음을 주목하라. 정규 표현식을 편집하는 것은 그것을 ('M'과 같은) 어떤 특정한 문자열에 일치시키는 것과는 아무런 상관이 없다; 그것은 오로지 그 정규 표현식 자체에 관련된다.
3 re.compile로부터 반환된, 편집된 패턴 객체는 유용해-보이는 함수들을 가진다. (searchsub같이) re 모듈에서 직접적으로 사용가능한 것들이 포함된다.
4 편집된 패턴 객체의 search 함수를 문자열 'M'으로 호출하는 것은 re.search를 정규 표현식과 문자열 'M' 두 개를 가지고 호출하는 것과 같은 일을 달성한다. 오직 더욱 더 빠를 뿐이다. (사실, re.search 함수는 단순하게 그 정규 표현식을 편집하고 그리고 그 결과로 나온 패턴 객체의 search 메쏘드를 여러분 대신에 호출한다.)
Note
한 번이상 정규 표현식을 사용하려고 할 때마다, 그것을 편집하여 패턴 객체를 획득하도록 하여야만 하고, 그리고 패턴 객체에 있는 메쏘드들을 직접적으로 호출하여야 한다.

Example 5.32. Compiled regular expressions in roman81.py

아직 그렇게 하지 않았다면, 이 예제와 함께 이 책에서 사용된 다른 예제들을 내려 받을 수 없다 (Windows, UNIX, Mac OS).

# toRoman and rest of module omitted for clarity

romanNumeralPattern = \
    re.compile('^M?M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$') 1

def fromRoman(s):
    """convert Roman numeral to integer"""
    if not s:
        raise InvalidRomanNumeralError, 'Input can not be blank'
    if not romanNumeralPattern.search(s):                                    2
        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
1 아주 비슷해 보이지만, 사실은 많은 것이 변경되었다. romanNumeralPattern는 이제 문자열이 아니다; 그것은 re.compile으로부터 반환된 패턴 객체이다.
2 romanNumeralPattern에 있는 메쏘드를 직접적으로 호출할 수 있다는 것을 의미한다. re.search을 매번 호출하는 것보다 더욱, 더 빠르게 될 것이다. 정규 표현식은 그 모듈이 처음으로 반입될 때 한번 편집되어 romanNumeralPattern에 저장된다; 그리고는 fromRoman을 호출할 때마다, 물 밑에서 일어나는 어떤 간접적인 단계도 필요 없이, 즉시 입력 문자열을 정규 표현식에 맞추어 볼 수 있다.

그래서 우리의 정규 표현식을 편집하면 얼마나 더 빠른가? 여러분 스스로 알아보라:

Example 5.33. Output of romantest81.py against roman81.py

.............          1
----------------------------------------------------------------------
Ran 13 tests in 3.385s 2

OK                     3
1 여기의 건네주기에서 주의사항: 이 번에, 나는 그 유닛 테스트를 -v 선택사항 없이 실행했다, 그래서 각 테스트에 대한 완전한 문서화 문자열 (doc string) 대신에, 우리는 통과되는 각 테스트에 대하여 한 개의 점만을 얻는다. (만약 테스트가 실패하면, 우리는 F를 획득하게 될 것이다, 그리고 만약 그것이 에러를 가진다면, 우리는 E를 얻게 될 것이다. 우리는 각각의 실패와 에러에 대하여 여전히 완전한 역 추적을 유지할 것이며, 그래서 우리는 어떠한 문제도 추적해 내려갈 수가 있다.)
2 13개의 테스트를 정규 표현식을 미리 편집하지 않은 3.685 와 비교하여, 3.385초 만에 실행했다. 전반적으로 8%가 개선되었다. 그리고 기억할 것은 유닛 테스트 동안에 소비되는 대부분의 시간은 다른 일들을 하는 데에 소비된다는 것이다. (따로 나는 정규 표현식을 유닛 테스트의 나머지와 떼어서, 그 자체로 시간 측정을 하였다. 그리고 내가 발견한 것은 이 정규 표현식을 편집하면 search를 평균적으로 54%정도 속도 증가시킨다는 것이었다.) 이런 간단한 수정치고는 좋은 결과이다.
3 오! 경탄해 마지 않을 경우로, 정규 표현식을 미리 편집하더라도 어떤 것도 파괴되지 않았고, 그것을 증명해 보였다.

시도해보고 싶은 또 하나의 수행 최적화가 있다. 정규 표현식 구문의 복잡성을 고려하면, 같은 표현을 작성하는데 한 개 이상의 방법이 있다고 해도 놀랄만한 일은 아닐 것이다. comp.lang.python에서 이 모듈에 관한 약간의 토론후에, 어떤 사람의 제안을 받아들여서 나는 선택적인 반복된 문자열 대신에 {m,n} 구문을 사용하기를 시도하였다.

Example 5.34. roman82.py

아직 그렇게 하지 못했다면, 이 예제와 더불어 이 책에서 사용된 다른 예제들을 내려 받을 수 있다 (Windows, UNIX, Mac OS).

# rest of program omitted for clarity

#old version
#romanNumeralPattern = \
#   re.compile('^M?M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$')

#new version
romanNumeralPattern = \
    re.compile('^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$') 1
1 M?M?M?M?M{0,4}로 대치하였다. 둘 다 같은 것을 의미한다: “0에서 4개 까지의 M 문자를 일치시킨다”. 비슷하게, C?C?C?C{0,3}이 되었다 (“0 에서 4 개까지의 C 문자를 일치시킨다”) 그리고 등등 X 그리고 I도 마찬가지다.

정규 표현식의 이런 형태는 약간 더 짧다 (그렇지만 읽기는 쉽지 않다). 큰 문제는 얼마나 더 빠른가?

Example 5.35. Output of romantest82.py against roman82.py

.............
----------------------------------------------------------------------
Ran 13 tests in 3.315s 1

OK                     2
1 전반적으로 유닛 테스트는 이런 형태의 정규표현식으로 2% 더 빠르게 실행된다. 그 정도야 신나게 보이지는 않는다. 그러나 기억할 것은 search 함수는 전체 유닛 테스트에서 작은 부분이라는 것이다; 대부분의 시간은 다른 일들을 하는데 소비된다. (따로 나는 정규 표현식만을 시간-측정해 보았다. 그리고 search 함수가 이 구문으로 11% 빠르다는 것을 알았다.) 정규 표현식을 미리 편집해두고 이 새로운 구문을 사용하기 위하여 일부분을 재작성함으로써, 정규 표현식의 수행능력을 60% 이상 개선하였고, 그리고 전체 유닛 테스트의 전반적 성능을 10% 이상 개선하였다.
2 수행 성능 증가 보다도 더욱 중요한 것은 그 모듈이 여전히 완벽하게 작동한다는 사실이다. 이것이 바로 이전에 내가 이야기 하던 그 자유이다: 비틀어댈 자유, 변경하거나, 혹은 그 어떤 조각도 재작성할 자유 그리고 그 과정중에 다른 어떤 것도 흐트리지 않았다는 것을 확인하는 자유 말이다. 이것은 비틀기 그 자체 만을 위해서 끊임없이 코드를 비틀어대는 방종이 아니다; (“fromRoman을 더 빠르게 만드는”) 아주 특정한 목적을 가졌으며, 그리고 그 과정중에 새로운 버그를 이끌어 들이지는 않았는지에 관하여 한 점의 의심도 남기지 않고 목적을 달성할 수 있었다.

다른 하나만 더 비틀어 보고 싶다. 그리고 나서 요소화를 멈추고 이 모듈을 안정시킬 것이라고 약속한다. 반복해서 보았듯이, 정규 표현식은 대단히 난삽해질 수 있고 그리고 대단히 빠르게 판독 불가능해질 수 있다. 6개월이 지난후에 다시 이 모듈에 돌아와 보수하려고 노력하고 싶지는 않다. 물론, 테스트 사례는 통과한다. 그래서 그것이 작동한다는 것을 안다. 그러나 만약 내가 어떻게 그것이 작동하는지 이해하지 못한다면, 나는 새로운 사양을 추가할수도, 새로운 버그를 수정할수도, 그렇지 않으면 그것을 유지보수할 수도 없을 것이다. 문서화는 핵심적으로 중요하다. 그리고 파이썬은 정규 표현식을 자세하게 문서화하는 방법을 제공한다.

Example 5.36. roman83.py

아직 그렇게 하지 못했다면, 이 예제와 함께 이 책에서 사용된 다른 예제들을 내려 받을 수 있다 (Windows, UNIX, Mac OS).

# rest of program omitted for clarity

#old version
#romanNumeralPattern = \
#   re.compile('^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$')

#new version
romanNumeralPattern = re.compile('''
    ^                   # beginning of string
    M{0,4}              # thousands - 0 to 4 M's
    (CM|CD|D?C{0,3})    # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 C's),
                        #            or 500-800 (D, followed by 0 to 3 C's)
    (XC|XL|L?X{0,3})    # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 X's),
                        #        or 50-80 (L, followed by 0 to 3 X's)
    (IX|IV|V?I{0,3})    # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 I's),
                        #        or 5-8 (V, followed by 0 to 3 I's)
    $                   # end of string
    ''', re.VERBOSE) 1
1 re.compile 함수는 선택적인 두 번째 함수를 취할 수 있다. 이 함수는 하나 이상의 플래그의 집합으로 편집된 정규 표현식에 관한 다양한 선택사항을 제어한다. 여기에서는 re.VERBOSE 플래그를 지정하는데, 파이썬에게 정규 표현식 자체 안에 줄-안 주석이 있다는 것을 지시한다. 주석들과 그 주변의 공백은 정규 표현식의 일부분으로 간주되지 않는다; re.compile 함수는 표현식을 편집할 때 단순하게 그것들을 모두 벗겨내 버린다. 이 새로운 설명적인 “(verbose)” 버전은 예전 버전과 동일하지만, 분명히 더 읽기 쉽다.

Example 5.37. Output of romantest83.py against roman83.py

.............
----------------------------------------------------------------------
Ran 13 tests in 3.315s 1

OK                     2
1 이 새로운, 설명적인“ (verbose)” 버전은 이전 버전과 정확하게 똑 같은 속도로 실행된다. 사실, 편집된 패턴 객체는 동일하다. 왜냐하면 re.compile 함수는 추가한 모든 것들을 벗겨내 버리기 때문이다.
2 이 새로운, “설명적인 (verbose)” 버전은 예전 버전과 마찬가지로 같은 테스트 모두를 통과한다. 아무것도 변하지 않았다. 6개월 후에 이 모듈에 다시 돌아온 그 프로그래머가 그 함수가 어떻게 작동하는지 이해하려는 사투를 벌이지 않아도 된다는 점만 빼고는 말이다.

5.14. 요 약

유닛 테스트는 강력한 개념으로, 적절히 구현되면 유지 비용을 줄일 수도 있으며 모든 장기 프로젝트에서 유연성을 증가시킬 수 있다. 또한 중요한 것은 유닛 테스트는 만병통치약도 신비스러운 문제 해결사도 또는 은빛 총알도 아니라는 것을 이해 하는 것이다. 훌륭한 테스트 사례를 작성하는 것은 어렵다. 그리고 최신으로 유지하는 것은 엄한 규율을 필요로 한다 (특히나 손님이 치명적인 버그 수정을 맹열히 요구할 때 그렇다). 유닛 테스트는 기능적인 테스트, 통합 테스트, 그리고 사용자 승인 테스트를 포함한, 다른 형태의 테스트의 대치물이 아니다. 그러나 유닛 테스트는 그럴 듯하다. 그리고 잘 작동한다. 그리고 그것이 작동하는 것을 보게 되면 여러분은 그것 없이 도데체 어떻게 지냈는지 놀라게 될 것이다.

이 장은 많은 기본을 다루었다. 그리고 심지어 많은 부분은 파이썬-특정적이지 않았다. 많은 언어에는 나름대로 유닛 테스트 작업틀이 있다. 그 모두가 똑 같이 다음과 같은 기본적 개념들을 이해하기를 요구한다:

또한, 편안하게 다음의 파이썬-특정의 것들을 수행할 수 있어야 한다:

더 읽어야 할 것



[11] “나는 모든 것을 거부할 수 있다, 유혹만 빼면 말이다.” --Oscar Wilde


 제 4 장 HTML 처리 목 차 부 록 >>