Last time, we talked about mocks as abstraction breakers. The problem with mocks is that the test needs to know how a dependency gets called, in order to mock calls. This leads to coupling between the test and the code, something we’d like to avoid, as changes to implementation details may require to changes in the test. Testing is hard.
Mocks are not the only way to make tests more coupled, and therefore more fragile. Let’s talk about that, but first, a moment of distinction.
Abstraction vs Encapsulation
Abstraction is hiding complex information. Encapsulation does the same, mostly. So what’s the difference?
Let’s think about the an object. It has an interface. The interface abstracts the implementation. Basically we don’t need to know what’s happening behind the interface, as long as it’s clear enough, documented, and does what it promises.
This object has some private fields, not accessible to the caller. It also has some private methods, which are not part of the contract with the caller. It encapsulate those implementation details.
So far, it’s not that different. Different words for the same meaning. But now let’s expand our object to a system. We still have an interface, that the caller uses. The caller still wants to be oblivious to how the system is implemented. Only now, we’re talking about a system, with components, configuration and state. All behind our interface.
Encapsulation at the object level is easy to do, because we have language constructs for it. Declare a field as private and it “becomes” encapsulated. With a complex system, we don’t have that automatic encapsulation.
We still want it though. We want abstraction through encapsulation. But complexity makes it harder. Because now, working with the interface may not be enough.
For an object, an interface is the only surface to access it. But for a system, the interface is just one side of the system, as there are other sides.
The Other Side Of The System
Remember mocking? Mocking happens on another boundary of the system – the back side (he, he). The test needs to know about that boundary or interface. That knowledge creates the coupling we talked about last time.
But mocking is just one way of looking behind the looking glass. Let’s go back to the simple “Does It Work” example. We have a POST API, that is supposed to save something to the database. How can a test check the API works? If this API’s the only one we’ve got, we need to look in the database. Meaning we need to know about the database.
Ok, let’s add a GET API. Is calling the GET good enough (after the POST) to make sure the POST worked? Well, if it’s not, it’s back into the database again.
Our test needs access to the database, and also where to look for the data. It may also need to set up the database, and clean it after the test.
So the test needs to know a lot about the system’s structure, flow and state. While the API abstracts the use, the system is not encapsulated, and the test needs to manipulate it, to run the scenario.
Abstraction gone, welcome to fragile-town.
More Breakage?
Don’t mind if I do.
If the system-under-test sends an event, we need to know about the event, and how it’s sent. In a unit test, that checks that an event was raised, the event sending is really part of the contract of the object.
But, let’s say we test a microservice, which sends an event through a queue (e.g. Kafka). The test needs to know which queue, the topic, and even timing of the event, in order to catch it and confirm it was sent correctly.
The more complex a system, it has more boundaries, or sides. It can have sub-components we’re interested in (in the test, not in production), or it can be talking through its back-side (he,he) to another system. The test needs to know about content, state, boundaries and flow – all going inside and through the sides.
That means that we break abstraction intentionally in our testing, leading to more coupling and adding to the maintenance costs of change. Add this into the fragility of complex systems, and you add up with quite fragile tests.
Like I said, testing is hard.
Next time, we’ll add more abstraction layers..
Meanwhile, check out my “API Testing” workshop, where we talk about boundaries and break abstractions like there’s no tomorrow.
1 Comment
David V. Corbin · January 16, 2024 at 12:09 pm
Yes, testing is hard. But testing “only the contract” (aka interface) is virtually useless. Implementation matters. Can I take your implementation and replace it with one that takes hundreds of CPU hours, TeraBytes of Disk IO and even mor Network IT… will it be a “drop in replacement” (ie pass all your tests) but be a totlly unacceptable experience for the customer?