Covering the Edge Cases
We have tested an empty list, a single add and get, and an out-of-range index. Bugs are more likely at the edges of the behavior than in the common case. An add that looks correct may still mishandle the first element added to an empty list. A get may be right everywhere except the very last index. A remove may work until it has to remove the only element.
The same small set of edge cases comes up for almost every collection. For a list, a thorough suite includes:
- The empty list. Size is
0;getat any index throws;containsis false;removereturns false. The starting state needs its own tests because a lot of code has a special case for an empty collection. - A single element. Adding one element should grow the size to
1, makecontainsreturn true, and make the element retrievable at index0. The change from an empty list to a list with one element is where many off-by-one bugs occur. - Several elements. Elements come back in the order they were added;
getreturns the correct element at every valid index. - Duplicates. Add the same value twice and check exactly what the contract promises:
indexOfreturns the first occurrence,removedeletes the first occurrence,containsis still true afterward. - The boundaries of the index. Index
0and indexsize - 1are valid and must return the right element; index-1and indexsizeare invalid and must throw. The boundary between valid and invalid indices is a common source of bugs. - Removal positions. Removing the first element, a middle element, the last element, the only element, and an element that is not present (which returns false and changes nothing) are different code paths.
- Edge arguments. If the contract says something about unusual arguments, such as a
nullelement, write a test for what it says.
Let’s write a few of these as tests:
@Test
public void indexOfReturnsFirstOccurrence() {
list.add(7);
list.add(7);
assertEquals(0, list.indexOf(7));
}
@Test
public void removeOnlyElementReturnsTrue() {
list.add(99);
assertTrue(list.remove(99));
}
@Test
public void removeOnlyElementLeavesEmpty() {
list.add(99);
list.remove(99);
assertEquals(0, list.size());
}
@Test
public void removeMissingElementReturnsFalse() {
list.add(1);
assertFalse(list.remove(2));
}
@Test
public void removeMissingElementLeavesSizeUnchanged() {
list.add(1);
list.remove(2);
assertEquals(1, list.size());
}
Notice that JUnit has assertTrue and assertFalse in addition to assertEquals. The library provides a few other assertions, such as assertNull and assertNotNull, which are useful for testing the occasional method that returns a reference that may be null. We also used fail inside the try/catch idiom to assert that a method throws an exception when it is supposed to.
These edge-case tests are in ListTest, so they run against ArrayList and LinkedList. The edge cases are where two implementations differ most. A linked list’s node traversal can be off by one at the last index. An array-backed list can be wrong when the array is full and has to grow.
Sometimes you go to write an edge-case test and discover the contract never said what should happen. What does add(null) do? Does it store the null, or reject it? When a test makes you ask that question, you have found a missing case in the specification. I think a good test suite is also a good specification. If the contract does not say what should happen, the test says it, and seeing the test helps the implementer understand what is expected.