Unit Testing

Kinds of Testing

Classified by how much of a system is being tested

Classified by what is being tested

Classified by testing technique

Also see Wikipedia's software testing category page.

The Importance of Unit Testing

Unit testing is crucial. It is done by developers, not QA. In many organizations, unit tests are required: programmers are not allowed to check-in code or build a system unless their unit tests pass.

Links:

How NOT to Unit Test

Sometimes one wonders if the world is divided into (1) practitioners that know how to unit test and (2) textbook writers that don't know squat about it.

Some textbook writers (I won't name them here) show you pathetic "test drivers" that go through a class's methods and call System.out.println() a lot.

THIS IS TOTAL CRAP

Like someone's gonna sit there are run the program a gazillion times during debugging and read the output off the screen?!?

The Right Way To Unit Test

Unit tests should be non-interactive. They are made up of a huge number of statements that exercise some code and compare the computed result with the expected result. (Duh! See: the computer checks the result, not a slow dim-witted human.) A test driver can report the number of successes and number of failures when it's finished.

An Example Class to Test

Let's test this money bag class

import java.math.BigDecimal;
import java.util.Map;
import java.util.TreeMap;
import java.util.Map.Entry;

/**
 * A little class for holding various amounts of different currencies.
 * Example values that a bag can hold are:
 * <pre>
 *   [ ]  (nothing)
 *   [ CHF:1.5 USD:3.0 ]  (3 U.S. Dollars and 1.5 Swiss Francs)
 * </pre>
 * <p>Negative amounts are allowed.  Currencies are supposed to be
 * legal ISO 4217 codes, but any three-character string made up
 * of capital letters in A..Z are allowed.</p>
 */
public class MoneyBag {

    private Map<String, BigDecimal> contents =
        new TreeMap<String, BigDecimal>();

    /**
     * Returns how much of a given currency is in the bag.
     */
    public BigDecimal getAmount(String currency) {
        checkCurrencyCode(currency);
        BigDecimal amount = contents.get(currency);
        return amount == null ? BigDecimal.ZERO : amount;
    }

    /**
     * Adds (or removes) a bit of a certain currency in the bag.
     */
    public void add(BigDecimal amount, String currency) {
        checkCurrencyCode(currency);
        BigDecimal newAmount = getAmount(currency).add(amount);
        if (BigDecimal.ZERO.compareTo(newAmount) == 0) {
            // Remove the entry for the currency if the amount goes to zero.
            contents.remove(currency);
        } else {
            contents.put(currency, newAmount);
        }
    }

    /**
     * Returns the number of distinct currency types with non-zero
     * amounts.
     */
    public int numberOfCurrencies() {
        return contents.size();
    }

    /**
     * Returns a textual description of the bag in the form
     * "[ c1:amount1 c2:amount2 ... cn:amountn ]".  The currencies
     * are given in alphabetical order.
     */
    public String toString() {
        StringBuffer buffer = new StringBuffer("[ ");
        for (Entry<String, BigDecimal> entry: contents.entrySet()) {
            buffer.append(entry.getKey() + ":" + entry.getValue() + " ");
        }
        buffer.append("]");
        return buffer.toString();
    }

    /**
     * Throws an exception if the given currency code is not a three
     * letter string consisting solely of the letters A-Z.
     */
    private void checkCurrencyCode(String currency) {
        if (currency == null || !currency.matches("[A-Z]{3}")) {
            throw new IllegalArgumentException("Currency code must be "
                    + "three characters long and contain A-Z only");
        }
    }
}

Selecting Tests

Here are some important tests for the money bag example

  1. Create a new bag; ensure there is nothing in it.
  2. Create a new bag; ensure its toString() method produces "[ ]".
  3. Insert a currency with value 0 to an empty bag; ensure the bag is still empty.
  4. Ensure getters of aritrary currencies on an empty bag return exactly zero.
  5. Insert a non-zero amount to an empty bag; ensure there is only one currency type, the toString() works as expected, and the getter for that currency type returns the value just added.
  6. Add an amount of a currency already present; ensure the number of currency types is unchanged and the getter for that currency returns the new amount.
  7. Add an amount that makes an existing currency type become zero; ensure the number of currency types decreases by one.
  8. Make sure IllegalArgumentExceptions are thrown for each method that can throw them.

Is that enough? I don't think so. We can go on and on.

Exercise: Write some more things to test.

Writing Your Own Test Framework

It is possible, but not recommended, to write your own tester from scratch:

import java.math.BigDecimal;

/**
 * A unit test for the MoneyBag class, written from scratch.  Real Java
 * programmers use JUnit, so this class is only good for illustrating
 * the kinds of things you would consider if you had to write a testing
 * framework yourself (which for Java would be never).
 *
 * <p>This tester is pretty lousy because it doesn't tell you which
 * tests failed and why.  Besides, since we are counting tests and failures
 * manually, other classes that need testing can't share this code.  This
 * is why we need frameworks like JUnit.</p>
 */
public class MoneyBagTester {

    private static int tests = 0;
    private static int failures = 0;

    private static void expect(boolean condition) {
        tests++;
        if (!condition) failures++;
    }

    // Utility class - do not instantiate
    private MoneyBagTester() {
    }

    public static void main(String[] args) {
        MoneyBag bag = new MoneyBag();
        expect(bag.numberOfCurrencies() == 0);
        expect(bag.toString().equals("[ ]"));
        bag.add(BigDecimal.ZERO, "USD");
        expect(bag.numberOfCurrencies() == 0);
        expect(bag.getAmount("CHF").equals(BigDecimal.ZERO));
        expect(bag.getAmount("CAD").equals(BigDecimal.ZERO));
        expect(bag.getAmount("EEK").equals(BigDecimal.ZERO));
        bag.add(BigDecimal.ONE, "USD");
        expect(bag.numberOfCurrencies() == 1);
        expect(bag.getAmount("USD").equals(BigDecimal.ONE));
        bag.add(new BigDecimal("1.50"), "USD");
        expect(bag.numberOfCurrencies() == 1);
        expect(bag.getAmount("USD").equals(new BigDecimal("2.50")));
        bag.add(new BigDecimal("200.0"), "COP");
        expect(bag.numberOfCurrencies() == 2);
        expect(bag.toString().equals("[ COP:200.0 USD:2.50 ]"));
        bag.add(new BigDecimal("-2.50"), "USD");
        expect(bag.numberOfCurrencies() == 1);
        expect(bag.toString().equals("[ COP:200.0 ]"));
        try {bag.getAmount("abc"); expect(false);}
        catch (IllegalArgumentException e) {expect(true);}
        try {bag.getAmount(null); expect(false);}
        catch (IllegalArgumentException e) {expect(true);}
        try {bag.getAmount("AB"); expect(false);}
        catch (IllegalArgumentException e) {expect(true);}
        try {bag.add(BigDecimal.TEN, "ABCD"); expect(false);}
        catch (IllegalArgumentException e) {expect(true);}
        System.out.println(tests + " test(s), " + failures + " failure(s)");
    }
}
Exercise: Add tests to this tester.

JUnit

When you start to have to do a lot of testing, it helps to have a framework to do a lot of the work for you. JUnit is the framework practically every Java programmer uses.

JUnit, developed by Kent Beck and Erich Gamma, is almost indisputably the single most important third-party Java library ever developed. As Martin Fowler has said, "Never in the field of software development was so much owed by so many to so few lines of code." JUnit kick-started and then fueled the testing explosion. Thanks to JUnit, Java code tends to be far more robust, reliable, and bug free than code has ever been before. — Eliotte Harold

Here is a test for the MoneyBag class

import static org.junit.Assert.assertEquals;

import java.math.BigDecimal;

import org.junit.Before;
import org.junit.Test;

/**
 * JUnit test for the money bag class.
 */
public class MoneyBagTest {

    private MoneyBag bag;

    /**
     * All tests should begin with a fresh bag.
     */
    @Before
    public void initializeBag() {
        bag = new MoneyBag();
    }

    /**
     * Tests that a bag is empty upon creation, and stays empty if
     * you add a zero amount of some currency, and that retrieving
     * currencies that don't exist actually give a zero amount instead
     * of throwing an exception or otherwise crashing.
     */
    @Test
    public void testEmptyBag() {
        assertEquals(bag.numberOfCurrencies(), 0);
        assertEquals(bag.toString(), "[ ]");
        bag.add(BigDecimal.ZERO, "USD");
        assertEquals(bag.numberOfCurrencies(), 0);
        assertEquals(bag.getAmount("CHF"), BigDecimal.ZERO);
        assertEquals(bag.getAmount("CAD"), BigDecimal.ZERO);
        assertEquals(bag.getAmount("EEK"), BigDecimal.ZERO);
    }

    /**
     * Tests various methods on non-empty bags.
     */
    @Test
    public void testNonEmptyBag() {
        bag.add(BigDecimal.ONE, "USD");
        assertEquals(bag.numberOfCurrencies(), 1);
        assertEquals(bag.getAmount("USD"), BigDecimal.ONE);
        bag.add(new BigDecimal("1.50"), "USD");
        assertEquals(bag.numberOfCurrencies(), 1);
        assertEquals(bag.getAmount("USD"), new BigDecimal("2.50"));
        bag.add(new BigDecimal("200.0"), "COP");
        assertEquals(bag.numberOfCurrencies(), 2);
        assertEquals(bag.toString(), "[ COP:200.0 USD:2.50 ]");
        assertEquals(bag.getAmount("EEK"), BigDecimal.ZERO);
    }

    /**
     * Tests that making a currency value go to 0 removes the currency
     * from the bag.
     */
    @Test
    public void testCancellation() {
        bag.add(BigDecimal.ONE, "USD");
        bag.add(new BigDecimal("1.50"), "USD");
        bag.add(new BigDecimal("200.0"), "COP");
        assertEquals(bag.numberOfCurrencies(), 2);
        bag.add(new BigDecimal("-2.50"), "USD");
        assertEquals(bag.numberOfCurrencies(), 1);
        assertEquals(bag.toString(), "[ COP:200.0 ]");
    }

    /**
     * Tests that uses of illegal currency names throw the right
     * exception.
     */
    @Test(expected=IllegalArgumentException.class)
    public void testTheeCharactersButNotAllUpperCase() {
        bag.getAmount("aBC");
    }

    @Test(expected=IllegalArgumentException.class)
    public void testNullCurrencyCode() {
        bag.getAmount(null);
    }

    @Test(expected=IllegalArgumentException.class)
    public void testLessThanThreeCharacters() {
        bag.getAmount("AB");
    }

    @Test(expected=IllegalArgumentException.class)
    public void testMoreThanThreeCharacters() {
        bag.getAmount("CCDE");
    }
}

If you are developing with an IDE like Eclipse, you can select "Run as JUnit test case" from a menu, and voila! (Yes, every decent IDE already knows about JUnit and supports it out of the box — you'll mos def find the file junit.jar or something similarly named in your IDE distro.)

juniteclipse.png

You could also run the test case directly from the command line:

$ javac -cp .:junit-4.1.jar MoneyBagTest.java

$ java -cp .:junit-4.1.jar org.junit.runner.JUnitCore MoneyBagTest
JUnit version 4.1
.......
Time: 0.125

OK (7 tests)

In a large application you might have hundreds or thousands of test files, and you might launch all of them from an ant script. Something like this:

<project name="...">
    ...
    <target name="test" depends="compile-tests"
        description="Runs all unit tests">
        <junit printSummary="true"
            haltOnFailure="true"
            fork="true">
            <batchtest todir="${test.output.todir}">
                <fileset dir="${test.src.dir}">
                    <include name="**/*Test.java" />
                </fileset>
            </batchtest>
        </junit>
    </target>
    ...
</project>

Unit Testing in Practice

Exercise: What's a boundary condition anyway? Do some research. Write a three-page paper about them. Give a bunch of examples.