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 (2017102) (659,  40, zt (2017102) (9,  0,  00),
        (zt (2017102) (942410, zt (2017102) (942410),
        (zt (2017102) (191320, zt (2017103) (9,  0,  00)
      ]
    let actual = adjustToBusinessHours dt
    return $ ZT expected ~=? ZT actual
 
  ,
  "Composed adjust returns correct result" ~: do
    (dt, expected) <-
      [
        (zt (2017131) ( 74555)   2 , zt (2017228) ( 7,  0,  00),
        (zt (20172,  6) (10,  3,  2)   1 , zt (20173,  6) ( 9,  3,  20),
        (zt (20172,  9) ( 420,  0)   0 , zt (20173,  9) ( 9,  0,  00),
        (zt (2017212) (16,  211)   0 , zt (2017310) (16,  2110),
        (zt (2017314) (134829) (-1), zt (2017413) (1448290)
      ]
    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.



Wish to comment?

You can add a comment to this post by sending me a pull request. Alternatively, you can discuss this post on Twitter or somewhere else with a permalink. Ping me with the link, and I may respond.

Published

Monday, 07 May 2018 12:41:00 UTC

Tags



"Our team wholeheartedly endorses Mark. His expert service provides tremendous value."
Hire me!