“Test Interface, Not Implementation”
Perhaps you’ve heard the phrase “Test interface, not implementation”, or its relative, “Test behavior, not implementation”. We’ll look at this from the TDD perspective, growing a system in small steps of testing and refactoring.
Note: I haven’t found the origin of either phrase:( but have heard both for years.
Terms
Interface – By interface, we mean the set of public method signatures and variables, that a class or object provides, and their defined effects (not visible in the signatures).
Some languages have a construct that defines this as a separate entity: interface in Java, protocol in Swift, abstract base classes (sort of) in C++. These usually allow some sort of inheritance. These constructs define interfaces (in our sense), but a class has an interface whether or not interface constructs are involved.
Behavior – Behavior is the effect of calling a method, affecting the object we called or another object.
You have two basic ways to find out what happened:
- Examine the object we called, by looking at a field or calling a method that returns information about the object.
- Do the same on another object that this object affected.
Implementation – Implementation is how something is done, as opposed to what is done. An interface could be implemented in any of several ways. For example, a set might be represented as a list, a binary tree, a hash table, or more.
Testing Interface/Behavior
A test written in terms of interface and behavior is known as a black-box test. Such a test must change if the intended behavior changes. But it will not change if the implementation changes (without changing the interface).
Such tests are longer-lived and easier to understand.
The alternative (sometimes called clear-box tests) depends on some aspect of the implementation, and is prone to change when the implementation does.
Clear-box tests make TDD harder. Part of TDD is refactoring as we better understand our problem or our implementation. One definition of refactoring is “changing software to improve its implementation without affecting its interface”. If our tests rely on implementation, our tests are brittle: we end up having to change tests for minimal benefit.
Encapsulation
“Test interface, not implementation” is another path to encouraging encapsulation.
An encapsulated object has two parts: an interface and an implementation. Several different implementations can support a given interface. The interface is intended to be more stable than the implementation.
Without encapsulation, any change anywhere in the class may affect clients. Since clients can depend on any design details, they have to change if the details do.
With encapsulation, changes that don’t affect the interface have to impact on clients:
Encapsulation makes refactoring more valuable: refactoring in the implementation means clients are not affected. The interface serves as a barrier preventing a ripple of changes.
Examples of Not Testing Interfaces/Behavior
1. Test-only methods. You might create methods strictly for tests, that reveal internal information. When the internals change, you may have to rewrite those tests or change the test-only methods. Expediency now creates cost later.
If you really must have such methods, some languages provide “extensions” that let you add methods to an existing class. By restricting these methods to your test package, you can at least make sure production classes don’t see them.
2. Relying on not-promised behavior. A method signature is only a partial definition of an interface; your tests need to respect the intended meaning as well.
For example, a set might provide an iterator, but doesn’t promise to return values in a particular order. Suppose your implementation naturally produces values in sorted order. (Perhaps it keeps them in a binary tree or sorted list.)
If your tests assumes the items are returned in order, your tests will break if you change to a hash table for better performance.
Instead, consider providing an equality check that compares the sets directly.
3. Implementation-revealing methods. Your interface may reveal things it shouldn’t. (This may be more about the interface than the tests.)
For example, you may have methods with public access that really shouldn’t be part of your interface.
Or, you may provide access to a field. If you make it public so others can read it, you also let them write it. That might destroy relationships the class relies on. But at least C# and Swift have “private set” declarations that let you provide read-only access. (In other languages, you might keep the field private and provide a method to read it.)
This problem is especially likely if you share an array. If you’re not careful, callers might modify the array contents unexpectedly.
So pay attention to what your interface reveals – is it hiding the decisions you want to protect?
Conclusion
- Encapsulation separates interface from implementation.
- Refactoring in the implementation will not break clients – including tests.
- This avoids brittle tests and reduces the cost of change, allowing more freedom to refactor.
- Remember that an interface is not just the method signatures – it’s also the intended meaning (behavior) of those methods!

