Test-driven development in Mendix, part 2 — Converting numbers to Roman numerals
This article is part of a series on quality assurance and test-driven development in Mendix and a sequel to my article Clean code in low-code: test-driven development in Mendix.
The previous article was an introduction to TDD, with a short example to see if it was possible to do in Mendix. Turns out, it is!
Now, let’s go deeper by doing multiple test cases of a more complex flow than last time, better illustrating the reasoning behind some of the more… unconventional principles of TDD.
A quick TDD refresher
Don’t know what TDD is or want to refresh your memory? Here you go!
The five steps of test-driven development, and the ones we will follow in this article are:
The requirements
OK, let’s go. This is what we are going to build:
Write a function that converts a number in the range 1 to 4,000 (excluding) to a Roman numeral, using subtractive notation for the numbers 4, 9, 40, 90, 400 and 900.
As a reminder, the Roman numerals are I, V, X, L, C, D and M for the numbers 1, 5, 10, 50, 100, 500 and 1000 respectively.
The test cases
Step 1: Write a list of the test scenarios you want to cover
Let’s start with just a basic list, covering the units (1 to 9) first, thinking about larger numbers like 10, 489 and 2656, where we have to convert tens, hundreds and thousands, and the edge cases (if any) later.
The most trivial case is converting 1, so let’s start with that:
Test 1: When the input is 1, the output should be I
We should also be able to repeat multiple ones, so let’s do 3 next:
Test 2: When the input is 3, the output should be III
The next step could be a special case, like 5, as that has its own symbol:
Test 3: When the input is 5, the output should be V
Combining the first three tests, we can sort out adding one or multiple ones to a 5:
Test 4: When the input is 8, the output should be VIII
(If we build this one right, it covers 6 (VI) and 7 (VII) as well)
Now let’s do that weird ‘subtractive notation’ thing by testing 4 and 9 next:
Test 5: When the input is 4, the output should be IV Test 6: When the input is 9, the output should be IX
Step 2: Pick one item off the list and write an actual test
OK, let’s start with some actual modelling in Mendix and see how far we get until we get the first fail. We’ll start at the top of the list, converting the number 1 to its Roman numeral ‘I’.
The first test
First, we’ll create a microflow that will be recognized by Mendix’ UnitTesting module as being a test: Test_UnitConversion_ShouldReturnI_WhenInput1.
Now we have an empty flow, which causes no errors. So far so good.
Next up, we are going to set up this unit test flow in a particular way. At first this might come across as a bit overkill, but bear with me. It will make the tests after this one so much easier to set up and modify, I promise it is worth the initial hassle.
Add the following activities to the flow:
The Assert using expression activity has four arguments, which we will give the following values:
The result: a unit test flow that causes two errors, caused by the fact that we use the $Output parameter, which doesn’t exist.
So we have our first blocking issue, errors in our console, which — with some imagination — constitutes a failed test. Fortunately, we can solve these errors by a single action: creating the actual flow we want to test.
Step 3: change the code to make the test pass
To make sure our first test passes — or rather, make the errors disappear — we need to add a new microflow to the app, and call it from the test flow. We’ll call this microflow SUB_RomanNumerals_ConvertUnits, with an input parameter of type Integer and an output parameter called ‘Output’ returning an empty String:
Pointing the Call microflow action in the test flow to this new microflow clears the errors, and we are ready to run the test.
For those screaming at their screens about hard-coding an empty String return value while it should have returned ‘I’ for the test to pass: I know.
While it seems trivial to do it like this, there is a very good reason for it: we want all tests to fail on their first run. By adding an error to the flow-under-test on purpose, we know exactly where the mistake is, we know the test should fail and we can actually confirm the flow-under-test is doing what it’s supposed to if and when the test passes.
So. Now we have a test that we can actually run, let’s run it and see it fail!
Failing test? Check. Now let’s make it pass by modifying the flow-under-test: let’s set the return value of the SUB_RomanNumerals_ConvertUnits microflow to ‘I’, redeploy and run the test again:
There we go, our first passing test!
Step 4: Refactor to improve the implementation design
Well, there’s really nothing to refactor at the moment, as all we have done is create an empty microflow that accepts an integer as input and returns a (hard-coded) string, which is enough for now.
Step 5: go back to step 2
Next up: test number 2.
Test 2: convert 3 to ‘III’
We went into quite some detail of every TDD step for the first test, so let’s pick up the pace a bit.
Step 2 of TDD, creating the test, is easy: we’ll just duplicate the first one and adjust as necessary. Duplicating the previous test and renaming it results in Test_UnitConversion_ShouldReturnIII_WhenInputIs3.
Now we see the major benefit of taking a little more time setting up our first test the way we did, because all we have to do is change the Integer variable Input to 3 and the String variable Expected to ‘III’ respectively, and we’re done. The arguments in the Assert using expression activity automatically pick up the new values.
Save, redeploy, run the test, see it fail.
The reason it fails is because the SUB_RomanNumerals_ConvertUnits flow returns that hard-coded ‘I’, as we can see in the assertion details:
To make it pass, we need to modify the flow in such a way that it checks the actual value of the input parameter.
Recommended by LinkedIn
Warning: this is going to get ugly. We are going to commit a horrible sin…
We’ll add an exclusive split that checks if the Input parameter’s value is 3. If so, return ‘III’, else return ‘I’.
‘Oh, the horror!’ I hear you cry out, covering your eyes…
But remember, at this point the only thing we care about is making the test we are working on pass, while also making sure all the other tests don’t break, so resist the urge to do anything more than that and don’t touch that hard-coded ‘I’! For now. I promise we’ll get back to it.
Rerunning the test, now it does pass.
Refactor time.
Now, this hard check on the input value and returning not one but two hard-coded values is hardly sustainable. It flies in the face of every software development best practice we know of, so let’s fix it!
Let’s spend some more brainpower and take a look at what is actually happening: the number of I’s we want to return is the same as the input number. So if we add a String variable, with its initial value an empty String, we can use a loop to append it with a number I’s based on the flow’s input, and let the flow return that variable instead of a hard-coded value.
We’ll also need a counter, initially set to the Input variable’s value and subtracting one after each added ‘I’ so we can let the loop know when it has reached the end, and voilà: we successfully refactored the flow, doing away with those ugly hard-coded values and checks, and making it way more generic and useful for all integers, not just 1 and 3.
This is quite the overhaul, so we should run the tests again to see if we broke anything during the refactor…
All green, everything is fine!
The case for test-driven development
This refactor showed the biggest benefit of using the test-driven development approach: we just rebuilt our flow from the ground up— literally the only thing we didn’t touch was the actual input parameter — and with one push of a button we ran all of our tests and confirmed that everything still worked.
Another major effect of this approach is that as the tests get more specific, the code gets more generic. And in general, code that is more generic is usable and reusable in many different situations and is easier to maintain.
Rinse and repeat, up to the next test!
Test 3: convert 5 to V
Just like with the previous test, lets just copy/paste the convert-3-to-III test flow, rename it and adjust where necessary, resulting in the test flow Test_UnitConversion_ShouldReturnV_WhenInputIs5, with $Input set to 5 and $Expected set to ‘V’.
Running the test, it fails, of course, because instead of the expected ‘V’, the conversion flow loops five times and returns as many I’s:
So we need to come up with a way to change the flow so it returns the correct Roman numeral, without breaking the flow and the other tests.
Employing the same shameful behavior we did in the previous test, we make this one pass by adding a hard check on the input’s value:
Rerun the test: it passes.
Nothing to refactor at this time.
(I know, we could do multiple things to refactor, but it doesn’t serve any purpose at this moment, unless it’s to scratch that itch that all developers get when they see something they know can be better)
Test 4: 8 to VIII
Duplicate the previous test, modify $Input (8) and $Expected (VIII), run the test and see that it fails.
Add a hard ‘equals 8?’ check with applicable return value, rerun the test, and see that it passes:
Now we have another possibility to refactor and make the flow more generic and more useful again: the flow returns two Roman numerals with a ‘V’ in it, and one of them has multiple I’s added to it. And it just so happens we already have a loop that does that last part…
How about we
Would that work?
Redeploying, resetting all tests and rerunning them shows that it actually does!
And the best thing is that we know that if it didn’t work, if we made a mistake somewhere, we know that the mistake would have been made in that last refactor. How do we know this? Because before we started the refactor, all our tests passed.
We could just undo all our changes, go back to the situation pre-refactor, and have a working piece of software again. Well, an ugly piece of software, but working nonetheless.
Test 5: 4 and 9
4 and 9 are special cases, because they use the ‘subtractive notation’ Roman numerals are known for, where the smaller symbol is written before the larger one and so subtracted from it: IV and IX are interpreted as 5–1 = 4 and 10–1 = 9, respectively.
First we’ll write the tests, which have the exact same setup as all the previous ones ($Input 4/9, $Expected ‘IV’/‘IX’), run them to see them fail, and only then we’ll modify the microflow.
Both of these cases can’t really be captured in the logic we have written already, and there is (at least for the purposes of this article) no way to make it more generic, so we’ll decide to add two hard ‘equal to 4’ and ‘equal to 9’ checks at the start of the flow, so we only trigger the more involved logic when necessary.
The (for now) finished microflow looks like this:
With the list of tests and their results looking nice and clean:
Other tests and edge cases
For now, we’ll leave it at this, but I challenge you to continue, building up the full logic of converting a number to its corresponding Roman numeral, taking a look at these test cases:
Also consider these edge cases:
Recap
OK, let’s take a step back and see what we actually did here.
We started with creating a list of tests that we want to perform to check if our system does what it should.
Then we just picked a test from the list, and started building that test, instead of the actual logic. Only when we ran into problems (read: errors and/or a failed test) we looked at the actual code and built just enough logic to make that one test that we were working on, and all the previous ones that we already made, pass.
Once the test passed we refactored the code, if necessary.
And because we already had a test that passed before we started refactoring, if we inadvertently did something in the refactor that broke something, somewhere in the app, we would immediately notice, because previously made (and passing) tests would start failing. Back to the pre-refactor situation, retest, make sure everything works again and try again.
And only when the refactor doesn’t break any tests, go on to the next one on the list until there is nothing more to test.
Conclusion
Test-driven development is awesome, it is entirely possible using Mendix and in my not-so-humble opinion should be used and advocated for more widely in the Mendix (and wider low-code) community.
True, it does take some getting used to and it requires quite the significant shift in thinking about our development approach, but the results are far-reaching and should not be underestimated: application logic that you know for certain works as it should, because it comes with a complete set of low-level tests that all pass.
And because they all pass, those tests can then be used to ensure the application doesn’t break when you change something. The fear of refactoring code because you are afraid that something, somewhere will break if you change this little piece of logic here is a thing of the past, because you can always go back and have it working again.
Would I recommend everyone to switch their workflow to TDD? No. At least, not immediately and not fully. Because it is not just a different way of working, it is also a different way of thinking: instead of building now, testing later and then automate those tests (if ever), you have to take a step back first and think about what you are going to build, how it can be tested, and build it according to the required outcomes of those tests. And while that sounds easy, it actually is pretty hard.
But it is also very, very rewarding.
I don’t understand any peppernut of this post, but seeing what you are doing (and reading about it) does make me a proud brother 😉
I’ll need to implement this at work !
Very insightful series of blogs! Whether or not TDD as an app development method is the right fit for your project or not, increased security and guidance throughout the dev process through (automated) testing is something we should all get behind. I look forward to reading more!
Loving this series Bart, thank you for highlighting TDD as a method to develop applications in Mendix!