avatarLuís Soares

Summary

The provided content emphasizes the importance of decoupling automated tests from implementation details to facilitate refactoring and maintain a stable test suite that focuses on behavior rather than code structure.

Abstract

The article discusses the concept of decoupling tests from the underlying implementation details, advocating for tests that define intended behavior rather than how it is implemented. It suggests that tests should be written to withstand changes in implementation, using the Arrange/Act/Assert pattern to structure tests effectively. The author advises against altering code solely for testing purposes, as this leads to tight coupling between tests and implementation. Instead, testing should be performed through APIs, which are more stable and reflect business needs. The article also cautions against testing utilities and accessing databases directly, proposing that tests should interact with the database through the implementation's code and use a subset of APIs as entry points for testing. The author promotes the creation of cohesive and deep APIs to reduce the implementation's surface area, thereby hiding complexity behind simple interfaces and making tests more resilient to changes in the codebase.

Opinions

  • The author believes that changing implementation code to accommodate tests is a counterproductive practice and a testing antipattern.
  • Tests should be sensitive to changes in behavior but not to changes in code structure or details, implying that APIs are the preferred interface for testing.
  • Utilities, databases, and other technical artifacts should not be directly tested but rather tested indirectly through their use in functionalities.
  • Shared utilities are considered an antipattern, and utilities should be placed contextually as private functions to increase cohesion.
  • Direct database manipulation for test preparation or assertion is discouraged; instead, the same API should be used for the Arrange, Act, and Assert phases of testing.
  • The author suggests that only a small, clear set of APIs should be used as default entry points for tests, favoring outermost APIs and domain-level APIs.
  • APIs should be cohesive and deep, with a focus on reducing the number of public concepts and hiding complexity behind simple interfaces to improve usability and reduce test exposure to implementation details.
  • Snapshot testing is criticized for creating tight coupling with the implementation and for producing a high number of irrelevant test failures.

Decoupling tests from implementation details

One of the goals of automated testing is to support refactoring but many times, they get in the way. Tests should aim to define what is intended (behavior); not how it’s implemented (details).

Photo by Michael Dziedzic on Unsplash

Refactoring is a continuous task so we should reduce the exposure to details. Also, tests should not hold us back when evolving the architecture. A good test suite enables coding to be fun.

Write your tests in such a way that as many implementation details as possible could be changed before a change to the test code itself is required. Object Design Style Guide

📝 I’ll use the Arrange/Act/Assert terms (same as Given; When; Then), which is how tests should be split. This is one of the testing patterns that I adhere to and highly recommend.

Don’t change code for the sake of testing

Changing implementation code to please tests is the quickest way to couple your tests to the implementation. It’s a testing antipattern — check “Changing implementation to make tests possible”.

Test only using APIs

I don’t like the rule “test all classes, functions, and methods”. Instead, you should test from the outside, targetting, describing, and exercising the business needs; not the technical aspects — these are just the way to achieve the business needs. In other words, tests should be sensitive to behavior change, not code structure or details. The best way to ensure this is by testing through APIs only (not necessarily web APIs).

APIs are stable, precise, and predictable. Changes in APIs are good reasons for tests to fail because they’re driven by business needs whereas other aspects of the codebase are technical and volatile, which doesn't constitute good reasons for tests to fail [APIs can be external-facing (e.g. REST API) or internal (invoking methods)]. Therefore, tests should talk solely through APIs and only be aware of their concepts (operations and data types). This way, your tests are coupled to API contracts, the less volatile aspect of the codebase.

You should strive to eliminate every non-API connection between tests and implementation. This is one of the key factors in decoupled tests. Here are some examples of “non-API connections” that you should not have:

⚠️ Don’t rely on constants and variables that belong to the implementation (e.g. configurations) in your tests (inject them instead).

⚠️ While testing a web page, classes, tags, and identifiers are implementation details (creating test identifiers is an antipattern because you’re adding code for the sake of testing); instead, consider the Testing Library approach.

⚠️ Tests should not be aware of external non-deterministic things such as the system clock or environment variables. In lower-level testing, the implementation should allow their injection for clarity and determinism. In acceptance testing, tests shouldn’t even know about them.

⚠️ Don’t test utilities; they are secondary things and are supposed to be tested only indirectly.

⚠️ The database is a technical detail, not an API. It should be hidden behind the implementation code.

Let me elaborate on the last two examples.

Don’t test utilities

As a rule of thumb, you don’t want to be exposed to technical artifacts. Instead, you want to test them indirectly. For example, serializers don’t exist for their own sake; they serve a bigger purpose, which is what you want to test (within their adapter). To ensure it, they should be placed where they’re needed (to increase cohesion) and made private. The same applies to converters, view models, mappers, DTOs, and such. Generally speaking, don’t test utilities. Instead, test whatever makes use of them. Make your tests revolve around functionalities rather than technicalities.

⚠️ Shared utilities are an antipattern; you should place them contextually where they belong as private functions.

📝 Even though value objects and entities are not utilities or technical artifacts, I prefer testing them indirectly (unless they have many variations to test).

Don’t access the database directly from the tests

Surely, at least some tests should use a real database (e.g. in Docker), but that doesn’t mean they manipulate it directly to prepare a test (Arrange phase) or to assert (Assert phase). You should not couple tests with low-level volatile details, in this case, with the database schema. Additionally, you’ll end up setting up artificial scenarios (invalid, outdated, or impossible) since you’re bypassing important domain business rules. This hinders code as documentation and tests as a safety net.

Tests should only use the implementation’s code to interact with the database — through the outermost APIs (for realism) or the inner ones, as long as they don’t skip the business domain layer comprising all the rules.

As a rule of thumb, always use the same API to Arrange, Act, and Assert. Even if you’re testing the data layer directly (e.g. while testing a repository), don’t play around with the database directly; use the data layer API. For example, if you’re testing a repository with an actual database, you use that repository’s methods to Arrange and Assert.

An exception to this rule is the “before each test” routine, where you may need to clear the database directly (for test isolation) but have no methods to do so. Besides that, the tests should not even know you’re using a database.

Test using a subset of the APIs

Don’t test all the components directly. In other words, don’t depend on all APIs in tests. Pick a small and clear set of APIs as default entry points to tests and use them. I like to consider only the outermost APIs (e.g. REST API) and the domain-level APIs as entry points to tests.

Make APIs cohesive

If component A depends on B and no one else is using B, probably they should be merged. Both share the same reasons to change so you’ll end up with more cohesion and a smaller surface area visible by the tests. One-to-one relations between components hint that.

Make APIs deep

Now that our tests solely depend on APIs, let’s make a detour to the implementation. We can promote decoupling by reducing the implementation’s surface area; this will make it more opaque. The obvious thing to do is to make everything private until proven otherwise. Regarding public APIs, we should decrease the number of concepts (operations) to a minimum. For example, you could execute an operation with a single call rather than two if the second always needs the first (to prevent temporal coupling). The same principle mediates the iPhone gestures: a lot can be done with a single entry point.

Never increase, beyond what is necessary, the number of entities required to explain anything. (William of Ockham)

But there’s more: we can strive for deep APIs, where the complexity is hidden behind simple interfaces:

The best modules are deep: they have a lot of functionality hidden behind a simple interface. A deep module is a good abstraction because only a small fraction of its internal complexity is visible to its users. A Philosophy of Software Design

Deeper interfaces improve usability as there are fewer concepts to grasp while testing and using in real life. Complexity is hidden behind a simple interface. As a consequence, tests have less exposure to implementation.

⚠️ Refrain from reusing data types across different API operations, to keep them separate —API cohesion is much more relevant than having fewer data types. Also, don’t create APIs that have modes of operation depending on the input— each API operation should only be focused on one thing.

Conclusion

In a nutshell, here’s what I proposed:

  • Use only APIs (internal or external) to bridge tests and implementation;
  • Consistently use a subset of the available APIs in tests;
  • Reduce the implementation’s surface area: make everything private by default and strive for cohesive and deep APIs.

With these practices, your tests become black-box — insensitive to code structure. Tendentially they become independent from the codebase design; they don't care if you use clean architecture or not because they test vertical slices of value, from the outside.

⚠️ Snapshot testing (comparing a JSON, HTML, or image) is the best way to be coupled to the implementation. Every tiny irrelevant change makes tests red; there are too many reasons to fail (e.g. changing CSS class names, swapping JSON fields order). Snapshot testing has only given me pain so far. I was once in a project where the previous developers built a tool to auto-update the snapshots and no one verified what changed! Talk about the test safety net and how to fix symptoms and not root causes…

Learn more

Automated Testing
Decoupling
Recommended from ReadMedium