Inlined HUnit test lists by Mark Seemann
An alternative way to organise tests lists with HUnit.
In the previous article you saw how to write parametrised test with HUnit. While the tests themselves were elegant and readable (in my opinion), the composition of test lists left something to be desired. This article offers a different way to organise test lists.
Duplication #
The main problem is one of duplication. Consider the main
function for the test library, as defined in the previous article:
main = defaultMain $ hUnitTestToTests $ TestList [ "adjustToBusinessHours returns correct result" ~: adjustToBusinessHoursReturnsCorrectResult, "adjustToDutchBankDay returns correct result" ~: adjustToDutchBankDayReturnsCorrectResult, "Composed adjust returns correct result" ~: composedAdjustReturnsCorrectResult ]
It annoys me that I have a function with a (somewhat) descriptive name, like adjustToBusinessHoursReturnsCorrectResult
, but then I also have to give the test a label - in this case "adjustToBusinessHours returns correct result"
. Not only is this duplication, but it also adds an extra maintenance overhead, because if I decide to rename the test, should I also rename the label?
Why do you even need the label? When you run the test, that label is printed during the test run, so that you can see what happens:
$ stack test --color never --ta "--plain" ZonedTimeAdjustment-0.1.0.0: test (suite: ZonedTimeAdjustment-test, args: --plain) :adjustToDutchBankDay returns correct result: : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] :adjustToBusinessHours returns correct result: : [OK] : [OK] : [OK] :Composed adjust returns correct result: : [OK] : [OK] : [OK] : [OK] : [OK] Test Cases Total Passed 20 20 Failed 0 0 Total 20 20
I considered it redundant to give each test case in the parametrised tests their own labels, but I could have done that too, if I'd wanted to.
What happens if you remove the labels?
main = defaultMain $ hUnitTestToTests $ TestList $ adjustToBusinessHoursReturnsCorrectResult ++ adjustToDutchBankDayReturnsCorrectResult ++ composedAdjustReturnsCorrectResult
That compiles, but produces output like this:
$ stack test --color never --ta "--plain" ZonedTimeAdjustment-0.1.0.0: test (suite: ZonedTimeAdjustment-test, args: --plain) : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] : [OK] Test Cases Total Passed 20 20 Failed 0 0 Total 20 20
If you don't care about the labels, then that's a fine solution. On the other hand, if you do care about the labels, then a different approach is warranted.
Inlined test lists #
Looking at an expression like "Composed adjust returns correct result" ~: composedAdjustReturnsCorrectResult
, I find "Composed adjust returns correct result"
more readable than composedAdjustReturnsCorrectResult
, so if I want to reduce duplication, I want to go after a solution that names a test with a label, instead of a solution that names a test with a function name.
What is composedAdjustReturnsCorrectResult
? It's just the name of a pure function (because its type is [Test]
). Since it's referentially transparent, it means that in the test list in main
, I can replace the function with its body! I can do this with all three functions, although, in order to keep things simplified, I'm only going to show you two of them:
main :: IO () main = defaultMain $ hUnitTestToTests $ TestList [ "adjustToBusinessHours returns correct result" ~: do (dt, expected) <- [ (zt (2017, 10, 2) (6, 59, 4) 0, zt (2017, 10, 2) (9, 0, 0) 0), (zt (2017, 10, 2) (9, 42, 41) 0, zt (2017, 10, 2) (9, 42, 41) 0), (zt (2017, 10, 2) (19, 1, 32) 0, zt (2017, 10, 3) (9, 0, 0) 0) ] let actual = adjustToBusinessHours dt return $ ZT expected ~=? ZT actual , "Composed adjust returns correct result" ~: do (dt, expected) <- [ (zt (2017, 1, 31) ( 7, 45, 55) 2 , zt (2017, 2, 28) ( 7, 0, 0) 0), (zt (2017, 2, 6) (10, 3, 2) 1 , zt (2017, 3, 6) ( 9, 3, 2) 0), (zt (2017, 2, 9) ( 4, 20, 0) 0 , zt (2017, 3, 9) ( 9, 0, 0) 0), (zt (2017, 2, 12) (16, 2, 11) 0 , zt (2017, 3, 10) (16, 2, 11) 0), (zt (2017, 3, 14) (13, 48, 29) (-1), zt (2017, 4, 13) (14, 48, 29) 0) ] let adjustments = reverse [adjustToNextMonth, adjustToBusinessHours, adjustToDutchBankDay, adjustToUtc] let adjust = appEndo $ mconcat $ Endo <$> adjustments let actual = adjust dt return $ ZT expected ~=? ZT actual ]
In order to keep the code listing to a reasonable length, I didn't include the third test "adjustToDutchBankDay returns correct result"
, but it works in exactly the same way.
This is a list with two values. You can see that the values are separated by a ,
, just like list elements normally are. What's unusual, however, is that each element in the list is defined with a multi-line do
block.
In C# and F#, I'm used to being able to just write new test functions, and they're automatically picked up by convention and executed by the test runner. I wouldn't be at all surprised if there was a mechanism using Template Haskell that enables something similar, but I find that there's something appealing about treating tests as first-class values all the way.
By inlining the tests, I can retain my F# and C# workflow. Just add a new test within the list, and it's automatically picked up by the main
function. Not only that, but it's no longer possible to write a test that compiles, but is never executed by the test runner because it has the wrong type. This occasionally happens to me in F#, but with the technique outlined here, if I accidentally give the test the wrong type, it's not going to compile.
Conclusion #
Since HUnit tests are first-class values, you can define them inlined in test lists. For larger code bases, I'd assume that you'd want to spread your unit tests across multiple modules. In that case, I suppose that you could have each test module export a [Test]
value. In the test library's main
function, you'd need to manually concatenate all the exported test lists together, so a small maintenance burden remains. When you add a new test module, you'd have to add its exported tests to main
.
I wouldn't be surprised, however, if a clever reader could point out to me how to avoid that as well.