
Many times, when I pick up a legacy project, I feel relieved when I see that the code has a high level of test coverage and fails only rarely. At that point it is easy to think we are standing on safe ground, almost as if it were a green field project, and that we will be able to maintain it and add new features without much trouble.
But wait
That feeling changes quickly when we open the first test file and discover that we have spent far too long reading complex structures, trying to understand setup data, and losing focus on the behaviour we actually want to validate.
Legacy code is not required for this to happen. We can create the same problem in our own tests. That is why it is worth reviewing a few practices that help us write more readable tests and keep attention on intention.
The starting problem
Imagine a React function component that renders a specific message depending on a prop value.
type Exercise = {
tags: string[];
warning?: string;
};
function ExerciseCard({ exercise }: { exercise: Exercise }) {
return (
<>
{exercise.warning && <p>{exercise.warning}</p>}
{exercise.tags.map((tag) => (
<span key={tag}>{tag}</span>
))}
</>
);
}
When we look at the component, we understand the important part immediately: it receives an exercise, that object has tags, and it may include a warning that should only be rendered when present. The component interface is simple.
The problem usually appears in the test file.
it('should display the warning', () => {
const exercise = {
tags: ['react', 'testing'],
warning: 'Do not skip warm up',
};
// lots of extra noise...
});
Even if a test like that gives us 100% coverage, that does not mean it is pleasant to read. Sometimes we end up with dozens of setup lines to validate three simple behaviours.
Names should explain intent
One of the first signs of a hard-to-read test is often its name. Phrases such as should display the warning or should not display the warning are too weak because they do not tell us under which condition that should happen.
A useful improvement is to lean on the given-when-then idea so the context becomes explicit:
it('renders the warning when the exercise contains one', () => {
// ...
});
it('does not render the warning when the exercise has no warning', () => {
// ...
});
There is no need to write the full structure mechanically every time, but it helps to keep the principle in mind: the test name should explain the situation and the expected outcome.
Avoid irrelevant details
It is also easy to end up with descriptions that are too specific, for example saying that a component “renders a list of two elements” when what really matters is that it renders the tags. That kind of detail may come from an old specification or from an attempt to make the test “more precise”, but often it only adds noise and confusion.
Hide structure, not behaviour
The next step is to hide some of the data structure that consumes mental space inside the test. Depending on the language, we can do that with helper functions, fake classes, or builders.
In TypeScript, a very convenient approach is to use destructuring with defaults:
type Exercise = {
tags: string[];
warning?: string;
};
function buildExercise(overrides: Partial<Exercise> = {}): Exercise {
return {
tags: ['react', 'testing'],
warning: undefined,
...overrides,
};
}
That lets us build a full object with sane defaults or override only what matters for a given test. If the structure changes later, the test no longer depends on every internal detail of the model.
The test body should breathe
Once we have a builder, the test can focus on what it is actually trying to prove:
it('renders the warning when the exercise contains one', () => {
const exercise = buildExercise({ warning: 'Do not skip warm up' });
const wrapper = render(<ExerciseCard exercise={exercise} />);
expect(wrapper.getByText('Do not skip warm up')).toBeInTheDocument();
});
Now the reading path goes straight to the intent. We are no longer navigating through a pile of irrelevant properties.
Helpers for mounting
If we write tests frequently, it is natural to think about beforeEach or beforeAll in order to centralize component setup. But those tools do not always help, especially when they force us to depend on shared state or remember too much implicit context.
In many cases it is more useful to create a small function that mounts the component and returns the common references:
function renderExercise(exercise: Exercise) {
const view = render(<ExerciseCard exercise={exercise} />);
return {
...view,
getWarning: () => view.queryByText(/warm up/i),
};
}
That kind of helper reduces repetition without hiding the behaviour that matters.
Result
The difference is not only about cutting lines. The real improvement is that the test starts to represent exactly what we need to know about the component behaviour.
When tests are more readable:
- it becomes easier to add new features,
- it is easier to spot duplication or poor naming,
- and the cost of understanding the file weeks later goes down.
Conclusion
A test is not valuable only because it covers code. It is valuable because it communicates intent. If we need to read a large amount of accidental structure to understand it, then it is probably not doing that job well enough yet.
Using expressive names, builders, and small mounting helpers helps us keep the focus on behaviour and prevents the test file from becoming an obstacle for the next person who reads it.