15.2. Tratamiento del cambio de requisitos

A pesar de nuestros mejores esfuerzos para agarrar a nuestros clientes y extraerles requisitos exactos usando el dolor de cosas horribles que impliquen tijeras y cera caliente, los requisitos cambiarán. La mayoría de los clientes no sabe lo que quiere hasta que lo ve, e incluso si lo saben, no son buenos explicando qué quieren de forma lo suficientemente precisa como para que sea útil. Así que prepárese para actualizar los casos de prueba según cambien los requisitos.

Suponga, por ejemplo, que quería expandir el rango de las funciones de conversión de números romanos. ¿Recuerda la regla que decía que ningún carácter podía repetirse más de tres veces? Bien, los romanos quisieron hacer una excepción a esta regla teniendo 4 caracteres M seguidos para representar 4000. Si hace este cambio, será capaz de expandir el rango de los números convertibles de 1..3999 a 1..4999. Pero primero necesitará hacer algunos cambios en los casos de prueba.

Ejemplo 15.6. Modificación de los casos de prueba por nuevos requisitos (romantest71.py)

Este fichero está disponible en py/roman/stage7/ dentro del directorio de ejemplos.

Si aún no lo ha hecho, puede descargar éste ejemplo y otros usados en este 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 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 Los valores conocidos existentes no cambian (siguen siendo valores razonables que probar), pero necesitamos añadir unos cuántos más en el rango de 4000. He incluido 4000 (el más corto), 4500 (el segundo más corto), 4888 (el más largo) y 4999 (el más grande).
2 La definición de “entrada grande” ha cambiado. Esta prueba llamaba a toRoman con 4000 y esperaba un error; ahora que 4000-4999 son valores buenos debemos subir esto a 5000.
3 La definición de “demasiados números repetidos” también ha cambiado. Esta prueba llamaba a fromRoman con 'MMMM' y esperaba un error; ahora que MMMM se considera un número romano válido, necesitamos subir esto a 'MMMMM'.
4 La prueba de cordura y la de mayúsculas iteran sobre cada número del rango, desde 1 hasta 3999. Como el rango se ha expandido estos bucles for necesitan actualizarse igualmente para llegar hasta 4999.

Ahora nuestros casos de prueba están actualizados a los nuevos requisitos, pero nuestro código no, así que espere que varios de ellos fallen.

Ejemplo 15.7. Salida de romantest71.py frente a 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 Nuestra comprobación de mayúsculas falla ahora porque itera de 1 a 4999, pero toRoman sólo acepta números de 1 a 3999, así que fallará en cuanto el caso de prueba llegue a 4000.
2 La prueba de valores conocidos de fromRoman fallará en cuanto llegue a 'MMMM', porque fromRoman sigue pensando que esto no es un número romano válido.
3 La prueba de valores conocidos de toRoman fallará en cuanto se tope con 4000, porque toRoman sigue pensando que está fuera de rango.
4 La prueba de cordura también fallará en cuanto llegue a 4000, porque toRoman piensa que está fuera de rango.

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

Ahora que tenemos casos de prueba que fallan debido a los nuevos requisitos, debemos pensar en arreglar el código para que quede de acuerdo a estos casos de prueba. (Una cosa a la que lleva tiempo acostumbrarse cuando se empieza a usar pruebas unitarias es que el código probado nunca está “por delante” de los casos de prueba. Mientras esté por detrás sigue habiendo trabajo que hacer, y en cuanto alcanzan a los casos de uso, se deja de programar).

Ejemplo 15.8. Programación de los nuevos requisitos (roman72.py)

Este fichero está disponible en py/roman/stage7/ dentro del directorio de ejemplos.

"""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 sólo necesita un pequeño cambio en la comprobación de rango. Donde solíamos comprobar 0 < n < 4000 ahora comprobamos 0 < n < 5000. Y cambiamos el mensaje de error que lanzamos con raise para reflejar el nuevo rango aceptable (1..4999 en lugar de 1..3999). No necesita hacer ningún cambio al resto de la función; ya considera los nuevos casos. (Añade felizmente 'M' por cada millar que encuentra; dado 4000, devolverá 'MMMM'. La única razón por la que no lo hacía antes es porque le detuvimos explícitamente con la comprobación de rango).
2 En realidad no necesita hacer cambios a fromRoman. El único cambio es para romanNumeralPattern; si observa atentamente advertirá que hemos añadido otra M opcional en la primera sección de la expresión regular. Esto permitirá 4 caracteres M opcionales en lugar de 3, lo que significa que permitiremos los equivalentes en romanos de 4999 en lugar de 3999. La función fromRoman en sí es completamente general; simplemente busca números romanos repetidos y los suma, sin importar cuántas veces aparecen. La única razón de que no tratara 'MMMM' antes es que se lo prohibimos explícitamente con el patrón de la expresión regular.

Puede que no se crea que estos dos pequeños cambios sean todo lo que necesitamos. ¡Hey!, no tiene por qué creer en mi palabra; véalo usted mismo:

Ejemplo 15.9. Salida de romantest72.py frente a 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 Todos los casos de prueba pasan. Deje de programar.

Tener pruebas unitarias exhaustivas significa no tener nunca que depender de un programador que diga “Confíe en mí.