Test-driven development in Mendix, part 2 — Converting numbers to Roman numerals

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:

  1. Write a list of the test scenarios you want to cover
  2. Turn exactly one item on the list into an actual, concrete, runnable test
  3. Change the code to make the test (& all previous tests) pass (adding items to the list as you discover them)
  4. Optionally refactor to improve the implementation design
  5. Until the list is empty, go back to #2

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:

  • a Create variable (Integer) activity with value 1 and name Input
  • a Create variable (String) activity with value ‘I’ and name Expected
  • a Call microflow activity
  • the Assert using expression activity from the UnitTesting module

The Assert using expression activity has four arguments, which we will give the following values:

  • Name: ‘Return ‘’’ + $Expected + ‘’’ when input is ’ + $Input This value is displayed on the UnitTestOverview page NB. Note the (two) triple quotation marks! Following up a quotation mark with another one escapes it and includes it in the string, which is exactly what we want in this case.
  • Expression: $Output = $Expected This is the actual assertion that will determine if the test passes or fails. This will cause errors, as there is no $Output variable available (yet). We’ll get to that later!
  • FailureMessage: ‘Expected: ‘’’ + $Expected + ‘’’ Actual: ’ + $Output + ‘’’’ The message that is displayed on the UnitTestOverview page when the test fails. Notice the triple and even quadruple quotation marks again!
  • StopOnFailure: false This unit test shouldn’t cause the other unit tests to not run, so this can be set to false.

The result: a unit test flow that causes two errors, caused by the fact that we use the $Output parameter, which doesn’t exist.

Article content
Article content

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:

Article content

Pointing the Call microflow action in the test flow to this new microflow clears the errors, and we are ready to run the test.

Article content

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!

Article content

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:

Article content
Article content

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.

Article content
Article content

Save, redeploy, run the test, see it fail.

Article content

The reason it fails is because the SUB_RomanNumerals_ConvertUnits flow returns that hard-coded ‘I’, as we can see in the assertion details:

Article content

To make it pass, we need to modify the flow in such a way that it checks the actual value of the input parameter.

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’.

Article content

‘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.

Article content

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.

Article content

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:

Article content

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:

Article content

Rerun the test: it passes.

Article content

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.

Article content

Add a hard ‘equals 8?’ check with applicable return value, rerun the test, and see that it passes:

Article content
Article content

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

  • replace the hard ‘equals 5’ and ‘equals 8’ checks with a single ‘is greater than or equal to 5’ check
  • move that check a bit further down the flow so it has access to the return string and the counter
  • if the input is indeed greater than or equal to 5, set the return String to ‘V’ and subtract 5 from the counter
  • let the loop add I’s to that ‘V’ based on what remains after the subtraction.

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:

  • Convert tens: the numbers 10, 30, etc.
  • Combining the tens and units conversions, convert all numbers in the range 1–99
  • Convert hundreds: 100, 300, etc.
  • Combine hundreds and tens (110, 250, 960, etc.)
  • Combine hundreds, tens and units (111, 256, 989, etc.)
  • Convert thousands
  • Convert all numbers in the range 1–3999

Also consider these edge cases:

  • input less than or equal to 0 (zero)
  • input greater than or equal to 4,000
  • empty input

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 😉

Like
Reply

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!

To view or add a comment, sign in

More articles by Bart Zantingh

Others also viewed

Explore content categories