HUnit
A unit testing framework for Haskell
Install / Use
/learn @hspec/HUnitREADME
HUnit User's Guide
HUnit is a unit testing framework for Haskell, inspired by the JUnit tool for Java. This guide describes how to use HUnit, assuming you are familiar with Haskell, though not necessarily with JUnit. You can obtain HUnit, including this guide, at https://github.com/hspec/HUnit
Introduction
A test-centered methodology for software development is most effective when tests are easy to create, change, and execute. The JUnit tool pioneered support for test-first development in Java. HUnit is an adaptation of JUnit to Haskell, a general-purpose, purely functional programming language. (To learn more about Haskell, see www.haskell.org).
With HUnit, as with JUnit, you can easily create tests, name them, group them into suites, and execute them, with the framework checking the results automatically. Test specification in HUnit is even more concise and flexible than in JUnit, thanks to the nature of the Haskell language. HUnit currently includes only a text-based test controller, but the framework is designed for easy extension. (Would anyone care to write a graphical test controller for HUnit?)
The next section helps you get started using HUnit in simple ways. Subsequent sections give details on writing tests and running tests. The document concludes with a section describing HUnit's constituent files and a section giving references to further information.
Getting Started
In the Haskell module where your tests will reside, import module Test.HUnit:
import Test.HUnit
Define test cases as appropriate:
test1 = TestCase (assertEqual "for (foo 3)," (1,2) (foo 3))
test2 = TestCase (do (x,y) <- partA 3
assertEqual "for the first result of partA," 5 x
b <- partB y
assertBool ("(partB " ++ show y ++ ") failed") b)
Name the test cases and group them together:
tests = TestList [TestLabel "test1" test1, TestLabel "test2" test2]
Run the tests as a group. At a Haskell interpreter prompt, apply the
function runTestTT to the collected tests. (The TT suggests
Text orientation with output to the Terminal.)
> runTestTT tests
Cases: 2 Tried: 2 Errors: 0 Failures: 0
>
If the tests are proving their worth, you might see:
> runTestTT tests
### Failure in: 0:test1
for (foo 3),
expected: (1,2)
but got: (1,3)
Cases: 2 Tried: 2 Errors: 0 Failures: 1
>
Isn't that easy?
You can specify tests even more succinctly using operators and overloaded functions that HUnit provides:
tests = test [ "test1" ~: "(foo 3)" ~: (1,2) ~=? (foo 3),
"test2" ~: do (x, y) <- partA 3
assertEqual "for the first result of partA," 5 x
partB y @? "(partB " ++ show y ++ ") failed" ]
Assuming the same test failures as before, you would see:
> runTestTT tests
### Failure in: 0:test1:(foo 3)
expected: (1,2)
but got: (1,3)
Cases: 2 Tried: 2 Errors: 0 Failures: 1
>
Writing Tests
Tests are specified compositionally. Assertions are combined to make a test case, and test cases are combined into tests. HUnit also provides advanced features for more convenient test specification.
Assertions
The basic building block of a test is an assertion.
type Assertion = IO ()
An assertion is an IO computation that always produces a void result. Why is an assertion an IO computation? So that programs with real-world side effects can be tested. How does an assertion assert anything if it produces no useful result? The answer is that an assertion can signal failure by calling assertFailure.
assertFailure :: String -> Assertion
assertFailure msg = ioError (userError ("HUnit:" ++ msg))
(assertFailure msg) raises an exception. The string argument identifies the
failure. The failure message is prefixed by "HUnit:" to mark it as an HUnit
assertion failure message. The HUnit test framework interprets such an exception as
indicating failure of the test whose execution raised the exception. (Note: The details
concerning the implementation of assertFailure are subject to change and should
not be relied upon.)
assertFailure can be used directly, but it is much more common to use it
indirectly through other assertion functions that conditionally assert failure.
assertBool :: String -> Bool -> Assertion
assertBool msg b = unless b (assertFailure msg)
assertString :: String -> Assertion
assertString s = unless (null s) (assertFailure s)
assertEqual :: (Eq a, Show a) => String -> a -> a -> Assertion
assertEqual preface expected actual =
unless (actual == expected) (assertFailure msg)
where msg = (if null preface then "" else preface ++ "\n") ++
"expected: " ++ show expected ++ "\n but got: " ++ show actual
With assertBool you give the assertion condition and failure message separately.
With assertString the two are combined. With assertEqual you provide a
"preface", an expected value, and an actual value; the failure message shows the two
unequal values and is prefixed by the preface. Additional ways to create assertions are
described later under Advanced Features
Since assertions are IO computations, they may be combined--along with other
IO computations--using (>>=), (>>), and the do
notation. As long as its result is of type (IO ()), such a combination
constitutes a single, collective assertion, incorporating any number of constituent
assertions. The important features of such a collective assertion are that it fails if
any of its constituent assertions is executed and fails, and that the first constituent
assertion to fail terminates execution of the collective assertion. Such behavior is
essential to specifying a test case.
Test Case
A test case is the unit of test execution. That is, distinct test cases are executed independently. The failure of one is independent of the failure of any other.
A test case consists of a single, possibly collective, assertion. The possibly multiple
constituent assertions in a test case's collective assertion are not independent.
Their interdependence may be crucial to specifying correct operation for a test. A test
case may involve a series of steps, each concluding in an assertion, where each step
must succeed in order for the test case to continue. As another example, a test may
require some "set up" to be performed that must be undone ("torn down" in JUnit
parlance) once the test is complete. In this case, you could use Haskell's
IO.bracket function to achieve the desired effect.
You can make a test case from an assertion by applying the TestCase constructor.
For example, (TestCase (return ())) is a test case that never
fails, and (TestCase (assertEqual "for x," 3 x))
is a test case that checks that the value of x is 3. Additional ways
to create test cases are described later under Advanced Features.
Tests
As soon as you have more than one test, you'll want to name them to tell them apart. As soon as you have more than several tests, you'll want to group them to process them more easily. So, naming and grouping are the two keys to managing collections of tests.
In tune with the "composite" design pattern [1], a test is defined as a package of test cases. Concretely, a test is either a single test case, a group of tests, or either of the first two identified by a label.
data Test = TestCase Assertion
| TestList [Test]
| TestLabel String Test
There are three important features of this definition to note:
- A
TestListconsists of a list of tests rather than a list of test cases. This means that the structure of aTestis actually a tree. Using a hierarchy helps organize tests just as it helps organize files in a file system. - A
TestLabelis attached to a test rather than to a test case. This means that all nodes in the test tree, not just test case (leaf) nodes, can be labeled. Hierarchical naming helps organize tests just as it helps organize files in a file system. - A
TestLabelis separate from bothTestCaseandTestList. This means that labeling is optional everywhere in the tree. Why is this a good thing? Because of the hierarchical structure of a test, each constituent test case is uniquely identified by its path in the tree, ignoring all labels. Sometimes a test case's path (or perhaps its subpath below a certain node) is a perfectly adequate "name" for the test case (perhaps relative to a certain node). In this case, creating a label for the test case is both unnecessary and inconvenient.
The number of test cases that a test comprises can be computed with testCaseCount.
testCaseCount :: Test -> Int
As mentioned above, a test is identified by its path in the test hierarchy.
data Node = ListItem Int | Label String
deriving (Eq, Show, Read)
type Path = [Node] -- Node order is from test case to root.
Each occurrence of TestList gives rise to a ListItem and each
occurrence of TestLabel gives rise to a Label. The ListItems
by themselves ensure uniqueness among test case paths, while the Labels allow
you to add mnemonic names for individual test cases and collections of them.
Note that the order of nodes in a path is reversed from what you might expect: The first node in the list is the one deepest in the tree. This order is a concession to efficiency: It allows common path prefixes to be shared.
The paths of the test cases that a test comprises can be
