While developing code, new corner case inputs are often discovered. Being able to capture these inputs in an iterable array makes it easy to add related test methods.
In this recipe, we will look at a different way to test corner cases.
recipe10.py
in which to put all our code for this recipe.class RomanNumeralConverter(object): def __init__(self): self.digit_map = {"M":1000, "D":500, "C":100, "L":50, "X":10, "V":5, "I":1} def convert_to_decimal(self, roman_numeral): val = 0 for char in roman_numeral: val += self.digit_map[char] if val > 4000: raise Exception( "We don't handle values over 4000") return val def convert_to_roman(self, decimal): if decimal > 4000: raise Exception( "We don't handle values over 4000") val = "" mappers = [(1000,"M"), (500,"D"), (100,"C"), (50,"L"), (10,"X"), (5,"V"), (1,"I")] for (mapper_dec, mapper_rom) in mappers: while decimal >= mapper_dec: val += mapper_rom decimal -= mapper_dec return val
import unittest class RomanNumeralTest(unittest.TestCase): def setUp(self): self.cvt = RomanNumeralConverter()
def test_edges(self): r = self.cvt.convert_to_roman d = self.cvt.convert_to_decimal edges = [("equals", r, "I", 1), ("equals", r, "", 0), ("equals", r, "", -1), ("equals", r, "MMMM", 4000), ("raises", r, Exception, 4001), ("equals", d, 1, "I"), ("equals", d, 0, ""), ("equals", d, 4000, "MMMM"), ("raises", d, Exception, "MMMMI") ] [self.checkout_edge(edge) for edge in edges]
def test_tiers(self): r = self.cvt.convert_to_roman edges = [("equals", r, "V", 5), ("equals", r, "VIIII", 9), ("equals", r, "X", 10), ("equals", r, "XI", 11), ("equals", r, "XXXXVIIII", 49), ("equals", r, "L", 50), ("equals", r, "LI", 51), ("equals", r, "LXXXXVIIII", 99), ("equals", r, "C", 100), ("equals", r, "CI", 101), ("equals", r, "CCCCLXXXXVIIII", 499), ("equals", r, "D", 500), ("equals", r, "DI", 501), ("equals", r, "M", 1000) ] [self.checkout_edge(edge) for edge in edges]
def test_bad_inputs(self): r = self.cvt.convert_to_roman d = self.cvt.convert_to_decimal edges = [("equals", r, "", None), ("equals", r, "I", 1.2), ("raises", d, TypeError, None), ("raises", d, TypeError, 1.2) ] [self.checkout_edge(edge) for edge in edges]
def checkout_edge(self, edge): if edge[0] == "equals": f, output, input = edge[1], edge[2], edge[3] print("Converting %s to %s..." % (input, output)) self.assertEquals(output, f(input)) elif edge[0] == "raises": f, exception, args = edge[1], edge[2], edge[3:] print("Converting %s, expecting %s" % (args, exception)) self.assertRaises(exception, f, *args)
TextTestRunner
.if __name__ == "__main__": suite = unittest.TestLoader().loadTestsFromTestCase( RomanNumeralTest) unittest.TextTestRunner(verbosity=2).run(suite)
We have a specialized Roman numeral converter that only converts values up to MMMM
or 4000
. The immediate edges which we write tests for are 1
and 4000
. We also write some tests for one step past that: 0
and 4001
. To make things complete, we also test against -1
.
But we've written the tests a little differently. Instead of writing each test input/output combination as a separate test method, we capture the input and output values in a tuple that is embedded in a list. We then feed it to our test iterator, checkout_edge
. Because we need both assertEquals
and assertRaises
calls, the tuple also includes either equals
or raises
to flag which assertion to use.
Finally, to make it flexibly handle the convertion of both Roman numerals and decimals, the handles on the convert_to_roman
and convert_to_decimal
functions of our Roman numeral API is embedded in each tuple as well.
As shown in the following highlighted parts, we grab a handle on convert_to_roman
, and store it in r
. Then we embed it in the third element of the highlighted tuple, allowing the checkout_edge
function to call it when needed.
def test_bad_inputs(self): r = self.cvt.convert_to_roman d = self.cvt.convert_to_decimal edges = [("equals", r, "", None), ("equals", r, "I", 1.2), ("raises", d, TypeError, None), ("raises", d, TypeError, 1.2) ] [self.checkout_edge(edge) for edge in edges]
A key part of the algorithm involves handling the various tiers of Roman numerals (5, 10, 50, 100, 500, and 1000). These could be considered mini-edges, so we wrote a separate test method that has a list of input/output values to check those out as well. In the recipe Testing the edges, we didn't include testing before and after these mini-edges, for example 4
and 6
for 5
. Now that it only takes one line of data to capture this test, we have it in this recipe. The same was done for all the others (except 1000).
Finally, we need to check bad inputs, so we created one more test method where we try to convert None
and a float
to and from Roman numeral.
In a way, it does. If something goes wrong in one of the test data entries, then that entire test method will have failed. That is one reason why the other recipe split things up into three test methods instead of one big test method to cover them all. This is a judgment call about when it makes sense to view inputs and outputs as more data than test method. If you find the same sequence of test steps occurring repeatedly, consider whether it makes sense to capture the values in some sort of table structure, like the list used in this recipe.
In case it wasn't obvious, these are the exact same tests used in the recipe Testing the edges. The question is, which version do you find more readable? Both are perfectly acceptable. Breaking things up into separate methods makes it more fine-grained and easier to spot if something goes wrong. Collecting things together into a data structure, the way we did in this recipe makes it more succinct, and could spur us on to write more test combinations as we did for the conversion tiers.
In my opinion, when testing algorithmic functions that have simple inputs and outputs, it's more suitable to use this recipe's mechanism to code an entire battery of test inputs in this concise format, for example, a mathematical function, a sorting algorithm, or perhaps a transform function.
When testing functions that are more logical and imperative, the other recipe may be more useful. For example, functions that interact with a database, cause changes in the state of the system, or other types of side effects that aren't encapsulated in the return value, would be hard to capture using this recipe.
3.145.179.85