Unit tests with JUnit

By David Neary .

 

Contents:

  1. Why write tests?
    1. To test code
    2. To document interfaces
    3. To locate bugs
    4. To fix bugs
  2. JUnit
  3. How to use it
    1. Starting a class
    2. Full speed ahead
  4. Testing edge cases
  5. 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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值