Readable tests beat DRY tests every time
It's easy to fall into the habit of tidying up your tests just like your app code. However, unlike app code, tests are read far more often than they're written. And I'm advocating that the DRY principle is bad for tests.
Abstraction hides intent
Helpers like setup()
or submitForm()
might save a few lines, but they also hide what the test is actually doing.
test('should show an error on empty input', () => {
setup();
submitForm();
expect(screen.getByText('Field is required')).toBeInTheDocument();
});
How do you know what setup()
does? How do you know it sets up the component in a way that's appropriate for this test case?
And how about submitForm()
? Does it just click the submit button? Or does it fill out all form fields, leave one empty, and click the submit button?
How do you know if the test passes because it accurately simulates the scenario you intended, or because the helper functions are doing something unexpected that allows the test to pass? Do you trust the helpers or do you, um, need to write tests for them too?
To be fair, helpers like setup()
would be useful if they help reduce boilerplate, but their names should communicate their purpose clearly. renderWithRouter
or renderWithRedux
are the ones I'd use to set up components with the necessary context. The function names are explicit about what they do, and their implementation does exactly what their name suggests, nothing else.
Using abstractions requires a level of trust in the helpers, which can be problematic when the test itself is meant to verify correctness.
Abstracted tests are harder to debug
When a test relies on helpers, it becomes much harder to see what's actually happening. If a test fails, you can't just look at the test itself, but you have to dig into each helper to figure out what it does. This is even more painful if the helpers call other helpers, and suddenly, you're dealing with a chain of indirection.
Hidden side effects are another problem. A helper might set up mocks, timers, or global state changes that aren't obvious from the test. When something breaks, you're left guessing whether the issue is in the test, the helper, or somewhere else entirely.
Premature abstraction creates inflexibility
When you abstract too early, your helpers are based on the current shape of your components and tests. As requirements change, those helpers may no longer fit, forcing you to rewrite or remove them.
Even worse, a simple change to a component or test case can ripple through all the helpers and every test that uses them. And what should be a quick update turns into a tedious, error-prone refactor.
Over time, these helpers accumulate options and parameters to handle edge cases, making them harder to understand and maintain. They may end up doing too much or not quite what you need for specific test scenarios.
Anecdotally, in a previous role, I had to investigate some test cases written by a different team/colleague. The tests were using a lot of helpers, and some of those helper functions could take 10-11 parameters (yes, parameters—not an object with 10 properties!).
DRY doesn't apply the same way to tests
The DRY principle makes sense for production code, where abstractions reduce maintenance burden and can improve performance. But that doesn't apply to test code, it doesn't need to be optimized for runtime efficiency.
Repetition in tests isn't a smell. It makes each test self-contained, readable, and easier to reason about. If you find yourself repeating the same setup or assertions across multiple tests, consider whether those tests are actually testing different scenarios or if they can be combined.
Final thoughts
Tests exist to serve your codebase, not the other way around. When overly abstracted tests slow down development and turn quick fixes into debugging sessions, they've crossed the line from helpful to harmful.
Your time is your most valuable asset. Spend it on what actually matters.