My Tests are Broken Again, Part 2: Examining a new Approach

Ofek Deitch
Fiverr Tech
Published in
8 min readJan 19, 2022

This is the second post in a multi-part series on how to write tests that are decoupled from production code (implementation details). See here for part 1: Understanding the Problem.

Although the community seems to be making a shift from the Testing Pyramid toward the Testing Diamond, we showed in part 1 why our tests are still not perfect. They are still coupled to implementation details because they’re strongly coupled to the way we chose to model the contracts between the component-under-test and its neighboring components.

In this part, we’ll explore a new technique that will decouple our tests from these inter-component contracts, making our Testing Diamond even more shiny!

The Power of the Declarative Language

One of the things that made React such a powerful tool is that it uses a declarative language. Instead of specifying How we want things to happen, we simply specify What should happen. The How is then inferred behind the scenes. When changing the How, the What doesn’t necessarily have to change as well.

Often, while working on new projects, the implementation details change as the requirements change. More often than not, the contract between components is broken, which leads to a broken suite of tests. At some point, while fixing all of my broken tests for the thousandth time, this idea popped into my mind: “hey—what if we could create initial states in a declarative way?”

Well, we certainly can!

We can achieve that by borrowing a concept from the event-driven architecture:

In an Event Driven architecture, everything that happens is an event. When a to-do is created, an event is sent to every micro-service: “this to-do was just created.” When a to-do is marked as “done,” an event is then sent to every micro-service: “this to-do has just been updated.” Each micro-service can listen to these events and modify its internal state (usually records in the database) according to what happened.

Put together chronologically in sequence, these events create a history of everything that happened in our system.

Should we need to create a new micro-service, we could use all of these events to re-create a state that is up-to-date with everything that happened in our system — even the things that happened before we created the new service.

Mind blowing!

Let’s see how we can achieve the same in our tests.

Creating Initial States using Principles of the Event-Driven Architecture

In the first part of this post we tried to create a test whose initial state is “a meeting that lasted 30 minutes.”

If we were working in an event-driven architecture, our events would be:

  1. Create a meeting
  2. Start the meeting
  3. Wait 30 minutes
  4. End the meeting

Thus, the result of events 1, 2, 3, and 4 is exactly what we need — a meeting that lasted 30 minutes.

So now we will introduce a new class—the Test Driver. The Test Driver exposes a method for each of the events above. Using these methods, we can now write our tests like this:

The Test Driver records the events that occurred, and then returns the desired (initial) state that our test needs.

Now that’s beautiful!

We have achieved a test that is completely decoupled from its implementation. We are no longer coupled to how the initial state is implemented — it could be duration in minutes, it could be start- and end-time — our tests no longer care about that!

As long as the behavior remains the same, our tests will not need to change.

A developer who has just refactored a huge module, and whose tests are still passing! (his tests are decoupled from the implementation details).

But there’s no magic.

Under the hood, our Test Driver is coupled to something. True, should the implementation details change, we would not need to fix our tests. But the Test Driver will always need to adapt to these changes.

In the next section, we’ll discuss how we can reduce the maintenance costs of our Test Driver.

Protecting our Test Driver from Changes

It often makes sense to look at our software as layers, each of which serves a different role:

One way of separating our software into layers. You might look at it differently, and that’s perfectly fine.

I’ll describe each layer briefly, just so that we’re on the same page.

In the front-end:

The UI Layer — receives little and simple pieces of data, and renders them using elements such as buttons, labels, inputs, tables, etc.

The Services Layer—if you are using Redux (or any other state management library), this is where the data is stored. We can change it (and its structure) as often as we need to, as everything is done in-memory.

The Data Retrieval Layer—this is the layer in which we would call axios.get(). This layer knows all the details—APIs, routes, schemas—that are needed when getting and sending data from and to our back-end component.

In the back-end:

The Controllers Layer—knows about routes and schemas of requests / results. It doesn’t do anything other than calling the layer below it, and then returning the results according to the contract with the clients of the component (e.g., the front-end component, another back-end component, etc.).

The Services Layer—this is where the business logic happens. This layer does not know or care about REST.

The Persistence Layer—this layer is responsible for inserting and retrieving data from the database.

Judging by my own experience, it seems that the layers closest to the edges of each component change less frequently than those in the center.

In a back-end application, making changes to the structure of our persisted state (our data in the databases) is always harder than changing how things are represented internally. It is also hard to change the schema and structure of the REST requests and their results, as this often requires us to make changes in lockstep with our clients.

Likewise, in a front-end application, changing its contract with its back-end (usually the structure of the HTTP requests) is harder than changing how things are represented internally (e.g., our Redux store).

It makes sense, therefore, to couple our Test Driver to the peripheral layers of the component-under-test: in a back-end component, we should couple the Test Driver to the Persistence Layer; in a front-end component we should couple the Test Driver to the Data Retrieval Layer.

Coupling our Test Driver to one layer or another can be done by using the language that that particular layer uses (and not using the languages of the other layers). In most cases, this means that we would use only the classes that this layer uses.

When aiming to couple our Test Driver to the Persistence Layer of a back-end component, we ought to use solely the DAO classes that this layer uses; and, while aiming to couple the Test Driver to the Data Retrieval Layer of a front-end component, we ought to use solely the data-types that this layer uses.

Having done that, we no longer care whether the internal representations of our components change — be it the internals of a front- or back-end component. We only have to change the implementation of the Test Driver when the peripheral layers change.

A Concrete Example

Earlier this year, I had the pleasure to work on a dedicated messaging system for Fiverr Business users, in which teams can post questions to each other and reply to one another in the form of threads.

5 more replies

The feature: given a thread with 7 replies, the label of the “show more replies” button should read “5 more replies” (the first 2 are already visible to the user).

The old, coupled (and very uncool) way of writing this test would be to create a stub of a thread that has 7 comments in it. The problem is that doing so would couple our test to the way messages and replies are stored.

We might store each thread as an object with a children property, which is a list of replies to that thread.

Alternatively, we might later decide to pass all messages (both parent and child) as a single array: child messages will have a parent-id property, and we will build the messages’ tree in the browser.

Having to refactor from one representation to the other will certainly be painful. We’d have to go through our tests, one by one, and fix them according to the new representation.

The new, decoupled (and super cool) way of creating a thread with seven replies would be something like this:

Boom!

We don’t care how messages and replies are represented. We only care that there would be a single thread with seven replies. As long as this is our use-case, this setup does not need to change.

7 hours ago

The feature: each reply should have a label on it that states how long ago it was created.

The old, coupled (and very uncool) way of creating the initial data for this test would be something like:

The new, decoupled (and super cool) way of doing so would be:

Again, our tests do not rely on the way the desired behavior has been implemented.

Code Examples

I’ve used mostly pseudo-code throughout this article, mainly for brevity and clarity, but I know that you’ve been waiting for some real code examples!

So I’ve created a simplified version of the chat with the technique we’ve just covered, so that you can see how all the pieces come together:

Conclusion

Component testing is awesome. Maintaining tests is a bummer.

We often create the initial states of our tests in a manner that couples our tests to implementation details (the way we represent our data), which means that changing the implementation needlessly breaks our tests.

However — and this is the essence of this article — we can make the maintenance of our tests far easier by declaratively building the initial states of our tests. Each state will be arranged by specifying a series of events, whose occurrence results in the desired state. Doing so will decouple our tests from the implementation.

That’s it!

I hope I was able to make you see things differently, and that you no longer have any excuses for not writing your tests!

Fiverr is hiring in Tel Aviv and Kyiv. Learn more about us here.

--

--