7.12. Gestire il cambiamento di requisiti

A dispetto dei vostri migliori sforzi di bloccare i vostri clienti in un angolo per tirargli fuori gli esatti requisiti del software da sviluppare, sotto la minaccia di sottoporli ad orribili operazioni con forbici e cera bollente, i requisiti cambieranno lo stesso. Molti clienti non sanno cosa vogliono fino a quando non lo vedono, ed anche allora, non sono così bravi a dettagliare esattamente il progetto, a tal punto da fornire indicazioni che possano risultare utili. Ed anche nel caso lo siano, chiederanno sicuramente di più per la prossima versione del software. Siate quindi preparati ad aggiornare i vostri test quando i requisiti cambieranno.

Supponiamo per esempio di voler espandere l'intervallo delle nostre funzioni di conversione dei numeri romani. Ricordate la regola che diceva che nessun carattere dovrebbe essere ripetuto più di tre volte? Bene, i Romani erano disposti a fare un'eccezione a questa regola, rappresentando 4000 con quattro caratteri M di seguito. Se facciamo questo cambiamento, saremo capaci di espandere il nostro intervallo di numeri convertibili da 1..3999 a 1..4999. Ma prima, abbiamo bisogno di fare qualche cambiamento al codice dei nostri test.

Esempio 7.27. Modificare i test per tener conto di nuovi requisiti (romantest71.py)

Se non lo avete ancora fatto, potete scaricare questo ed altri esempi usati in questo libro.


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 I valori noti precedenti non cambiano (costituiscono sempre dei valori significativi da verificare), ma abbiamo bisogno di aggiungerne qualcun altro nella zona dei quattromila. Qui abbiamo incluso 4000 (il più corto), 4500 (il secondo più corto), 4888 (il più lungo) e 4999 (il più grande).
2 La definizione di “input troppo grande” è cambiata. Questo test chiamava la funzione toRoman con il valore 4000 e si aspettava un errore; ora che i valori da 4000 a 4999 sono validi, dobbiamo innalzare il valore del test a 5000.
3 La definizione di “troppe cifre romane ripetute di seguito” è anch'essa cambiata. Questo test chiamava fromRoman con 'MMMM' e si aspettava un errore; ora che 'MMMM' è considerato un numero romano valido, dobbiamo portare il valore di test a 'MMMM'.
4 Il test di consistenza ed il test sulle maiuscole/minuscole iterano su ogni numero nell'intervallo, da 1 a 3999. Dato che l'intervallo si è espanso, questi cicli for hanno bisogno di essere aggiornati per arrivare fino a 4999.

Ora i nostri test sono aggiornati in accordo con i nuovi requisiti, ma non il nostro codice, per cui c'è da aspettarsi che alcuni dei test falliscano.

Esempio 7.28. Output di romantest71.py a fronte di 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 Il nostro controllo sulle maiuscole/minuscole ora fallisce perché si cicla da 1 a 4999, ma la funzione toRoman accetta solo numeri da 1 a 3999, quindi va in errore non appena il test raggiunge 4000.
2 Il test sui valori noti per fromRoman fallisce anch'esso non appena si raggiunge 'MMMM', perché fromRoman lo considera ancora un numero romano non valido.
3 Il test sui valori noti per toRoman fallisce non appena si raggiunge 4000, perché toRoman lo considera ancora fuori dall'intervallo dei valori validi.
4 Il test di consistenza fallisce anch'esso quando si arriva a 4000, perché toRoman lo considera ancora fuori dall'intervallo.

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

Ora che abbiamo dei test che falliscono perché i nuovi requisiti non sono ancora stati implementati, possiamo cominciare a pensare di modificare il codice per allinearlo ai nuovi test. (Una cosa a cui occore un po' di tempo per abituarsi, quando si programma usando i test delle unità di codice, è il fatto che il codice sotto verifica non è mai più “avanti” del codice di test. Di solito rimane indietro e ciò significa che avete ancora del lavoro da fare. Non appena si mette in pari, si smette di programmare.)

Esempio 7.29. Trasformare in codice i nuovi requisiti (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 ha solo bisogno di un piccolo cambio, nel controllo dell'intervallo. Dove prima controllavamo che 0 < n < 4000 ora controlliamo che 0 < n < 5000. Inoltre cambiamo il messaggio di errore dell'eccezione sollevata per indicare il nuovo intervallo accettabile (1..4999 invece di 1..3999). Non abbiamo bisogno di fare alcun cambio per il resto della funzione; i nuovi casi vengono già gestiti correttamente. La funzione aggiunge tranquillamente una 'M' per ogni migliaia; datole in input 4000, tira fuori 'MMMM'. L'unica ragione per cui prima non si comportava così era dovuta al fatto che veniva bloccata esplicitamente dal controllo di intervallo.
2 Non abbiamo bisogno di fare alcun cambio nella funzione fromRoman. L'unica modifica riguarda la variabile romanNumeralPattern; se la osservate da vicino, vi accorgerete che abbiamo aggiunto un ulteriore M opzionale nella prima parte della espressione regolare. Questo consentirà fino a quattro caratteri M invece che fino a tre; di conseguenza verranno accettati i numeri romani fino all'equivalente di 4999 invece che solo fino all'equivalente di 3999. La funzione fromRoman in sé è assolutamente generica; semplicemente, cerca cifre di numeri romani ripetute e ne somma i rispettivi valori senza preoccuparsi di quante volte essi si ripetano. L'unica ragione per cui la funzione non trattava correttamente 'MMMM' in precedenza era perché ne era esplicitamente impedita dal confronto con l'espressione regolare.

A questo punto potreste essere scettici sul fatto che due piccole modifiche siano tutto ciò di cui abbiamo bisogno. Ehi, non dovete fidarvi della mia parola. Osservate da soli:

Esempio 7.30. Output di romantest72.py a fronte di 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 Tutti i test hanno avuto successo. Smettete di scrivere codice.

Progettare test esaustivi significa non dover mai dipendere da un programmatore che dice “Fidati di me!”.