Dive Into Python: call for comments (long)

Mark Pilgrim f8dy at yahoo.com
Mon Apr 23 15:59:01 EDT 2001


Chapter 4 of "Dive Into Python" ( http://diveintopython.org/ ) is almost
done, and I would like to devote Chapter 5 to PyUnit (
http://pyunit.sourceforge.net/ ), the new unit testing framework included in
Python 2.1.  This is a general call for help to anyone who has had
experience writing unit tests in other platforms (or worked with PyUnit in
particular), and anyone who has opinions about my Python coding style and
The Pythonic Way.

The example program for chapter 5 is a simple Python module to convert to
and from Roman numerals (inspired by a thread on c.l.py a few weeks ago) and
a corresponding set of test cases and suites.  Both are listed below.  My
questions:

1. Is this a good example module to teach unit testing?  Too simple?  Too
complex?  I thought it would be a good candidate because it (a) consists of
two functions which are reciprocals of each other (so a sanity check would
be to make sure that fromRoman(toRoman(n)) == n for all n in the domain),
(b) has a limited domain (integers from 1 to 3999), (c) runs quickly (I can
run through all possible inputs in < 2 seconds), and (d) requires no
external resources (database, external files, &c).

The format of the book is that each chapter teaches lessons that directly
relate to a specific example program, so if the example program is no good,
the whole chapter suffers.

2. Assuming this is a reasonable module to test, is the module well-written?
Feel free to suggest alternative, more Pythonic ways of doing things.  It
uses rich data structures (a tuple of tuples of string/integer/compiled RE)
rather than rich code, custom exceptions raised signal errors,
multi-variable assignment in for loops, and module-level attributes like
__author__ and __version__ that show up in PyDoc.  (Many of you will be
happy to hear that it uses neither list comprehensions nor "".join().)

In each of the first 4 chapters, I have written extensively on code which I
then changed based on feedback from more intelligent readers than myself,
who knew about built-in functions I should have used (hi Alex), or more
efficient patterns (hi Sean), or obscure use cases (hi Fred), &c.  I'd like
to shake these bugs out before I write the chapter; like bugs in code, bugs
in books are easier to fix during design than after deployment.

3. Assuming the module is well-written, are the test cases any good?  I
found a good article on JavaWorld.com (
http://www.javaworld.com/javaworld/jw-12-2000/jw-1221-junit.html ) which
listed some first principles for writing unit tests in JUnit, much of which
applies to PyUnit as well.  The test cases I've included test (a) reciprocal
sanity (fromRoman(toRoman(n)) == n for all reasonable n), (b) a 10% sampling
of inputs to test against a set of (externally verified) known outputs, (c)
a set of bad inputs for toRoman() (negative numbers, numbers too large,
non-integers) and fromRoman() (a variety of invalid Roman numerals).  Is
there some basic class of tests that I'm missing?  Are there other specific
cases I should be testing?

3. Assuming the tests are valid and reasonable, are the test cases and test
suites well-organized?  Some "related" tests are grouped together in the
same class; for instance, all 4 "bad input" tests for toRoman() are in the
ToRomanBadInputs class (descended from unittest.TestCase).  Should these be
broken out into separate classes, or should tests that are currently
separated be grouped into one class?  Much of this is PyUnit-specific, and I
suspect it is also based on personal taste, but since I'm new to PyUnit, I
have no personal taste to guide me.  (Based on my other code, some would
argue I have no taste at all.)

4. Assuming the test cases are well-organized, am I using the PyUnit
framework the way it was meant to be used?  Am I making things too difficult
for myself in any way?  There's nothing worse than basing a teaching lesson
on unnecessarily complex code; it confuses the readers who don't know any
better and annoys the readers who do.

Thank you all in advance for any assistance.

-M

#--start of roman.py
"""Convert to and from Roman numerals"""

__author__ = "Mark Pilgrim (f8dy at diveintopython.org)"
__version__ = "1.1"
__date__ = "21 April 2001"

import re

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

#Define digit mapping and patterns
romanNumeralDigits = ( \
    ('M',  1000, re.compile('^MM?M?')),
    ('CM', 900,  re.compile('^CM')),
    ('D',  500,  re.compile('^D')),
    ('CD', 400,  re.compile('^CD')),
    ('C',  100,  re.compile('^CC?C?')),
    ('XC', 90,   re.compile('^XC')),
    ('L',  50,   re.compile('^L')),
    ('XL', 40,   re.compile('^XL')),
    ('X',  10,   re.compile('^XX?X?')),
    ('IX', 9,    re.compile('^IX')),
    ('V',  5,    re.compile('^V')),
    ('IV', 4,    re.compile('^IV')),
    ('I',  1,    re.compile('^II?I?')))

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

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 roman, integer, pattern in romanNumeralDigits:
        while n >= integer:
            result += roman
            n -= integer
    return result

def fromRoman(s):
    """convert Roman numeral to integer"""
    s = s.upper()
    if not romanNumeralPattern.search(s):
        raise InvalidRomanNumeralError, 'Invalid Roman numeral: %s' % s

    result = 0
    for roman, integer, pattern in romanNumeralDigits:
        match = pattern.search(s)
        if match:
            lenmatch = len(match.group())
            result += integer * lenmatch / len(roman)
            s = s[lenmatch:]
    return result
#--end of roman.py

#--start of romantest.py
#!/usr/bin/python
"""Unit test for roman.py"""

__author__ = "Mark Pilgrim (f8dy at diveintopython.org)"
__version__ = "1.0"
__date__ = "21 April 2001"

import roman
import unittest

#-----------------
#Define test cases
#-----------------

class SanityCheck(unittest.TestCase):
    """test that fromRoman(toRoman(n))==n for all n"""

    def testSanity(self):
        """test that fromRoman(toRoman(n))==n for all n"""
        for n in range(1, 4000):
            s = roman.toRoman(n)
            fullcircle = roman.fromRoman(s)
            self.assertEqual(n, fullcircle, \
                "%(n)s --> %(s)s --> %(fullcircle)s" % locals())

class KnownValuesBase(unittest.TestCase):
    """base class to set up tests that require a set of known values"""

    def setUp(self):
        """define a set of known values"""
        unittest.TestCase.setUp(self)
        self.knownValues = ((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'),
                            (3940, 'MMMCMXL'))

    def tearDown(self):
        """stub for cleaning up after test cases"""
        pass

class ToRomanKnownValues(KnownValuesBase):
    """test toRoman with known values"""

    def testKnownValues(self):
        """test toRoman with known values"""
        for n, s in self.knownValues:
            result = roman.toRoman(n)
            self.assertEqual(s, result, \
                "toRoman(%(n)s) = %(result)s, should be %(s)s" % locals())

class FromRomanKnownValues(KnownValuesBase):
    """test fromRoman with known values"""

    def testKnownValues(self):
        """test fromRoman with known values"""
        for n, s in self.knownValues:
            result = roman.fromRoman(s)
            self.assertEqual(n, result, \
                "fromRoman('%(s)s') = %(result)s, should be %(n)s" %
locals())

class ToRomanBadInput(unittest.TestCase):
    """test toRoman with a variety of bad inputs"""

    def testTooLarge(self):
        """test toRoman with large input"""
        self.assertRaises(roman.OutOfRangeError, roman.toRoman, 4000)

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

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

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

class FromRomanBadInput(unittest.TestCase):
    """test fromRoman with a variety of bad inputs"""

    def testTooManyRepeatedNumerals(self):
        """test fromRoman 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):
        """test fromRoman with repeated pairs of numerals"""
        for s in ('CMCM', 'CDCD', 'XCXC', 'XLXL', 'IXIX', 'IVIV'):
            self.assertRaises(roman.InvalidRomanNumeralError,
roman.fromRoman, s)

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

#------------------
#Define test suites
#------------------

def toRoman():
    """make test suite for all toRoman test cases"""
    return unittest.TestSuite( \
        map(unittest.makeSuite, (ToRomanKnownValues, ToRomanBadInput)))

def fromRoman():
    """make test suite for all fromRoman test cases"""
    return unittest.TestSuite( \
        map(unittest.makeSuite, (FromRomanKnownValues, FromRomanBadInput)))

def knownValues():
    """make test suite for all known values test cases"""
    return unittest.TestSuite( \
        map(unittest.makeSuite, (ToRomanKnownValues, FromRomanKnownValues)))

def badInput():
    """make test suite for all bad input test cases"""
    return unittest.TestSuite( \
        map(unittest.makeSuite, (ToRomanBadInput, FromRomanBadInput)))

def all():
    """make full test suite for all of roman.py"""
    sanity = unittest.makeSuite(SanityCheck)
    return unittest.TestSuite((sanity, toRoman(), fromRoman()))

#-------------------
#Define test program
#-------------------

class TestProgram(unittest.TestProgram):
    USAGE = """\
Usage:
romantest.py [options] [test]

Options:
  -h, --help        print this usage summary
  -v, --verbose     print test case descriptions while testing
  -q, --quiet       print nothing while testing

  testname in ('all', 'toRoman', 'fromRoman',
               'knownValues', 'badInput', 'SanityCheck',
               'ToRomanKnownValues', 'FromRomanKnownValues',
               'ToRomanBadInput', 'FromRomanBadInput')
  if none specified, defaults to 'all'

Examples:
  %(progName)s                    - run all tests
  %(progName)s -v toRoman         - run toRoman test suite with verbose
output
  %(progName)s -q SanityCheck     - run sanity check with minimal output
"""

if __name__ == "__main__":
    TestProgram(defaultTest="all")
#--end of romantest.py






More information about the Python-list mailing list