Test Behavior, Not Implementation
Hello, and welcome back to The Coder Cafe! This week, we will explore unit tests and start with an essential principle in unit testing: testing behavior, not implementation.
After writing a function, it might seem natural to add a test to verify whether it works correctly. However, it’s important to realize that we shouldn’t always have a one-to-one relationship between functions and unit tests. Instead, our tests should focus on how the code behaves as a whole, not just on individual methods.
Consider writing an in-memory cache to store the balance for each customer ID. Our class contains a hashmap that stores the data and exposes methods to get and set the customer balance1:
class CustomerBalance {
private map<string, float> cache;
public get(id string) float { ... }
public set(id string, balance float) { ... }
}
One approach might be to create a test for get
and a test for set
. Each test checks the content of the cache
hashmap to verify that these methods work correctly. For example, with a test on get
:
void testGetBalance() {
val customer = newCustomerBalance();
customer.cache.set("foo", 42); // Update the internal cache
val balance = customer.get("foo"); // Query the get method
// Assert that balance equals 42
}
This approach tightly couples the test to implementation details. Why? Suppose that later, we decide to change the underlying data structure (e.g., switching from a hashmap to a B-tree for performance reasons); we would likely need to update our tests to accommodate these internal changes, even though the overall behavior hasn’t changed. This tight coupling can make our tests fragile and harder to maintain.
Now, let’s look at a different way to test the same class but focusing on the behavior of the CustomerBalance
class:
void testSetGetBalance() {
val customer = newCustomerBalance();
customer.set("foo", 42); // Update via the set method
val balance = customer.get("foo"); // Query the get method
// Assert that balance equals 42
}
Here, instead of directly interacting with the internal cache, the test interacts only with the class's public API. If we decide to change the internal data structure, this test will likely remain unchanged because the behavior of the class remains the same.
In this case, if we change the cache
data structure (say, for performance reasons, for instance), our test won’t change. The reason is that this test focuses on the behavior of the class, not its implementation.
NOTE: Make sure not to mix the concept of behavior-focused tests with behavior-driven development (BDD):
Behavior-focused tests: An approach to writing unit tests by focusing on the overall behavior of our code.
BDD: A methodology to foster collaboration between tech and non-tech people using testing specifications using a domain-specific language (DSL) to describe expected behavior through testing specifications. We’ll talk about it in a future issue.
Also, our tests don’t have to be one-to-one mappings with the methods of the class. Suppose we decide to change the implementation of set
to extract away some of the logic in a private method foo
. In this case, we shouldn’t have to write a test on foo
. Indeed, foo
isn’t public; it’s not part of the behavior exposed by CustomerBalance
; it’s an implementation detail. By focusing on behavior, as in the second approach, our test remains the same, unaffected by changes to the underlying implementation.
In most cases, favor testing behavior instead of implementation:
Maintainability: Behavior tests are more resilient to changes in the underlying implementation. This encourages refactoring and code optimization without the fear of breaking a bunch of tests.
Readability: Tests that focus on behavior are often easier to read and understand. They describe what the code does rather than how it does it, which can serve as effective documentation for other developers and aligns with what we discussed yesterday.
User-centric: By focusing on the behavior of a class, we align our tests with how an external client or user interacts with our code, which can also reveal opportunities to improve the API itself.
So, next time we write a unit test, let’s ask ourselves: am I testing a behavior or an implementation?
Tomorrow, we will discuss TDD… 😈
In pseudo-code.