Chapter 3.2.0: Decoupling from implementation details
We have achieved optimal decoupling of our tests from implementation details when even substantial changes to our code do not entail significant changes to our tests.
Disclaimer: You are reading an early version of the text! The final version of the book will be revised and may contain additional content.
We have achieved optimal decoupling of our tests from implementation details when even substantial changes to our code do not entail significant changes to our tests. In other words, as long as we don't make any changes to the actual functionality, all tests should continue to run successfully. For example, if we change our application from REST to GraphQL, ideally, our tests will not be affected. On the contrary, especially after such a significant refactoring, we need tests that we can rely on. Good tests give us confidence that even after a large-scale refactoring, everything continues to work, and we haven't broken anything. In the best case, we manage to decouple our tests so that even a complete rewrite of our application is no problem. For example, let's say we want to move from Vue.js to Svelte. If we've decoupled our tests from implementation details, even in this case, no or only minimal changes to the test setup should be necessary.
// ❌ Coupling to a specific library
import { mount } from '@vue/test-utils';
import Component from './Component.vue';
test('updates prop', async () => {
// ❌ Coupling to specifics of the framework
const wrapper = mount(Component, {
props: {
message: 'hello',
},
});
// ❌ Coupling to specific mechanics of the framework
await wrapper.setProps({ message: 'goodbye' });
expect(wrapper.html()).toContain('goodbye');
});
Here we see a component test that relies on particular properties of the framework. The mount
function is Vue specific and returns a wrapper
which is also Vue specific, Vue Test Utils specific to be more precise. The setProps()
method does not correspond to how real users use the component.
In the best case, we manage to make our tests utterly independent of our technology stack. However, in practice, we cannot always avoid a certain degree of coupling. For example, some tools and approaches are more appropriate for testing PHP-based applications than JavaScript SPAs. And when we test Vue.js applications, it makes sense to use a framework-specific library like @vue/tests-utils
. Again, however, we should keep the extent of coupling to a minimum. And in the case of libraries, such as @vue/test-utils
, we can fall back on techniques to limit coupling to only a few places in the code.
// ✅ Coupling to the Vue Test Utils lib moved to a central place
import { mount, render, screen } from '../test/utils';
// 🤷♂️ Still some coupling, which we can't avoid
import Component from './Component.vue';
test('it should say goodbye', async () => {
// ✅ Less coupling thanks to generic implementation
render(Component, {
props: {
message: 'goodbye',
},
});
// ✅ Less coupling thanks to neutral language
expect(await screen.findByText('goodbye')).toBeTruthy();
});
This code shows an optimized version of the previous example. We no longer refer to the Vue Test Utils Library directly but import generic helper functions from ../test/utils
. There we can continue to use the Vue Test Utils if we want. But if we change the framework, only this one file has to be adapted (apart from the component initialization logic, which we also need to adapt to the new component API). The assertion in the expect()
method is now also completely independent of the framework used.
The best way to avoid too much coupling is abstractions. Abstractions are not only a helpful tool when writing normal code. Also, for our test setup, we will resort to abstractions on different levels to reduce coupling to concrete libraries and concepts. Among other things, we will implement an abstraction for the data layer (mostly APIs) so that our tests know as little as possible about how and from where the application gets its data.
But it's not just the use of certain technologies that can cause our tests to be coupled to specific implementation details. The way our tests interact with the application or with a single component can be another source of coupling. For example, relying on certain CSS classes to appear in the code creates a very strong coupling to implementation details that can change at any time without regard to the tests.
it('should be possible to remove items from the cart', async () => {
// ...
// ❌ Coupling to CSS selectors
await wrapper.find('.remove').trigger('click');
expect(wrapper.find('.item-count').text()).toBe('2');
});
What we must avoid at all costs is coupling our tests to very concrete implementation details of components. For example, in theory, it is possible to execute individual methods of a component. And there is also nothing preventing us from testing the internal state and the props of a component. However, In practice, I urge you to refrain from doing so! When testing a component, it is crucial to consider it a black box. There is an input (props) and an output (HTML and events). We can interact with the HTML output (Virtual DOM, to be precise), and depending on what we do, the output changes, or the component emits a particular event. So when testing a component, in most cases, we focus on the HTML output and events.
it('should be possible to remove items from the cart', async () => {
// ...
// ❌ Coupling to internal state
await wrapper.setData({ itemCount: 2 });
expect(wrapper.find('.item-count').text()).toBe('2');
// ❌ Coupling to private method of the component
await wrapper.vm.remove(1);
expect(wrapper.find('.item-count').text()).toBe('2');
});
One approach that helps us cleanly separate our tests from business logic is to write the tests from a real user's perspective. We test our code from the point of view of a user actually using it. In the case of certain features or the entire application itself, the users are the people who interact with our application. In the case of components, users are both people who use our application and developers who incorporate the component at a particular location in the code. However, when we test individual functions or other smaller units of code, the users are exclusively developers (us and our colleagues) who want to use a certain piece of code.
So we always try to write the tests from the user's point of view. However, who the users are differs depending on what we are testing. But a user doesn't see or know anything about what's going on inside a particular unit of code. So looking at it from a user's perspective prevents us effectively from testing implementation details.
The phenomenal Testing Library Package will aid us in writing tests that behave as a real user would. For instance, it will help us target certain elements the same way a user would do. For example, the users of our application do not search for an element with the selector .cart-button
. With the Testing Library, on the other hand, we can select a button by role and name getByRole('button', { name: 'Add to cart' })
. This is much like what a user does: they look for a button (role
) with a particular label (name
).
Another important aspect when it comes to decoupling is mocking. Mocking allows us to decouple our application from the rest of the system. We will use the Mock Service Worker Library to implement our mocking layer. Ultimately, we even want to separate our tests from the mocking layer. Separating the tests from the mocking layer is crucial because otherwise, we couple the tests to the data fetching layer through mocking.
To decouple our tests form the mocking layer, we use so-called Preconditions. Preconditions are an abstraction layer around our API mocking implementation. A Precondition hides the knowledge about the mocking layer and which external services we mock in it from the tests. For example, a Precondition called userIsLoggedIn
ensures that our authentication service returns a positive response to a corresponding request from our application. Thus, we encapsulate the knowledge about the data fetching mechanism in the Precondition. As a result, our test does not need to know about these implementation details.
import cartService from '../services/cart';
it('should be possible to remove items from the cart', async () => {
// ❌ Coupling with data fetching layer
cartService.getList.mockResolvedValue(/* ... */);
// ...
});
This test knows that the tested code uses the cartService
. If this fact changes or we make significant modifications to the cartService
, we must also adapt all tests mocking the cartService
.
import cartPreconditions from '../tests/preconditions/cart';
it('should be possible to remove items from the cart', async () => {
// ✅ Less coupling thanks to Precondition abstraction
cartPreconditions.hasItemsInCart(3);
// ...
});
In this improved version of the previous example, we rely on a Precondition. The Precondition hides the knowledge of how exactly we load Items
in the Cart
from the test—changes to the data fetching logic impact only the Precondition and not all relevant tests.
So these are our tools to decouple our code from implementation details:
Testing like a real user with the Testing Library.
Decoupling our application and tests from the rest of the system by mocking API Requests.
And decoupling the mocking layer with the Precondition concept.