Lab: Unit Testing

Steven J Zeil

Last modified: May 3, 2023
Contents:

This is a self-assessment activity to give you practice in working with Java unit test frameworks. Feel free to share your problems, experiences, and choices in the Forum.

1 Set Up Your Project

  1. Start with your refactored code from this earlier lab. Close your IDE if you have it open.

  2. Download and unpack this code into a convenient directory outside of your project.

  3. Look at the package declarations at the top of the unitTestLab/testing/TestInterval.javax and unitTestLab/testing/TestRanges.javax files. Then move those files into the appropriate source code directory in your project, renaming them as .java isntead of .javax. (Remember, in Java, package names == directory names!).

  4. Next, we want to set up this project to support a unit test framework. Open the project in your IDE.

    • In VSCode, jars that are in the lib/ directory are automatically added to the project. In this case, the JUnit and Hamcrest libraries should be detected.

      You can verify this by looking for the “JAVA PROJECTS” area near the bottom of the Explorer column, expanding it to see your project, and expanding the “Referenced Libraries” list.

    • In Eclipse, you will need to configure your project’s Build Path. Right-click on the project name and select Build Path, then Configure Build Path.... Go to the Library Tab,

      • Select “Classpath”, then click Add Library.... Select Junit, Next, JUnit 5, Finish.
      • Select “Classpath”, then click Add External Jars.... From the lib directory, select the “hamcrest” jar.
      • Go to the “Order and Export Tab”. Move hamcrest above Junit 5.
      • Select “Apply and Close”.

    We will later make a point of automating this setup so that libraries get imported without all this manual manipulation.

  5. Examine TestInterval.java and TestRanges.java. They should compile without errors.

  6. Run the tests.

    • In Eclipse, right-click on the package name edu.odu.cs and select Run as..., then “JUnit test”.

    • In VSCode, click the testing icon on the left (the laboratory flask). Hover your mouse over the package name, and click the triangular Run button.

    The tests should run successfully.

  7. Try changing one of the test assertions “…is(true)…” to “…is(false)…”. Rerun the tests and observe what a test failure looks like in your IDE.

    Change it back to “…is(true)…”.

  8. JUnit tests can be run from the command line. In a terminal, cd to your project directory and give the following command:

    java -jar ./lib/junit-platform-console-standalone-*.jar -cp '.:lib/*' -c edu.odu.cs.TestInterval
    

    You should see that the test runs and is successful.

    This is, admittedly, a fairly awkward way to run tests. Again, when we later cover build management, we will see cleaner ways of integrating test execution into projects.

  9. Finally, read the tests in TestInterval.java.

    Look in particular at testBelow(). Do you see how it follows the pattern of 1) setting up data that will be needed (the interval object), 2) calling the mutator function (below()) and then 3) examining the effects of that call on each of the accessor functions in the Interval class?

    Do you understand what each of the assertions says about the value of the Interval object below75 following the most recent mutation?

2 Write Some Tests

Look now at TestRanges.java. Again, make sure that you understand what is going on in the test code.

The one subtlety in this code is that Ranges is “iterable”, meaning that it provides an iterator over a sequence of objects, in this case, a sequence of Interval values.

Normally you would employ such an iterator like this:

Ranges r = new Ranges(...);
   ⋮   --- one or more r.remove(...) calls
Iterator<Interval> iter = r.iterator();
while (!iter.hasNext()) {
    Interval interval = iter.next();
    doSomethingWith(interval);
}

or, more simply, the iterator can be used implicitly in a range-based for loop like this:

Ranges r = new Ranges(...);
   ⋮   --- one or more r.remove(...) calls
for (Interval interval: r) {
    doSomethingWith(interval);
}

As it happens, Hamcrest provides a convenient contains matcher for comparing the entire sequence of objects provided by an iterable object to an array or other sequence.

   Interval[] expected = {interval1, interval2, interval3};
   assertThat (r, contains(expected)); // r's iterator must provide a sequence of 3
                                       // objects equal to the ones in the array expected

The tests in TestRanges.java are not complete. Try to finish them.

Question: What new @Test functions will you need?

reveal
  1. Add your tests to TestRanges.java. Compile and run them.

    They should compile, and should pass. Fix them if necessary.

  2. Copy the file Ranges.java to Ranges.java.original. The copy variations/Ranges.java.instrumented into your src/main/java/edu/odu/cs/Ranges.java directory, replacing the old Ranges.java.

    (In Eclipse, refresh your project again after doing so.)

    This file has been instrumented to report on how well your tests exercise the mutator and accessor functions during testing.

  3. Compile and run the tests again. At the end of testing, you will get a report indicating whether any mutators were not checked with one or more accessors.

    Fix your tests, if any combinations are reported as not having been tested.

3 Catch Some Bugs

  1. Copy the file unitTestLab/variations/Ranges.java.bug1 into src/main/java/edu/odu/cs/Ranges.java.

    (In Eclipse, refresh your project again after doing so.)

    This file contains a buggy implementation of class Ranges.

  2. Compile and run the tests again. Did your tests catch the bug? If not, perhaps your tests are not aggressive enough.

    One thing to consider is how you might apply some of the conventional blackbox testing strategies (functional coverage, boundary value testing, & special value testing) to testing the remove function. For example, you might consider functional cases of

    • Removing an interval that overlaps the low end of a range.
    • Removing an interval that overlaps the high end of a range.
    • Removing an interval from the interior of a range.

    For boundary/special cases, you might consider

    • Remove an interval that covers the entire range.
    • Remove an interval that lies entirely outside the range.
    • Remove an empty interval from the range.

    Now, you can try to cram all of these inside a single testRemove function, but I think you get something a great deal more readable if you put each case inside a separate, appropriately named, @Test function.

  3. Repeat the above steps with variations/Ranges.java.bug2

  4. When you are done, restore the original Ranges.java file by checking out the unchanged version from your repository:

    git checkout HEAD -- src/main/java/edu/odu/cs/Ranges.java
    

    (or use the git functions in your IDE).

If you would like to see my full test of tests for this class, you can see it here:

TestRangesComplete.java
package edu.odu.cs;

import org.junit.jupiter.api.Test;
//import static org.junit.jupiter.api.Assertions.*;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;

/**
 * Test of the Ranges class
 */
public class TestRangesComplete {

    double precision = 0.001;

    @Test
    public void testConstructor() {
        Ranges ranges = new Ranges(1.0, 100.0);
        assertThat(ranges.sum(), closeTo(99.0, precision));

        Interval[] expected = { new Interval(1.0, 100.0) };
        assertThat (ranges, contains(expected)); // checks ranges.iterator()
    }

    @Test
    public void testSubtractLowEnd() {
        Ranges ranges = new Ranges(10.0, 100.0);
        Interval interval = new Interval(0.0, 20.0);
        ranges.remove(interval);
        assertThat(ranges.sum(), closeTo(80.0, precision));
        Interval[] expected = { new Interval(20.0, 100.0) };
        assertThat (ranges, contains(expected));
    }

    @Test
    public void testSubtractHighEnd() {
        Ranges ranges = new Ranges(10.0, 100.0);
        Interval interval = new Interval(90.0, 110.0);
        ranges.remove(interval);
        assertThat(ranges.sum(), closeTo(80.0, precision));
        Interval[] expected = { new Interval(10.0, 90.0) };
        assertThat (ranges, contains(expected));
    }

    @Test
    public void testSubtractInterior() {
        Ranges ranges = new Ranges(10.0, 100.0);
        Interval interval = new Interval(20.0, 90.0);
        ranges.remove(interval);
        assertThat(ranges.sum(), closeTo(20.0, precision));
        Interval[] expected = { new Interval(10.0, 20.0), new Interval(90.0, 100.0) };
        assertThat (ranges, contains(expected));
    }

    @Test
    public void testSubtractAll() {
        Ranges ranges = new Ranges(10.0, 100.0);
        Interval interval = new Interval(9.0, 110.0);
        ranges.remove(interval);
        assertThat(ranges.sum(), closeTo(0.0, precision));
        assertThat (ranges.iterator().hasNext(), is(false));
    }

    @Test
    public void testSubtractNone() {
        Ranges ranges = new Ranges(10.0, 100.0);
        Interval interval = new Interval(101.0, 110.0);
        ranges.remove(interval);
        assertThat(ranges.sum(), closeTo(90.0, precision));
        Interval[] expected = { new Interval(10.0, 100.0)};
        assertThat (ranges, contains(expected));
    }

    @Test
    public void testSubtractEmpty() {
        Ranges ranges = new Ranges(10.0, 100.0);
        Interval interval = new Interval(51.0, 50.0);
        ranges.remove(interval);
        assertThat(ranges.sum(), closeTo(90.0, precision));
        Interval[] expected = { new Interval(10.0, 100.0)};
        assertThat (ranges, contains(expected));
    }



}

That may look like a lot of code, but note that much of it is copied and pasted from the first testRemove... function, with only minor tweaks to each copy.