I recently took an interview for a new consulting position, and it included a live coding test. The assignment was converting Roman numerals to integers, and I had to use Test-Driven Development. I often write unit tests before implementing a method, but I realised during this coding test that Test-Driven Development biases developers towards delivering bad code.
How does Test-Driven Development work?
Let me first give a quick rundown of the steps involved in Test-Driven Development:
- Write a unit test that covers a single requirement.
- Run all unit tests. Notice that the new test fails, as no code has been written yet.
- Write the simplest code that passes the test. Omit anything but the absolutely necessary.
- Run all unit tests again. Observe that the new test now succeeds.
- Refactor the code. Make it more readable and adhere to coding standards. Remove any hard-coded data and potentially split up the code in methods.
The First Test
Back to the coding test. The first unit test was provided for me. It tested that the input string X
gave an output of 10
. Since I had to write the simplest code that made the test pass, I wrote this:
public int romanToDecimal(String roman) {
return 10;
}
Delivering this code felt completely nonsensical. And it also didn’t bring me any closer to solving conversion from Roman numerals to decimals.
The Second Test
The second unit test tested the mapping from input L
to output 50
. The minimal possible code addition to make that happen was the following:
public int romanToDecimal(String roman) {
if ("L".equals(roman)) {
return 50;
}
return 10;
}
A Sneaky One
The next input was XXXXX
, which was obviously not supposed to return 50
. I solved it with a regular expression to check for four repeating characters. The interviewers mentioned that I generalised more than necessary. Strictly speaking, I should have only checked for consecutive X
‘s:
public int romanToDecimal(String roman) {
Pattern pattern = Pattern.compile("(.)\\1{3}");
if (pattern.matcher(roman).find()) {
throw new IllegalArgumentException();
}
if ("L".equals(roman)) {
return 50;
}
return 10;
}
Now it gets ugly
Next, I got XXIV
as the next input and had to return 24
in this case. Making the minimal possible change, I ended up with this monstrosity:
public int romanToDecimal(String roman) {
Pattern pattern = Pattern.compile("(.)\\1{3}");
if (pattern.matcher(roman).find()) {
throw new IllegalArgumentException();
}
if ("L".equals(roman)) {
return 50;
} else if("XXIV".equals(roman)) {
return 24;
}
return 10;
}
You have probably picked up on the pattern by now; for any subsequent test I would add an if-clause hardcoding the mapping between input and output. Believe it or not, the interviewers were excited about my performance. I was applying Test-Driven Development according to the rules. But it was killing me inside.
My take on Test-Driven Development
Finally, time for the technical part of the interview was up. They asked me if there was anything I would change about this code before submitting it for code review. I have been a professional software engineer for nearly 9 years, and I still feel ashamed of writing this code. So my answer was: “Everything”. While I had great test coverage, I was no closer to solving Roman numeral conversion than I was before I started.
Needless to say, I didn’t take the job. But this coding test painfully showed the shortcomings of Test-Driven Development. We did not even get around to the all-important refactoring step in this controlled environment. So how would you ever get around to it in a real project? With other stuff to work on, with new bugs being reported, with a Product Owner breathing down your neck? Test-Driven Development makes it very tempting to take a shortcut and just be done with it. Because hey, the tests are already green.
In other words: Test-Driven Development yields bad code.
0 Comments