Decoupling Tests from Implementation Structure
Part 1: Decoupling Tests from Implementation Structure
In software development we already have many best practices defined, one of which is the concept of creating loosely coupled systems. Ironically, in the world of testing, we often tend to couple our tests tightly to the implementation structure. We either create a new test for every class/method we add, or we use mocks in tests (because we have to setup dependencies somehow, right?)
Why is this bad? Any tweak to the code structure (like extract method) or refactoring can render certain tests obsolete. Methods/Classes used in your tests simply disappear or aren’t called anymore in your user path - often your mocks aren’t actually used anymore and require a tweak.
In that situation we face a dilemma — either discard tests entirely or fix them again from 🔴 to 🟢, but doing so is potentially error-prone process, undermining the very essence of why we write tests in the first place.
Part 2: Shifting Focus to System Behavior
What you can do instead can be explained in several way, but the simplest one is to use a 2 patterns together: black-box
+ facade pattern
.
Black-box
testing is a method of software testing, where you know nothing about the system’s internals, what components interact with each other, nor how they do that.
Facade
patterns is a pattern to hide our functionality behind a simple API (most probably an interface or adequate in your language)
For the purpose of this entry article - assume that a facade is an entry point to your module or service.
Two techniques combined will enable you to create tests that benefit from the most important rule of immutestable:
Test behaviour, not implementation
And will get rid of the original problems mentioned! Let’s see how!
Part 3: Example
Let’s dive into an example - rating a movie we’ve watched in an online movie database (like IMDb or Filmweb).
Let’s assume that the system is composed of 3 modules: Users
, Movies
and Reviews
systems.
In black-box testing we just need to make sure a set of actions result in the expected behavior.
Our testing scenario is When a user rates a movie, the movie is returned in user's rated movies
, which can be showed as…
We don’t care what’s the internal components structure of your application or how they interact with each other.
So the black-box system can be a fully asynchronous application with complicated subsystems and software design patterns - or a single-class, in-memory procedural application. The behaviour of the system is correct or not and that doesn’t depend on HOW you’ve actually implemented it.
Let’s see how can this look in the code (extremely simplified, to just present the concept):
@Test
fun `when user leaves a review, it's returned in that user's reviewed movies`() {
// given
val usersFacade = UsersFacadeFactory.testFacade() // initialize users module
val registeredUserResponse = usersFacade.registerUser("name", "surname") // make sure a user is registered
val moviesFacade = MoviesFacadeFactory.testFacade()
val createdMovieResponse = moviesFacade.addMovie(CreateMovieCommand("title", "description"))
val reviewsFacade = ReviewFacadeImpl(
moviesFacade,
usersFacade
) // initialize reviews module, that uses both movies and users modules
// when
reviewsFacade.addReview(
registeredUserResponse.userId,
createdMovieResponse.movieId,
AddReviewCommand(5, "Great movie!")
)
// then
val reviewedMovies =
reviewsFacade.listReviews(registeredUserResponse.userId) // we use the same module to verify the expected behavior
assert(reviewedMovies != null)
assert(reviewedMovies!!.reviews.any { it.movieId == createdMovieResponse.movieId && it.userId == registeredUserResponse.userId && it.review.rating == 5 })
}
In our case UsersFacade
and MoviesFacade
are some implementation of the system that our ReviewFacade
is using (coupled to the Facade API only).
All that review module requires is to know the API it needs to use from users and movies modules:
interface ReviewsFacade {
fun addReview(userId: String, movieId: String, command: AddReviewCommand): ReviewAddedResult?
fun listReviews(userId: String): UserReviewsDTO?
}
interface UsersFacade {
fun registerUser(name: String, email: String): RegisteredUserResponse
fun getUser(userId: String): UserDTO?
...
}
interface MoviesFacade {
fun addMovie(command: CreateMovieCommand): CreateMovieResult
fun getMovie(movieId: String): MovieDTO?
...
}
your modules use only high-level Facade API that other modules provide.
In this example you’re decoupled from the implementation What’s the behaviour we want to test?
Of course this example consist more of a modular monolith application, but in the future you’ll see how this pattern applies in the different scenarios like microservices, event-driven systems, etc.
Part 4: mental shift
The hardest part of fully embracing this approach is a required change in how you think about tests. We got used to testing how the code executes (with mock/stubs and testing all the layers under the hood) rather than by what it does.
Here’s some additional questions that you can try answering when trying to design your tests to test behaviour instead of implementation:
- what is the result of user’s action upon my module, viewed from the perspective of the user?
- am I testing what the code does, or how it does it?
- is my test aware of an implementation details (like methods, classes used internally in my module)?
coming next
In the next part we’ll dive into an example refactoring and how the standard tests would fail, and how the immutestable keeps your test decoupled from the implementation details.