|
Unit tests with JUnit
By David Neary.
Contents:
- Why write tests?
- To test code
- To document interfaces
- To locate bugs
- To fix bugs
- JUnit
- How to use it
- Starting a class
- Full speed ahead
- Testing edge cases
- Conclusion
Unit tests with JUnit
Why write tests?
This question is often asked, and even if the answer should be obvious,
it is however necessary to answer it. Unit tests have several functions.
Here's a brief summary:
To test the code
Unit tests make it possible for a programmer, as well as his company, to
check the basic operation of the code he writes. Manually launched
functional tests (print statements in the code, main()...) must be
performed several times - when one writes the code at the beginning,
when one passes it to the 'testers' and when it is delivered to the
customer.
Unit tests with JUnit, which have a higher cost at the beginning, will
save time, in the long run, because it only takes a few seconds to run
them. They reassure us that the expected behavior of our work is the
actual behavior.
To document the interfaces
Unit tests make us to think about the interface exposed in classes.
Moreover, they force us to document their behaviour according to how
they are (as opposed to how they were designed).
Often, programmers have little time to write the docs. The unit tests,
often rather simple, document the code and the awaited behavior of the
interfaces. Thereafter, if the interfaces evolve/move, the tests must
also evolve/move. If not, they become obsolete and stop passing.
To locate bugs
Frequently code works well when it is written. But afterwards if the
code is taken over by someone else, who does not take time to understand
what the original programmer was trying to do, it is likely that bugs
will be introduced.
With unit tests, launched in an automated way rather regularly, these
regressions of functionality are detected much more easily, and quickly
become fixed.
To fix the bugs
The correction of a bug should imply first writing a JUnit unit test, to
reproduce this bug. Thereafter, we know that the bug is fixed when this
test passes without problems.
Since we can also running a regression test suite, and this will only
take a few seconds, we know if our change has reintroduced any other
bugs as well.
JUnit
JUnit is a unit testing framework. It has quickly become the de facto
standard of the Java tests. Its greater advantage is its ease of use:
a function --> a test
All you need to do is to set up a minimal program structure, to call the
function being tested and to compare the results of the call with the
expected answer.
And that is all.
One can group several tests together with a TestSuite, which allows us
execute all the tests associated with a project, or a sub-project,
without launching the whole of the application.
There are also tools such as HttpUnit and Jakarta Cactus which set up
complex environments which make it easy to test client/server aspects of
web projects - servlets, the execution of Javascript, the submission of
forms.
How to use it
Here is a small example:
Let us imagine a class java which calculates certain mathematical
relations (sum, mean, median, min, max) with a List of Integers. We'll
ignore the technical aspects of this (there are certainly better ways to
do this). Our goal is to show that this class gives us the expected results.
Beginning of class
Here's our MathOps.java class which we want to test:
package org.dneary.math;
public class MathOps
{
}
We'll write our test code in MathOpsTest.java. A good practice when
using JUnit is to implement the tests in a separate package. Note that
junit.jar must be on the classpath for compilation:
package org.dneary.math.test;
import junit.framework. *;
import org.dneary.math. *;
public class MathOpsTest extends public TestCase {
public void test_dummy() {
MathOps Mo = new MathOps();
}
}
This first test will simply make sure that the packages are declared ok
and that JUnit is on the classpath. Nothing spectacular.
And we build.
And we run our test.
There are several ways to run this test:
From the command line, one would write
For text: java junit.textui.TestRunner org.dneary.math.test.MathOpsTest
For AWT: java junit.awtui.TestRunner org.dneary.math.test.MathOpsTest
For Swing: java junit.swingui.TestRunner org.dneary.math.test.MathOpsTest
Better still (IMHO): use the Eclipse plug-in, or write a small 'main'
function like this:
public static void main(String[ ] args)
{
junit.swingui.TestRunner.run(MathOpsTest.class);
}
and simply run the test with
java org.dneary.math.test.MathOpsTest
Here is an execution on the command line, text mode.
java -cp
junit.jar;. junit.textui.TestRunner
org.dneary.math.test.MathOpsTest
.
Time: 0,01
OK (1 tests)
And woohoo! It passed.
Full speed ahead
Good - but tests which don't test anything are not very good. It's
better to have something to test.
We'll write a method which returns the average of a list of 'Integer's:
import java.util.*;
...
public static double average (List numbers)
{
return 0;
}
Ah, "That won't work" you say? Of course not but this will enable us to
check our test.
Then, in MathOpsTest, we adds this method.
import java.util.*;
...
public void test_average_simple ()
{
Vector nums = new Vector();
nums.add(new Integer(3));
assertTrue(MathOps.average(nums) == 3.0);
}
This is a very simple test which checks that we haven't made any very
silly mistakes. It should not pose any problem with our class. But...
java -cp
junit.jar;. junit.textui.TestRunner
org.dneary.math.test.MathOpsTest
..F
Time: 0,01 There was 1 failure:
1)
test_average_simple(org.dneary.math.test.MathOpsTest)junit.framework.AssertionFailedError
At
org.dneary.math.test.MathOpsTest.test_average_simple(MathOpsTest.java:18)
FAILURES!!!
Tests run: 2, Failures: 1, Errors: 0
This is normal. 0 != 3.0.
OK - let's stop playing around - we're going to write a proper method
'average()'.
public static double average (List l)
{
Iterator iter = l.iterator();
int sum = 0;
while (iter.hasNext())
{
Integer num = (Integer) iter.next(); sum + = num.intValue();
}
return sum/l.size();
}
And now...
java - CP
junit.jar;. junit.textui.TestRunner
org.dneary.math.test.MathOpsTest
..
Time: 0,01
OK (2 tests)
Woohoo!
But at the same time, this isn't a very good test. Let's try something a
bit more difficult:
public void test_average_multiple ()
{
Vector nums = new Vector();
nums.add(new Integer(3));
nums.add(new Integer(6));
assertTrue(MathOps.average(nums) == 4.5);
}
And we rerun the tests...
java -cp
junit.jar;. junit.textui.TestRunner
org.dneary.math.test.MathOpsTest
... F
Time: 0,01
There was 1 failure:
1)
test_average_multiple(org.dneary.math.test.MathOpsTest)junit.framework.AssertionFailedError
At
org.dneary.math.test.MathOpsTest.test_average_multiple(MathOpsTest.java:26)
FAILURES!!!
Tests run: 3, Failures: 1, Errors: 0
This time we have a little more trouble in seeing what's wrong. We can
of course add a println() in our test to see what's happenning...
System.out.println ("Average: "+ MathOps.average(nums));
Surprisingly, the calculated average is 4.0. But why??? 3+6 is equal to
9, and 9/2 =... 4 while working with integers. D'oh! Let's change our
method...
public static double average (List l)
{
Iterator iter = l.iterator();
double sum = 0;
while (iter.hasNext())
{
Integer num = (Integer) iter.next();
sum + = num.intValue();
}
return sum/l.size();
}
Now that 'sum' is a double, we shouldn't have any more trouble. Let's
have a look...
java -cp
junit.jar;. junit.textui.TestRunner
org.dneary.math.test.MathOpsTest
... Average: 4.5
Time: 0,021
OK (3 tests)
And it's OK.
Testing edge cases
You might now say to yourself that we're finished and pass directly on
to the next stage (max/min/median/whatever). Absolutely not! With
experience, you realise that the most common problems are not in the
most common cases (which are, after all, the most tested), but special
cases: we overrun the limits of an array by 1, or barf on a null object
or an empty list. We have to write two other tests for our method, by
defining its behavior in these special cases:
// an empty list should have an average of NaN
public void test_average_empty ()
{
Vector nums = new Vector();
assertTrue(MathOps.average(nums) == Double.NaN);
}
// a null object should have an average of NaN
public void test_average_null ()
{
assertTrue(MathOps.average(null) == Double.NaN);
}
And we run our tests again... This time after removing the println we
added in the last test.
java -cp
junit.jar;. junit.textui.TestRunner
org.dneary.math.test.MathOpsTest
....F.E
Time: 0,02
There was 1 error:
1)
test_average_null(org.dneary.math.test.MathOpsTest)java.lang.NullPointerException
At org.dneary.math.MathOps.average(MathOps.java:10)
At org.dneary.math.test.MathOpsTest.test_average_null(MathOpsTest.java:40)
There was 1 failure:
1)
test_average_empty(org.dneary.math.test.MathOpsTest)junit.framework.AssertionFailedError
At org.dneary.math.test.MathOpsTest.test_average_empty(MathOpsTest.java:34)
FAILURES!!!
Tests run: 5, Failures: 1, Errors: 1
Now then... What happened?
For the first test, we don't check the value passed to the function at
all. Being null, when we ask for its Iterator it doesn't go down too well!!!
We add this to the top of average():
if (l == null)
return Double.NaN;
And we run the tests again...
java -cp
junit.jar;. junit.textui.TestRunner
org.dneary.math.test.MathOpsTest
....F.F
Time: 0,02
There were 2 failures:
1)
test_average_empty(org.dneary.math.test.MathOpsTest)junit.framework.AssertionFailedError
At
org.dneary.math.test.MathOpsTest.test_average_empty(MathOpsTest.java:34) 2)
test_average_null(org.dneary.math.test.MathOpsTest)junit.framework.AssertionFailedError
At org.dneary.math.test.MathOpsTest.test_average_null(MathOpsTest.java:40)
FAILURES!!!
Tests run: 5, Failures: 2, Errors: 0
But why, why??? ... We return Double.NaN in both cases - one explicitly,
and the other by doing a division by 0 (sum/l.size()), that should be OK...
But no. To check whether a number is NaN, we have to use the method
Double.isNaN(), because Double.NaN != Double.NaN (go figure). This time
it's our test which has a bug.
So, let's change the test...
// an empty list should have an average of NaN
public void test_average_empty ()
{
Vector nums = new Vector();
assertTrue(Double.isNaN(MathOps.average(nums)));
}
// a null object should have an average of NaN
public void test_average_null ()
{
assertTrue(Double.isNaN(MathOps.average(null)));
}
And we rerun our tests...
java -cp
junit.jar;. junit.textui.TestRunner
org.dneary.math.test.MathOpsTest
.....
Time: 0,01
OK (5 tests)
Now, we can move on to the next problem.
Conclusion
After all that, we find ourselves with a java class 25 lines long
containing a single method, and a test class 45 lines long containing 5
methods... I can hear you all already... 'It's a waste of time...'
No! Definitely not...
Let's have a look at the code: our 5 test methods are very small, and
they each test one aspect of our main method. We have defined what
occurs in the edge cases of the method. And we are sure that it works.
We can now forget this method entirely until the moment that one of
these tests starts to fail (changing code, feature additions, bug
fixes...). These 5 tests, run automatically (once per day, after the
Nightly Build, from a cron job, ideally) will let us know very quickly...
We should compare the time to write the Junit tests with the time we
spend testing code that's already written (after a few days or a few
weeks, or better still code which you haven't written yourself...) This
way, the functionality is tested once and only once in an automatic way
without having to work on the code. And the tests are written by the
person writing the code when it is freshest in his mind, not several
months afterwards by someone who doesn't understand what's happenning.
Unit tests have to be done in the lifetime of a piece of code. These
tests can be done manually, or in automatically. The advantage of
writing tests we run automatically is that as soon as they are done, we
can run them 100 times very quickly. The only problem is that you have
to think about what your code is supposed to do before you write them,
and writing the test the first time takes longer than testing it manually.
But is it really a problem to think about your code before you write it?
And isn't it obvious that the time wasted by writing a test is saved
thereafter by avoiding manual tests and regressions?
Copyright David Neary, 2003
 This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 2.5 License.
This page has been translated into Spanish by Maria Ramos from Webhostinghub.com.
About the author, David Neary.
USERS COMMENTS
|