As engineers we train ourselves to develop solutions that fulfil the golden path of the requirements. Even though we don't want to admit it, or perhaps just subconsciously. What do I mean by golden path? That's basically the minimum amount of code required to solve a problem, as long as the user uses it exactly how we intended.

How many times have you been working on something that you believe is complete and the first person you show it to does the only thing you didn't try or think of and it immediately highlights a bug or oversight? I tried looking up to see if there was a law for this but not precisely. Perhaps the closest one is:

"Anything that can go wrong, will—at the worst possible moment."

- Finagle's law

This is Part 2 of an article which gives an introduction to TDD. This article has been shortened with the understanding that you are comfortable with the principles of TDD.

This is my attempt at the code kata for converting roman numerals into their respective number. It may seem familiar to a well known kata, but this is actually in reverse because the kata that is often used is the other direction: a number to roman numerals. I've decided to reverse it since theres already a million articles on how to do it the first way, and doing it in reverse provides more opportunity for potential errors that are normally left by the wayside; which is the real focus of this article.

So let's break it down, it's a pretty simple set of requirements:

- Given any roman numerals that represent 1 to 1000, return the number.

One requirement!? Lets's do this!

**Focus on the Bad, First**

As I said before, when we get to the end of the requirements, we feel the task is finished. It's easy to sit back in your chair and relax and feed quietly proud. There's nothing wrong with that. In fact, if you don't feel good about your solution then that's a strong indication something is fundamentally wrong.

TDD forces us to write tests along the way so that when we do get to that last test we truly are at the end (or at least closest to truly finished if followed correctly). Unfortunately, this leads to some oversight in that we expect the user to use the software in the way the requirements are provided. This almost certainly is never the case. Which is why that one person you showed it to went for the very thing that didn't have a requirement dictating the behaviour that should be seen.

Always focus, and write tests for all the misuses, edge cases, etc of the solution before you write the successful cases. You will not be able to handle them all at the start, but you should try and get as many out of the way as possible right now. For example, here is a list of things that may go wrong with the roman numeral calculator:

- Lower case is allowed? For this yes, xvi is the same as XVI.
- Invalid characters, like a P.
- A blank string is ambiguous, do they mean zero or is this an error? We want this to be an error.
- Invalid input type is if we are handed another value other than a string to convert. Especially important in loosely typed languages like Python.
- Invalid range are values of roman numerals that translate to a value that is greater than the allowed 1000.
- Surely if somebody gives us a string that's too long, say 500 characters we should not attempt to process it. We will limit the input to 25 characters even though thats way more than we actually need.
- Valid roman numerals that are given to us in an improper syntax, like IIIIIV.

Seeing all the potential issues we can now decide and clarify on:

- The requirements to do not give us enough information to resolve some of the ambiguities. This can lead to exponentially more work and complexity the longer they are left.
- Impossible or conflicting requirements under edge cases.
- Anything else we may not have discovered by just coding the golden path.

We won't be able to hammer out all of these initially, but we should try and do as many as possible first. The most important thing is that we are aware of them, and we know the solution is not complete until they are all ticked off by the end.

For brevity, I will not be showing the solutions to every single test. Rest assured that TDD is happening behind the scenes, but the article would get very long. Handling as many of the bad cases we outlined above I am so far up to:

import unittestclass RomanToNumberConverter:def validate(self, roman):if type(roman) is not str:raise ValueError("You must provide a string.")if roman == '':raise ValueError("An empty string was provided.")if len(roman) > 25:raise ValueError("Input string is over 25 characters.")raise ValueError("Invalid roman numerals: " + roman.upper())def roman_to_number(self, roman):self.validate(roman)class TestRomanToNumber(unittest.TestCase):def assertError(self, msg, *args, **kwargs):try:converter = RomanToNumberConverter()converter.roman_to_number(*args, **kwargs)self.assertFail()except ValueError as e:self.assertEqual(e.message, msg)def test_P_is_invalid(self):self.assertError('Invalid roman numerals: P', 'P')def test_blank_string(self):self.assertError('An empty string was provided.', '')def test_invalid_type(self):self.assertError('You must provide a string.', 123)def test_string_too_long(self):self.assertError('Input string is over 25 characters.', 'I' * 26)def test_Z_is_invalid(self):self.assertError('Invalid roman numerals: Z', 'Z')def test_always_convert_to_upper_case(self):self.assertError('Invalid roman numerals: U', 'u')# This will run the unit testsunittest.main()

**Handling the Regular Conditions**

Now we can continue with the original requirements in the same TDD fashion. I will only highlight the changes for each test. The commented out lines provide context for the modified classes/functions.

#class RomanToNumberConverter:#def validate(self, roman):if roman != 'I':raise ValueError("Invalid roman numerals: " + roman)def roman_to_number(self, roman):self.validate(roman)return 1#class TestRomanToNumber(unittest.TestCase):def assertResult(self, roman, number):converter = RomanToNumberConverter()result = converter.roman_to_number(roman)self.assertEquals(result, number)def test_I_is_1(self):self.assertResult('I', 1)

import re#class RomanToNumberConverter:#def validate(self, roman):if not re.match('I+', roman):raise ValueError("Invalid roman numerals: " + roman)def roman_to_number(self, roman):self.validate(roman)return len(roman)#class TestRomanToNumber(unittest.TestCase):def test_II_is_2(self):self.assertResult('II', 2)

#class RomanToNumberConverter:#def validate(self, roman):if not re.match('[IV]+', roman):raise ValueError("Invalid roman numerals: " + roman)def roman_to_number(self, roman):self.validate(roman)if roman == 'V':return 5return len(roman)#class TestRomanToNumber(unittest.TestCase):def test_V_is_5(self):self.assertResult('V', 5)

#class RomanToNumberConverter:#def validate(self, roman):if not re.match('[IV]+', roman):raise ValueError("Invalid roman numerals: " + roman)def roman_to_number(self, roman):self.validate(roman)roman = roman.upper()if roman == 'V':return 5return len(roman)#class TestRomanToNumber(unittest.TestCase):def test_v_is_5(self):self.assertResult('v', 5)

#class RomanToNumberConverter:def roman_to_number(self, roman):self.validate(roman)roman = roman.upper()if roman == 'V':return 5if roman == 'VI':return 6return len(roman)#class TestRomanToNumber(unittest.TestCase):def test_VI_is_6(self):self.assertResult('VI', 6)

#class RomanToNumberConverter:def roman_to_number(self, roman):self.validate(roman)roman = roman.upper()total = 0for c in roman:if c == 'I':total += 1else:total += 5return total#class TestRomanToNumber(unittest.TestCase):def test_VII_is_7(self):self.assertResult('VII', 7)

#class RomanToNumberConverter:#def validate(self, roman):if not re.match('[IVX]+', roman):raise ValueError("Invalid roman numerals: " + roman)def roman_to_number(self, roman):self.validate(roman)roman = roman.upper()total = 0for c in roman:if c == 'I':total += 1elif c == 'V':total += 5else:total += 10return total#class TestRomanToNumber(unittest.TestCase):def test_X_is_10(self):self.assertResult('X', 10)

#class RomanToNumberConverter:#def validate(self, roman):if not re.match('[IVXL]+', roman):raise ValueError("Invalid roman numerals: " + roman)def roman_to_number(self, roman):self.validate(roman)roman = roman.upper()total = 0numerals = {'I': 1, 'V': 5, 'X': 10, 'L': 50}for c in roman:total += numerals[c]return total#class TestRomanToNumber(unittest.TestCase):def test_L_is_50(self):self.assertResult('L', 50)

#class RomanToNumberConverter:#def validate(self, roman):if not re.match('[IVXLC]+', roman):raise ValueError("Invalid roman numerals: " + roman)def roman_to_number(self, roman):self.validate(roman)roman = roman.upper()total = 0numerals = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100}for c in roman:total += numerals[c]return total#class TestRomanToNumber(unittest.TestCase):def test_C_is_100(self):self.assertResult('C', 100)

#class RomanToNumberConverter:#def validate(self, roman):if not re.match('[IVXLCD]+', roman):raise ValueError("Invalid roman numerals: " + roman)def roman_to_number(self, roman):self.validate(roman)roman = roman.upper()total = 0numerals = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500}for c in roman:total += numerals[c]return total#class TestRomanToNumber(unittest.TestCase):def test_D_is_500(self):self.assertResult('D', 500)

#class RomanToNumberConverter:#def validate(self, roman):if not re.match('[IVXLCDM]+', roman):raise ValueError("Invalid roman numerals: " + roman)def roman_to_number(self, roman):self.validate(roman)roman = roman.upper()total = 0numerals = {'I': 1, 'V': 5, 'X': 10, 'L': 50,'C': 100, 'D': 500, 'M': 1000}for c in roman:total += numerals[c]return total#class TestRomanToNumber(unittest.TestCase):def test_M_is_1000(self):self.assertResult('M', 1000)

#class RomanToNumberConverter:def roman_to_number(self, roman):self.validate(roman)roman = roman.upper()if roman == 'IV':return 4total = 0numerals = {'I': 1, 'V': 5, 'X': 10, 'L': 50,'C': 100, 'D': 500, 'M': 1000}for c in roman:total += numerals[c]return total#class TestRomanToNumber(unittest.TestCase):def test_IV_is_4(self):self.assertResult('IV', 4)

#class RomanToNumberConverter:def roman_to_number(self, roman):self.validate(roman)roman = roman.upper()numerals = {'I': 1, 'V': 5, 'X': 10, 'L': 50,'C': 100, 'D': 500, 'M': 1000}if len(roman) > 1 and roman[0] < roman[1]:return numerals[roman[1]] - 1total = 0for c in roman:total += numerals[c]return total#class TestRomanToNumber(unittest.TestCase):def test_IX_is_9(self):self.assertResult('IX', 9)

#class RomanToNumberConverter:def roman_to_number(self, roman):self.validate(roman)roman = roman.upper()numerals = {'I': 1, 'V': 5, 'X': 10, 'L': 50,'C': 100, 'D': 500, 'M': 1000}if len(roman) > 1 and numerals[roman[0]] < numerals[roman[1]]:return numerals[roman[1]] - numerals[roman[0]]total = 0for c in roman:total += numerals[c]return total#class TestRomanToNumber(unittest.TestCase):def test_XL_is_40(self):self.assertResult('XL', 40)

#class RomanToNumberConverter:def roman_to_number(self, roman):self.validate(roman)roman = roman.upper()numerals = {'I': 1, 'V': 5, 'X': 10, 'L': 50,'C': 100, 'D': 500, 'M': 1000}total = 0i = 0while i < len(roman):# Subtractions can only go back one character, be careful not to go# beyond the end when looking for double letter combination.a, b = numerals[roman[i]], 0if i < len(roman) - 1:b = numerals[roman[i + 1]]# A subtraction uses two characters so we should skip then next one.if a < b:total += b - ai += 1else:total += ai += 1return total#class TestRomanToNumber(unittest.TestCase):def test_XIX_is_19(self):self.assertResult('XIX', 19)

**Error Conditions**

As soon as we have the opportunity or ability to tick off an error condition from the original list we should take it. Often we need to build a certain amount of logic before there error conditions can be caught. We are ready now.

#class RomanToNumberConverter:#def roman_to_number(self, roman):if total > 1000:raise ValueError("Number is larger than 1000.")return total#class TestRomanToNumber(unittest.TestCase):def test_MI_is_to_large(self):self.assertError('Number is larger than 1000.', 'MI’)

#class RomanToNumberConverter:def roman_to_number(self, roman):self.validate(roman)roman = roman.upper()numerals = {'I': 1, 'V': 5, 'X': 10, 'L': 50,'C': 100, 'D': 500, 'M': 1000}total = 0i = 0last = 0while i < len(roman):# Subtractions can only go back one character, be careful not to go# beyond the end when looking for double letter combination.a, b = numerals[roman[i]], 0if i < len(roman) - 1:b = numerals[roman[i + 1]]# A subtraction uses two characters so we should skip then next one.this = aif a < b:this = b - ai += 1i += 1if last and last < this:raise ValueError('Invalid roman numerals: ' + roman)last = thistotal += thisif total > 1000:raise ValueError("Number is larger than 1000.")return total#class TestRomanToNumber(unittest.TestCase):def test_IXL_is_invalid(self):self.assertError('Invalid roman numerals: IXL', 'IXL’)

This original method is slowly growing too large and complex. Sure we have lots of tests, but always be mindful of the next person (even if that's you) that has to come along as debug or use this code. So I'm going to do a substantial refactor as this point:

import unittestimport reclass RomanToNumberConverter:# Validate that the input may even be processed.def validate(self, roman):if type(roman) is not str:raise ValueError("You must provide a string.")if roman == '':raise ValueError("An empty string was provided.")if len(roman) > 25:raise ValueError("Input string is over 25 characters.")roman = roman.upper()if not re.match('[IVXLCDM]+', roman):raise ValueError("Invalid roman numerals: " + roman)# Convert roman numerals to individual numbers to be summed up, like:# "DLXXXIX" -> [500, 50, 10, 10, 10, 9]# Subtract elements are grouped together into one number like "IX" -> 9.def split_roman_to_numbers(self, roman):numerals = {'I': 1, 'V': 5, 'X': 10, 'L': 50,'C': 100, 'D': 500, 'M': 1000}parts = []i = 0while i < len(roman):# Subtractions can only go back one character, be careful not to go# beyond the end when looking for double letter combination.a, b = numerals[roman[i]], 0if i < len(roman) - 1:b = numerals[roman[i + 1]]# A subtraction uses two characters so we should skip then next one.this = aif a < b:this = b - ai += 1parts.append(this)i += 1return parts# Roman numbers must have the hundreds, tens and ones grouped separately.# Make sure the numerals are in this sequence and that the total is in an# acceptable range.def validated_total(self, roman, numbers):if numbers != sorted(numbers, reverse=True):raise ValueError('Invalid roman numerals: ' + roman)total = sum(numbers)if total > 1000:raise ValueError("Number is larger than 1000.")return total# Convert roman numbers to a number.def roman_to_number(self, roman):self.validate(roman)roman = roman.upper()numbers = self.split_roman_to_numbers(roman)return self.validated_total(roman, numbers)

**Are We Done?**

At this point I cannot think of any further tests. Fortunately roman numerals can produced in sequence easily and so we can actually exhaustively test all possible inputs:

#class TestRomanToNumber(unittest.TestCase):def test_all(self):ones = ('', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX')tens = ('', 'X', 'XX', 'XXX', 'XL', 'L', 'LX', 'LXX', 'LXXX', 'XC')huns = ('', 'C', 'CC', 'CCC', 'CD', 'D', 'DC', 'DCC', 'DCCC', 'CM')i = 0for h in huns:for t in tens:for o in ones:if i > 0:self.assertResult(h + t + o, i)i += 1

**Yes, We Are**

The complete solution is in a Gist. The main takeaway here is that the solution here (with respect to handling error conditions first) has yielded a more robust solution. We have not had to come up with all the ways to break it after the requirements have been completed, which is synonymous with writing tests after code. It's too easy to say "that's too hard to test now" or just plain forget about it.

comments powered by Disqus