Testing Should Not Hurt
Front-end testing can bring a lot of confidence to a codebase — if it is done well. But there are many pitfalls lurking along the way, and they tend to show up in familiar forms: slow execution, painful maintenance, and results you cannot rely on. All of this can make tests feel like a burden, and when that happens, teams often abandon them altogether.
These issues are common across test types, whether you are writing unit, integration, or end-to-end tests. The examples here draw from Jest and Cypress, but the underlying lessons should translate to most testing setups.
The Value and the Traps
Front-end testing verifies that the UI behaves as expected. It can be done at different levels of scope:
- Unit tests check small, isolated pieces of your application — classes, interfaces, or methods — using predefined inputs to compare against expected outputs.
- Integration tests look at how those units interact with one another.
- End-to-end tests exercise the application like an actual user would.
Used together, these practices can give you a strong safety net and reduce the need for manual testing before releases. But that value gets easily undermined by a few recurring problems, which show up again and again in long-lived codebases:
- Slow tests. When tests take too long to run, developers get impatient. Long waits during local development or before merging a pull request quickly become a source of frustration. This often comes from careless waiting times or tests that are broader in scope than they need to be.
- Maintenance-heavy tests. This is an even bigger contributor to abandoned test suites. A test you wrote months ago should still be readable. If you need to decode walls of text or too many layers of abstraction, motivation drops fast. Some production-coding best practices simply do not translate well to test code.
- Tests with no consistent value. The worst case is the flaky test, which produces different results between builds without any code changes. These so-called Heisenfails may pass when you are debugging them and fail only when you look away. They typically appear when convenience shortcuts override good testing practices.
None of these are unavoidable. Most can be prevented with thoughtful test design, and if damage is already done, refactoring the test suite is a worthwhile investment.
The Golden Rule: Keep Tests Simple
A test should behave like a friendly assistant — always there to help, never a burden. When it starts taking up too much mental bandwidth, it defeats its own purpose.
The key principle is simplicity. Yoni Goldberg, author of JavaScript Testing Best Practices, puts it this way:
“One should look at a test and get the intent instantly.”
That means tests should be flat and minimalist, with minimal logic and few to no abstractions. If you do use helpers such as page objects or custom commands, give them clear, indicative names and document their intent. This keeps the test easy to approach for anyone on the team.
Opinions on duplication also differ between production and test code. In tests, the goal of immediate comprehension can outweigh the DRY principle. Consider this example:
// Cypress
beforeEach(() => {
// It’s difficult to see at first glance what those
// command really do
cy.setInitialState()
.then(() => {
return cy.login();
})
}):
If you feel that a named command is not self-explanatory enough, add a clarifying comment to it:
// Cypress
/**
* Logs in silently using API
* @memberOf Cypress.Chainable#
* @name loginViaApi
* @function
*/
Cypress.Commands.add('loginViaApi', () => {
return cy.authenticate().then((result) => {
return cy.window().then(() => {
cy.setCookie('bearerAuth', result);
}).then(() => {
cy.log('Fixtures are created.');
});
});
});
That documentation is valuable: it helps your future self and the rest of the team understand what a test is really doing. Many best practices designed for production code can conflict with comprehensibility in test code. Tests are not production code, and they should not be treated as if they were. They still deserve care, but when there is a conflict, let the golden rule win — put the developer experience first.
Six Traps In Front-End Test Design And Implementation
The “Given, When, Then” Structure
A poorly structured test can be almost impossible to read at a glance. When declarations, actions and assertions are mixed together without any clear separation, you lose the ability to quickly determine what a test is verifying.
The classic AAA pattern — arrange, act, assert — is one common solution. You set up the system state, perform the action, then check the result. This works well for relatively short unit tests, but it breaks down when you are testing the DOM and need to verify both before and after states. In integration and end-to-end testing, you often need to check intermediate states, which means you end up acting, then asserting, then acting again.
A better fit for front-end testing is the given-when-then pattern from behavior-driven development. Instead of thinking “arrange my test,” you think “what am I given?” That is the scenario with all preconditions. Instead of “act,” you think “when something happens.” And instead of “assert the results,” you think “if that happens, then this is what I expect.”
This approach is not limited to Gherkin syntax. You can apply the same logic simply by structuring your test blocks around these three phases, which makes the intent of each section immediately obvious to anyone reading the code.
Shared Test Data Breeds Flaky Tests
When two tests share the same data — or worse, when one test relies on the outcome of a previous test — you are inviting flakiness. If the earlier test fails, or if the shared data gets corrupted, dependent tests cannot run successfully. The problem gets worse when tests execute in random order, because you can no longer predict whether the prerequisite test will have run at all.
Unit tests are not immune to this issue either; two tests mutating the same seed data is a typical example. The solution is to isolate tests from any outside influence. Take the case of an end-to-end test for a login flow in an online shop. To protect it, you use the beforeEach hook to:
- Reset the application to its factory settings. This removes any custom data or side effects from previous tests or external sources. The goal is to guarantee that every test starts from the exact same basis.
- Create all data needed for this specific test. Each test should generate its own tailored fixtures — for example, creating a dedicated customer who can log in. This makes the test self-contained and allows random execution order without breaking anything.
Both steps are equally important. They are what keep tests stable, reproducible and independent.
Meaningless Placeholder Names
Using placeholder names like “Foo Bar” in tests is a common habit, but it is also a failed opportunity for clarity. When you see fooBar in a test, do you immediately know what it represents? Most likely not, and that violates the core principle that a test should be understandable at first sight.
This is easy to fix. Instead of placeholders, use realistic names tied to the domain you are testing. For example, in a test that checks whether a product can be created and read, use meaningful names:
- For a t-shirt product name, use “T-Shirt Akbar”.
- For the manufacturer, use “Space Company”.
You don’t have to invent plausible data manually. You could import realistic data from production, or auto-generate it. The point is to make the test self-documenting, even down to the names of its fixtures.
CSS Selectors Test Implementation Details
CSS selectors are a trap many developers fall into, and for understandable reasons — they are often unique, easy to handle and seemingly reliable. But CSS classes are prone to change during refactoring. When you refactor your styles and change a class name, a test using that selector can fail even though you have not introduced a bug. That kind of false positive is exhausting: the test gives you no reliable signal about the state of your application.
As Kent C. Dodds puts it: “You shouldn’t test implementation details.” Instead, test things a user would actually notice. Better yet, choose selectors that are less likely to change. A good option is to use data attributes with meaningful names. Developers are far less likely to alter a data attribute merely because they are cleaning up CSS classes, and a well-named attribute makes its testing purpose clear to anyone reading the source code.
Testing implementation details can also produce false negatives — tests that pass even though the application contains a bug. This happens when you assert against an implementation detail that changes but doesn’t affect the underlying behaviour. Both false positives and false negatives waste time and erode confidence in your test suite.
Fixed Waiting Times Cause Slowness And Flakiness
Fixed waiting times — calls like cy.wait(500) — are a persistent anti-pattern. The problem gets worse when such a wait is embedded in a custom command that gets invoked many times during a test run. Every use of the command adds another fixed delay, slowing the test far beyond what is necessary.
Even more critically, a fixed amount of time is rarely the right amount. If your website takes longer to respond than the wait allows, the test fails randomly. The solution is to wait dynamically rather than assuming a static duration. Two approaches are especially reliable:
- Wait for UI changes a user would notice. This could be the disappearance of a loading spinner, the end of an animation, or any other visible transition. Almost every testing framework offers this type of built-in wait.
- Wait for API requests and responses. This is a deterministic approach. In Cypress, you first define the route you want to track, then assert against it in your test. The test only proceeds once the API has responded, so it is stable and rarely slower than it needs to be.
Removing fixed waits makes tests more stable, faster and closer to the actual behaviour of a real user. Each wait should be tied to a concrete signal that the system state has changed — not to an arbitrary clock.
The Payoff of Getting Testing Right
Much like the Battle of Endor, the struggle to fix a flawed testing strategy is often hard-fought. It can demand significant refactoring, especially when dealing with legacy code, or it may require a fundamental shift in how you approach test design. Yet the effort is worthwhile. The reward is a test suite that works for you rather than against you.
The foundational principle remains: a good test should act as a friendly assistant, not a hindrance. It should integrate into your workflow as a routine check, not feel like an intricate puzzle. When tests become a source of confusion or resistance, it’s usually a sign that this basic rule has been over looked.
Whether you are aiming to prevent the traps discussed above from the start or repairing damage already done, prioritize clarity and team collaboration. The change is rarely just technical; it often involves adopting new habits and standards across the team. The path to stable, reliable front-end testing is an ongoing process of learning and adaptation.
These insights come from real-world experience, but the landscape of front-end pitfalls is vast. If you have encountered particularly vexing testing problems, sharing them helps the engineering community refine its collective best practices.
Further Exploration
For those looking to deepen their understanding, several external resources are particularly valuable for avoiding these testing traps:
- “JavaScript and Node.js Testing Best Practices,” Yoni Goldberg
- “Testing Implementation Details,” Kent C. Dodds
- “Naming Standards for Unit Tests.html,” Roy Osherove
Related Reading on Tech Report
- The Hype Around Signals
- Sticky Headers And Full-Height Elements: A Tricky Combination
- Long Live The Test Pyramid
- Creating An Effective Multistep Form For Better User Experience
Learn more about front-end architecture and testing methodologies.



