5 Unit tests
Unit tests are like seat belts: they seem to be only complicating your life – until an accident happens.
Imagine you’re writing a function that takes an input and returns some result. Unless your function performs trivial calculations, like a single mathematical operation, you need to check somehow if it’s well written. Typically, you would execute your function with several sets of input parameters and verify if the output is correct. Now imagine that you have dozens of functions in your project, some of them very sophisticated, and you decide to optimize the workflow. Or completely change the architecture of your program. What would you do? What comes to mind, is: try to automate it, writing a script that would execute your functions with a predefined set of input parameters each time it’s launched.
Now add automatic output validation and you get unit tests.
In a team setting, unit tests are important for one more reason: they can tell your peers or future developers what exactly you expect your function to do. Sometimes documentation is not enough when your function deals with complicated input.
Unit testing routines are available for most programming languages – usually as separate libraries. Basic procedure almost always looks roughly the same: a single test case consists of the execution of one function and the expected output.
5.1 An ideal unit test
Ideally, unit tests should follow these rules:
- They should reflect the goal of the function – one should be able to infer what the function does by reading the test…
- …but they can’t duplicate the logic of the function – if you share function’s code with the test, you may be sharing bugs as well.
- They must not be changed after refactoring the function – this would undermine the point of unit testing.
- They shouldn’t be redundant – a test for one unit of code should be written and executed only once.
- They should cover the smallest possible portion of code (hence the name), usually one function – you shouldn’t confuse unit tests with integration tests, which check the cooperation of elements within the program.
It’s hard to follow these rules to a T – sometimes the structure of your application makes it hard to split into easily testable functions, or these functions are so interdependent that they can’t be tested separately – but the more closely you’ll follow them, the more you’ll get from your tests.
5.2 Test conditions
One of the most important tasks when writing unit test is the proper design of testing conditions. Let’s consider a trivial example:
divide = function(x, y) {
x / y
}
Now, you can test this functions for some made-up values of x and y [examples written using testthat package for R]:
expect_equal(divide(1, 2), 0.5)
expect_equal(divide(10, 5), 2)
# etc...
but it makes little sense. Instead, you should think about some tricky cases:
divide(1, 0) # should it return Inf or throw an error?
divide(1e300, 1e300) # what about large numbers?
divide(5, "10") # what if one of the arguments is not numeric?
From this simple case you can see that designing test conditions makes you think about the intended behavior of your function and, sometimes, about the goal of writing it. Which leads us to the next point…
5.3 Test-Driven Development
Test-driven development (abbreviated TDD) is a step forward in writing unit tests. Instead of writing tests for the existing code, you write them… before the tested functions are even created.
What’s the point of this? How is it even possible?
When you write a unit test for a non-existent function, you have to thoroughly think over its input and desired output (as we saw in the previous section), what the function should actually do and how it would interact with other parts of the program. Obviously the test will initially fail, because there’s no function yet, but when you eventually write it, it should behave exactly as intended by the test – and that’s the point of TDD: the program is ready when all tests pass.
I’m not advocating TDD – I hardly ever use it – but I strongly recommend following the philosophy of TDD: design your tests so that when a test fails, you refactor the tested function, not the test.
5.3.1 “Hybrid” approach
For some projects, especially from the scientific domain R is often used for, a mixed testing style would be a better choice:
- “Mold” the initial version of the function – this is effectively a prototype of the new functionality.
- Write an extensive set of unit tests, covering cases you haven’t thought about when writing your function.
- If possible, ask your peers to think of some edge cases – this is a great exercise in team programming, complementing traditional code review.
- Adjust your code so that the tests don’t fail.
- If you need to refactor the code, don’t change the tests. Let the new code pass the existing tests, and only then modify them.
5.4 Unit testing in R
There are several packages for unit testing in R, RUnit and testthat being the most widely used. While the latter seems to be more popular and actively maintained, both are perfectly valid for writing unit tests and the choice of one over the other is up to your preference (or the guidelines for the project).