Skip to main content

Section 10.7 Interfaces, Decoupling, and Testing

Perhaps one of the biggest advantages of using interfaces is that they help us decouple our classes, make them less dependent on each other. This is really important for unit testing:
The goal of a unit test is to test a very specific behavior: If that behavior is not implemented correctly, ideally that test, and only that test, should fail. And conversely that test should only fail if that behavior is not implemented correctly, and not for other reasons.
That’s our ideal, let’s talk about getting there. For example suppose that we would like to test our Summary class. In the old implementation we had to provide it with a GradeReader object. The class would then use that object to get some grades, then do its summary work for us. So here’s how a test might look like:
public void correctlySummarizeOneCourse() {
  GradeReader reader = GradeReader.fromString("CS 220 A");
  Summary summary = new Summary(reader);
  assertEquals("Courses: 1\nGPA: 4.00\n",
               summary.invoke());
}
Many things wrong with this as a test, see if you can use what you learned about unit tests to improve it. But for now we want to focus on something else about it:
Let’s assume this test fails. Why did it fail? Is it because there is something wrong with our Summary class? Or is it because our GradeReader class didn’t do its job properly?
You see the problem? We cannot tell the two apart, not without tracing the code step by step through a debugger or something like that. This is often called confounding in statistics; the error effects from the two classes are not isolated from each other. We really want to test the Summary class, but it has to bring its buddy with it. And that’s just not going to work for us!
Now the interfaces offer us a way out. The Summary class doesn’t actually need a GradeReader object, it needs an object implementing the Iterator<Grade> interface. And we could easily create that, from a List for example. So here is how we would do the same test: Create a Grade object, put it in a List, then get the iterator from that list and pass that in.
public void correctlySummarizeOneCourse() {
  List<Grade> grades = List.of(new Grade("A"));
  Iterator<Grade> reader = grades.iterator();
  Summary summary = new Summary(reader);
  assertEquals("Courses: 1\nGPA: 4.00\n",
               summary.invoke());
}
No longer depending on the GradeReader and its messy work! That’s a win.
Of course, we still depend on a Grade object, we still depend on List.of doing the right thing of making a list of the things we pass to it. And we still depend on grades.iterator() returning an appropriate iterator.
But those are things we can feel more comfortable relying on. the Grade object is simpler in terms of what it does, and the other two are part of the standard Java libraries and have been battle-tested over probably billions of lines of code. They are not likely to have a problem. So if the test fails, it has to be because our Summary is not doing its job (or because we wrote the wrong test).
We still have to write tests for GradeReader of course, but those would be testing the GradeReader functionality only, as they should.
Programming to an interface allows us to decouple our classes and we can test them in isolation, making our tests more robust.