Lab: Unit Testing

Steven J Zeil

Last modified: Jan 7, 2022
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. Copy the testing/*.java files into the appropriate source code directory in your project (Pay attention to the package names!).

  4. Move the lib/ directory (the entire directory, not just its contents) to the top-level directory (the one containing src/) of your project.

  5. 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.

    • 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.

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

  7. Run the tests.

    • In Eclipse, right-click on VocabTest.java 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 test should run successfully.

  8. 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)…”.

  9. 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 eventually see cleaner ways of integrating external libraries into projects

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

    Look in particular at testBelow(). Do you see how it follows the pattern of invoking a mutator function (the below() function) and then 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 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 from the copy you saved as Ranges.java.original.

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.