Zanurkuj w Pythonie/Obsługa zmieniających się wymagań

Choćbyśmy próbowali przyszpilić swoich klientów do ziemi w celu uzyskania od nich dokładnych wymagań, używając tak przerażających narzędzi tortur, jak nożyce czy gorący wosk, to i tak te wymagania się zmienią. Większość klientów nie wie, czego chce, dopóki tego nie zobaczy, a nawet jak już zobaczy, to nie jest w stanie wyartykułować tego wystarczająco precyzyjnie, aby było to użyteczne. Nawet, gdyby im się to udało, to zapewne i tak w kolejnym wydaniu będą chcieli czegoś więcej. Tak więc lepiej bądźmy przygotowani na aktualizowanie swoich przypadków testowych w miarę jak zmieniają się wymagania.

Przypuśćmy, na przykład, że chcieliśmy rozszerzyć zakres funkcji konwertujących liczby rzymskie. Czy pamiętacie regułę, która mówi, że żadna litera nie może być powtórzona więcej niż trzy razy? Otóż Rzymianie chcieli uczynić wyjątek od tej reguły tak, aby móc reprezentować wartość 4000 stawiając obok siebie cztery litery M. Jeśli wprowadzimy tę zmianę, będziemy mogli rozszerzyć zakres liczb możliwych do przekształcenia na liczbę rzymską z 1..3999 do 1..4999. Najpierw jednak musimy wprowadzić kilka zmian do przypadków testowych.

Przykład 15.6. Zmiana przypadków testowych przy nowych wymaganiach (romantest71.py)

Plik jest dostępny w katalogu py/roman/stage7/ znajdującym się w katalogu examples.

Jeśli jeszcze tego nie zrobiliście, ściągnijcie ten oraz inne przykłady (http://diveintopython.org/download/diveintopython-examples-5.4.zip) używane w tej książce.

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 testNonInteger(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. Istniejące wartości nie zmieniają się (to wciąż rozsądne wartości do przetestowania), jednak musimy dodać kilka do poszerzonego zakresu. Powyżej dodałem 4000 (najkrótszy napis), 4500 (drugi najkrótszy), 4888 (najdłuższy) oraz 4999 (największy co do wartości).
  2. Zmieniła się definicja "dużych danych wejściowych". Ten test miał nie przechodzić dla wartości 4000 i zgłaszać w takiej sytuacji błąd; teraz wartości z przedziału 4000-4999 są poprawne, a jako pierwszą niepoprawną wartość należy przyjąć 5000.
  3. Zmieniła się definicja "zbyt wielu powtórzonych cyfr rzymskich". Ten test wywoływał fromRoman z wartością 'MMMM' i spodziewał się błędu. Obecnie MMMM jest poprawną liczbą rzymską, a więc należy zmienić niepoprawną wartość na 'MMMMM'.
  4. Testy prostego sprawdzenia i sprawdzenia wielkości liter iterują po wartościach z przedziału od 1 do 3999. Ze względu na poszerzenie tego przedziału rozszerzamy też pętle w testach tak, aby uwzględniały wartości do 4999.

Teraz przypadki testowe odzwierciedlają już nowe wymagania, jednak nie uwzględnia ich jeszcze kod, a więc można się spodziewać, że pewne testy nie przejdą:

Przykład 15.7. Wyjście programu romantest71.py testującego 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. Test sprawdzający wielkość liter nie przechodzi, ponieważ pętla uwzględnia wartości od 1 do 4999, natomiast toRoman akceptuje wartości z przedziału od 1 do 3999. Jak tylko licznik pętli osiągnie wartość 4000, test nie przechodzi.
  2. Test poprawnych wartości używający toRoman nie przechodzi dla napisu 'MMMM', ponieważ toRoman wciąż sądzi, że jest to wartość niepoprawna.
  3. Test poprawnych wartości używający toRoman nie przechodzi dla wartości 4000, ponieważ toRoman wciąż sądzi, że jest to wartość spoza zakresu.
  4. Test poprawności również nie przechodzi dla wartości 4000, ponieważ toRoman wciąż sądzi, że jest to wartość spoza zakresu.
 ======================================================================
 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)

Kiedy już mamy przypadki testowe, które ze względu na nowe wymagania przestały przechodzić, możemy myśleć o poprawieniu kodu tak, aby był zgodny z testami (kiedy zaczyna się pisać testy jednostkowe, należy się przyzwyczaić do jednej rzeczy: testowany kod nigdy nie "wyprzedza" przypadków testowych. Zdarza się, że kod "nie nadąża", co oznacza, że wciąż jest coś do zrobienia, przy czym jak tylko kod "dogoni" testy, zadanie jest już wykonane).

Przykład 15.8. Implementacja nowych wymagań (roman72.py)

Plik jest umieszczony w katalogu py/roman/stage7/ znajdującym się w katalogu examples.

"""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, "non-integers 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 wymaga jednej małej zmiany w sprawdzeniu zakresu. Tam, gdzie było sprawdzenie 0 < n < 4000, powinno być 0 < n < 5000. Należy też zmienić treść komunikatu błędu, tak, aby odzwierciedlał on nowy, akceptowalny zakres wartości (1..4999 zamiast 1..3999). Nie trzeba dokonywać żadnych innych zmian w kodzie funkcji, który już obsługuje nowe wymaganie. (Kod dodaje 'M' dla każdego pełnego tysiąca; gdy na wejściu jest wartość 4000, otrzymamy liczbę rzymską 'MMMM'. Jedynym powodem tego, że funkcja nie działała tak wcześniej było jej jawne zakończenie przy wartości przekraczającej dopuszczalny zakres.
  2. Nie trzeba w ogóle zmieniać fromRoman. Jedyna wymagana zmiana dotyczy wzorca romanNumeralPattern; po bliższym przyjrzeniu się widać, że wystarczyło dodać jeszcze jedno opcjonalne 'M' w pierwszej części wyrażenia regularnego. Pozwoli to na dopasowanie maksymalnie czterech znaków M zamiast trzech, a więc uwzględni wartości rzymskie z przedziału do 4999 zamiast do 3999. Obecna implementacja fromRoman jest bardzo ogólna: wyszukuje ona powtarzające się znaki w zapisie rzymskim, a następnie sumuje ich odpowiednie wartości, nie przejmując się liczbą ich wystąpień. Funkcja ta nie obsługiwała wcześniej napisu 'MMMM' wyłącznie dlatego, że napis taki nie zostałby wcześniej dopasowany do wyrażenia regularnego.

Możecie być odrobinę sceptyczni wobec stwierdzenia, że te dwie małe zmiany to wszystko, czego potrzebujemy. Nie wierzcie mi na słowo, po prostu to sprawdźcie:

Przykład 15.9. Wyjście programu romantest72.py testującego 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. Wszystkie testy przechodzą. Kończymy kodowanie.

Pełne testowanie jednostkowe oznacza, że nigdy nie trzeba polegać na słowach programisty mówiącego: "Zaufaj mi".