CSE 373, Spring 2018: JUnit guide

About JUnit

JUnit is a popular library used to help you unit test your code. Although it comes packed with all kinds of features to help you write sophisticated tests, we'll be using only a few of these features.

This guide covers JUnit only on a basic level. If you want to use some of the more advanced JUnit features, feel free to do so. Note: if you plan on doing this, keep in mind that we are using JUnit 4 in this class. JUnit 5 was released very recently, so make sure you're looking at the correct version of the documentation.

The anatomy of a JUnit test suite

Here's a basic example of what a JUnit test suite looks like:

package mypackage;

import static org.junit.Assert.assertTrue;
import org.junit.Test;

import datastructures.concrete.DoubleLinkedList;
import datastructures.interfaces.IList;

import misc.Cse373BaseTest;

public class TestDoubleLinkedListSimple extends Cse373BaseTest {
    @Test
    public void testAddAndRemove() {
        IList<Integer> list = new DoubleLinkedList<>();

        list.add(3);
        list.add(7);
        list.add(9);

        assertTrue(list.remove() == 9);
        assertTrue(list.remove() == 7);
        assertTrue(list.remove() == 3);
    }

    @Test(timeout=1000)
    public void testSize() {
        IList<Integer> list = new DoubleLinkedList<>();

        int amount = 5134;

        for (int i = 0; i < amount; i++) {
            list.add(i);
        }

        assertTrue(list.size() == amount);
    }
}

A few things to note:

  1. Line 3 is importing the assertTrue(...) static method. This method will do nothing if the argument you pass in is true, but will cause the current test to fail if the argument is false.

    (For more details on what exactly a static method is, see below.)

  2. A JUnit test suite is a regular class consisting of one or more methods marked with the @Test annotation. Each one of these methods counts as a single unit test. So, this example class contains two unit tests.

    Unit test names are typically prefixed with the word "test" followed by a description of what they're testing. This is only a convention: you can name the methods whatever you want, as long as you remember to add the @Test annotation.

    (For more details on what exactly an annotation is, see below.)

  3. A unit test method will almost always contain one or more calls to some "assert" method imported from org.junit.Assert.

  4. The @Test annotation can take an optional "timeout" parameter. For example, see line 25. The timeout is set to 1000 milliseconds, or one second. So, if the testSize method isn't done after 1 second, that test will fail.

    We strongly recommend you add a timeout to all unit tests you write.

  5. Our test suites will always ultimately extend the Cse373BaseTest class, which contains a few useful utilities and workarounds for JUnit quirks.

    Normally, when using JUnit, there's no need to extend any class.

  6. Although our example does not demonstrate this, your may add and call as many private helper functions you wish. Just remember, do NOT add the @Test annotation to your helper methods! After all, your helper methods are not themselves tests.

  7. JUnit will call your test methods in basically random order. Make sure you don't assume they run in any particular order or try to share any data between them!

What are static imports?

A static import is a way of directly importing a static method, as opposed to a class.

Static imports are useful mainly because they make typing code a little easier. For example, suppose you were writing a program involving a lot of math. Normally, you'd need to scatter your code with calls to things like Math.sin(...) and Math.cos(...).

Instead, what you could do is add the following lines:

import static java.lang.Math.sin;
import static.java.lang.Math.cos;

// Or, if you want everything:
import static.java.Math.*;

Now, you no longer need to add Math. in front of everything and can just do sin(...) and cos(...), which is pretty convenient.

What are annotations?

Annotations are a form of metadata that you can attach to declarations, types, and certain expressions.

Annotations will have no direct effect on what the program does when run. Rather, they're used to do one of the following:

  1. Provide information to the Java compiler
  2. Provide compile-time information to certain tools and libraries
  3. Attach extra information that can (sometimes) be inspected during runtime when doing meta-programming related things.

The @Test annotation has no special meaning to the Java compiler -- it's used primarily by the JUnit library.

Another annotation you might have encountered already is the @Override annotation. When you mark a method with this annotation, you're telling Java that method is supposed to have overridden some other method in a super class. If the annotated method isn't overriding anything (perhaps you made a typo?) the Java compiler will refuse to compile your code.

Basically, once everything is compiled, the @Override annotation has zero impact on your code. It exists mainly to help the compiler catch mistakes you might have made.

More "assert" methods

You can use the assertTrue method for almost anything, but JUnit provides a few other "assert" methods that you may find handy. Here's an exact list of all of the methods available; here's a summary of the more relevant ones:

  • assertTrue(boolean condition)
    assertTrue(String message, boolean condition)

    Asserts that the condition is true (and fails the test if it isn't). You can optionally provide a custom error message that's printed on failure as the first argument.

  • assertEquals(T expected, T actual)
    assertEquals(String message, T expected, T actual)

    Asserts that the two objects are equal to one another, using their .equals(...) method. If the two objects aren't equal, JUnit will display a nice error message containing the expected and actual result. You can also override this error message with your own.

  • assertArrayEquals(T[] expected, T[] actual)
    assertArrayEquals(String message, T[] expected, T[] actual)

    Same as above, except for arrays.

  • fail()
    fail(String message)

    Causes the test to fail, either with no error message or with a custom one.

Testing exceptions

Testing that you're throwing the correct exception is unfortunately a little awkward. Here's an example of how to do it:

@Test(timeout=1000)
public void testRemoveCorrectlyThrowsEmptyContainerException() {
    IList<Integer> list = this.makeList();

    try {
        // Try running the operation we expect should throw an exception
        // inside of a "try" block
        list.remove();

        // If it somehow succeeds, that's bad, and we want to fail.
        fail("Expected an EmptyContainerException");

    } catch (EmptyContainerException ex) {
        // We're going to try catching the specific exception we're
        // looking for. If we catch it, we do nothing: everything
        // is ok. It's good practice to include a comment mentioning
        // this, though.
    }

    // If the method somehow throws a *different* unexpected exception,
    // the "catch" block won't catch it and the test will fail.
    // For example, if list.remove() throws a NullPointerException, something
    // has probably gone horribly wrong and we *want* our test to fail.
}

If you have never seen a try-catch block before, here's more information on what they are.

As a note of warning: as a matter of style, the only place you should be using try-catch blocks is within your tests. There are times when using try/catch blocks inside your main code is the correct thing to do, but you will never encounter those scenarios within this class.