End-to-End Testing: Where It Fits and Why It Hurts
Automated testing is now a standard pillar of software development. Unit tests form the base, integration tests sit in the middle, and end-to-end tests sit at the top of the stack. E2E tests simulate real user workflows against the full application stack, which makes them a powerful quality check — but they bring real costs. They are slow, which burdens CI/CD pipelines; they are hard to maintain and debug; and they are prone to flakiness, where the same test run produces different results for no apparent reason.
Those drawbacks are manageable, though. The key is to use the right framework and to pick your test cases deliberately. Cypress, an all-in-one JavaScript testing framework, addresses several of these pain points directly.
Why Cypress Improves on Older Tooling
I started writing E2E tests with Mink and Behat, then moved to Selenium. As my team adopted Vue.js, we migrated to Nightwatch.js. We kept hitting compatibility problems — what you might call dependency hell. Versions of Chrome in CI would update faster than the test framework could keep up, and the retry and waiting mechanisms in Nightwatch.js didn't match our product well. These issues caused an increasing number of flaky tests and pipeline failures.
After an unconference session, I switched to Cypress. Unlike Selenium- or WebDriver-based tools, Cypress runs inside the browser via Node.js rather than remote-controlling it. This architecture delivers key advantages:
- Debugging is excellent. The test runner records snapshots of the app at each step, letting you jump straight to any state before an error. You also get full access to Chrome DevTools.
- Waiting is built in. Cypress implicitly waits for actions, UI animations, and API responses, so you don't add brittle sleep calls or manual checks.
- Tests are written in JavaScript. For JS-heavy teams, the learning curve is minimal, and the open-source runner fits most product strategies.
Setting Up and Starting Cypress
To get started, create a project directory and install Cypress with npm:
If you prefer Yarn, use those commands instead:
yarn add cypress --dev
Cypress also offers direct ZIP downloads for installation.
You have two ways to run tests. The first is headless via the console:
./node_modules/.bin/cypress run
The second uses Cypress's interactive test runner, which is a graphical UI for controlling and watching test executions. Launch it with:
./node_modules/.bin/cypress open
On first launch, Cypress shows you an interface with example tests it has prewritten for you:
Ignore those for now — you will write your own soon in the "Integration Tests" area.
Understanding Cypress's Folder Structure
Open the newly created project in your IDE, and you'll see the default layout:
smashing-example
└── cypress
└── fixtures
└── integration
└── plugins
└── support
└── cypress.json
Here's what each folder does:
fixtures— fixed test data that is independent of other entities and doesn't store changeable IDs.integration— the actual test files.plugins— extensions via existing Cypress plugins or your own.support— custom commands and helper functions that extend Cypress itself.cypress.json— configuration file, including environment settings.
Choosing Which Workflows to Test
Because E2E tests are expensive and slow, you shouldn't automate everything. Choose test cases based on realistic user stories, not exhaustive coverage. The best candidates are:
- The most common and critical workflows of a feature — the "happy paths," including typical CRUD operations.
- Workflows identified through risk analysis as most vulnerable, where a failure would cause the most damage.
- Cases that don't duplicate coverage already handled by lower-level tests.
- Only those that test the application's response to errors, not the underlying causes of those errors.
When you do write an E2E test, focus narrowly on the workflow you want to verify. Use API calls or database setups to handle all the prerequisite steps that are not the target of the test. This keeps run times low and gives you clear, isolated failure results. Approach each test from the end-user's perspective, not from the implementation details.
Example: To test an online shop's checkout process, don't manually create the products and categories through the UI. Set those up with an API call or database seed, and keep the test scoped strictly to the checkout flow.
Writing Your First Cypress Test
Inside the integration folder, create a new file called find-author.spec.js. The .spec suffix indicates a specification file for the technical details a feature must meet. Begin by structuring the test suite with the describe method, which groups and organizes related tests:
// find-author.spec.js
describe('Find authors at smashing', () => {
//...
});
Next, define the actual test with it (or its alias specify). A single file can contain many such tests, giving you solid structure:
// find-author.spec.js
describe('Find authors at smashing', () => {
it('Find the author Ramona Schwering', () => {
cy.log('This is our brand-new test');
});
});
If you've used Mocha, the syntax will look familiar — Cypress is built on top of it.
When you run this test in the test runner, Cypress opens a dedicated browser to execute it:
At this point, the test passes but does nothing. To make it useful, you'll need to add steps that interact with real UI elements and assert expected behaviors — that's where Cypress's implicit waiting and snapshot debugging ultimately shine.
Commands and Assertions: The Two Pillars of a Cypress Test
Every end-to-end test boils down to two kinds of statements. Commands are the actions Cypress performs against your site — a click, a scroll, or finding an element. Assertions describe the expected state of the UI, checking whether something exists, is visible, or has disappeared.
Starting With visit and get
The first command in nearly any test is the one that opens the page. For our running example, that means navigating to smashingmagazine.com with cy.visit():
// find-author.spec.js
describe('Find authors at smashing', () => {
it('Find the author Ramona Schwering', () => {
cy.visit('https://www.smashingmagazine.com/');
});
});
Right behind visit, the command you will reach for most often is get. It returns an element by selector, much like jQuery’s $(…), and is typically used to start a chain of commands:
cy.get(‘selector’);
Commands in Cypress chain together, with each one passing its subject — the return value — to the next. That means a unique selector matters: if get matches multiple elements, it will return all of them, which can break the chain unless that is intentional.
Finding Reliable Selectors
Cypress includes a tool for discovering good selectors. The Selector Playground, activated via the crosshair icon in the test runner header, highlights an element on hover and shows its selector. After clicking an element, a bar beneath the icon displays the selector and the number of matching elements, which helps confirm uniqueness.
Auto-generated selectors are not always ideal. They can be long, hard to read, or just not fit your project’s conventions:
In those situations, the browser’s DevTools are a solid fallback. Inspecting the element in the Elements tab and searching for a candidate selector in the code view will confirm whether it matches only one node — a quick check that the selector is unique.
Selector choice also affects stability. CSS classes used for styling can change frequently, breaking tests that depend on them. Cypress’ own best-practices documentation offers guidance on picking selectors that are less likely to shift under you.
Putting It Together in a Workflow
Our example workflow follows a user searching for an author’s article, then navigating to the author’s website through a reference area in that article:
“I, as a user, will search for the author’s article and navigate to the author’s website through the reference area in one of their articles.”
The full sequence of commands, with comments explaining each step, is below:
// find-author.spec.js
it('Find the author Ramona Schwering', () => {
// Open the website
cy.visit('https://www.smashingmagazine.com');
// Enter author’s name in search field
cy.get('#js-search-input').type('Ramona Schwering');
// Navigate to author’s article
cy.get('h2 > a').first().click();
// Open the author’s page
cy.get('.author-post__author-title').click();
});
But this test only proves that the commands executed successfully. Run headlessly, you would not know if the page actually arrived at its final destination. To verify that, the test needs assertions.
Adding Assertions
Cypress assertions build on Chai and Sinon-Chai, which is visible in their syntax. A basic check for our example is confirming we have landed on the author’s profile page:
// find-author.spec.js
it('Find the author Ramona Schwering', () => {
// Open the website
cy.visit('https://www.smashingmagazine.com');
// Enter author’s name in search field
cy.get('#js-search-input').type('Ramona Schwering');
// Navigate to author’s article
cy.get('h2 > a').first().click();
// Open the author’s page
cy.get('.author-post__author-title').click();
// Check if we’re on the author’s site
cy.contains('.author__title', 'Ramona Schwering').should('be.visible');
});
With that assertion, the test now has real value.
Making the Test Robust
A first meaningful test still needs work before it is merge-ready. Flakiness is a common issue, and smoothing it out is the main task in this phase.
Assertions on the UI Beat Fixed Waits
Cypress’ built-in retry logic helps with elements that appear asynchronously, but it only checks for their existence in the DOM. That is not enough for every situation the application may throw at the test suite.
The user model is the right guide: wait for the parts of the UI you need to render before interacting with them. That is best done by asserting on those exact elements — checking that something needed is fully loaded before the test proceeds. One such assertion placed at the start of the test confirms the page shell is ready:
// find-author-assertions.spec.js
// Open website
cy.visit('https://www.smashingmagazine.com');
// Ensure site is fully loaded
cy.get('.headline-content').should('be.visible');
// Enter author’s name in the search field
cy.get('#js-search-input').type('Ramona Schwering');
Fixed waits are a trap. Avoid cy.wait(500) and similar hard-coded delays; they introduce flakiness rather than removing it. If the UI is the signal, an assertion is the right instrument.
Waiting on API Responses
Cypress also exposes network features, useful when the UI takes time to refresh with server data. The search step in our workflow is a case in point:
“I, as a developer, want to make sure that our search results have fully loaded so that no article of older results will mislead our test.”
Set up the route with intercept, using a wildcard URL and an alias so the test can reference it later:
// find-author-hooks.spec.js
// Set the route to work with
it('Find the author Ramona Schwering', () => {
// Route to wait for later
cy.intercept({
url: '*/indexes/smashingmagazine/*',
method: 'POST'
}).as('search'); // With this alias Cypress will find the request again
//...
Define these routes at the top of the test, where Cypress lists them before execution begins:
The bare wait command on the alias is too lenient — it resolves on any HTTP response, even a 400 or 500. The right pattern adds an assertion that the response status is successful before the test moves on:
// find-author-hooks.spec.js
// Later: Assertion of the search request’s status code
cy.wait('@search')
.its('response.statusCode').should('equal', 200);
Waiting on the response status, rather than on time, keeps the test fast and stable even under load.
Configuration and Hooks
Two smaller pieces round out the setup: the baseUrl and lifecycle hooks.
The full example on GitHub uses only cy.visit('/') instead of a full URL:
// Cypress
describe('Find author at smashing', () => {
beforeEach(() => {
// Open website
cy.visit('https://www.smashingmagazine.com');
});
//...
That works because the baseUrl config value prefixes both cy.visit() and cy.request(). It is set in cypress.json:
// cypress.json
{
"baseUrl": "https://www.smashingmagazine.com"
}
Hooks are the other common pattern. Like many test frameworks, Cypress supports before, after, beforeEach, and afterEach to run code around one or all tests:
// Cypress
describe('Hooks', function() {
before(() => {
// Runs once before all tests
});
after(() => {
// Runs once after all tests
});
beforeEach(() => {
// Runs before each test
});
afterEach(() => {
// Runs after each test
});
});
For a test file with several specs, a shared setup step like visiting the site fits naturally into a beforeEach hook:
// Cypress
describe('Find author at smashing', () => {
beforeEach(() => {
// Open website
cy.visit('https://www.smashingmagazine.com');
});
//...
beforeEach hook is displayed in the test runner’s log. (Large preview)Hooks are also how you reset application state between tests. Each test should be independent — never rely on the state from a previous run. That isolation protects the validity of the results.
Final Thoughts on Cypress for End-to-End Testing
End-to-end tests remain a critical component of any CI pipeline that values application quality and team efficiency. In my experience, they do not replace manual testers but rather offload the repetitive, error-prone checks, allowing humans to focus on exploratory and edge-case scenarios. When it comes to tooling, Cypress remains my preferred choice for quick, stable, and efficient debugging of end-to-end tests. If you are already comfortable with JavaScript, the learning curve is notably gentle, making it a pragmatic addition to a developer's toolkit.
Throughout this article, I have aimed to provide both a solid starting point for writing your first Cypress test suite and the practical, field-tested tips that make the difference between a flaky script and a reliable one. All the code examples referenced here are accessible in the GitHub repository, which you can use as a base scaffold for your own projects.
That said, what we have covered only scratches the surface. The Cypress ecosystem is broad, and several avenues merit deeper investigation:
- Intercepting and stubbing network requests for robust, deterministic testing without depending on live backends.
- Custom commands and utilities to reduce duplication and increase readability across large test suites.
- Test retries and advanced configuration options to handle intermittent failures.
- Parallelization strategies within CI to optimize run times.
- Visual regression testing integrated with Cypress commands.
Regarding best practices, I strongly advise against relying blindly on default selectors. Writing end-to-end tests demands discipline in terms of how you query the DOM. Prefer accessible selectors or dedicated data attributes over tightly coupled CSS classes or text content that may change frequently. Furthermore, a common pitfall is asserting on aesthetics such as CSS colors or shadows; these are brittle and do not reflect functional behavior. Keep your assertions focused on states, data flow, and user-visible outcomes rather than on styling details.
Repository and Learning Resources
To ensure you have everything in one place, I have consolidated some of the most valuable documentation and community resources. These links cover the core API, advanced recipes, and a set of community style guidelines for writing maintainable end-to-end tests.
- Official Cypress documentation — the first stop for API specifics.
- Cypress Recipes — a curated selection of practical examples and walkthroughs.
- Learn to Code With JavaScript: Cypress — a lesson-based introduction for developers new to testing.
- Shopware's Best Practices on Writing End-to-End Tests — a documented set of guidelines for real-world application suites.
For the full, runnable example used throughout this piece, revisit the original smashing-example repository by Ramona Schwering.
Ultimately, writing reliable end-to-end tests is more about consistent architecture than syntax. Enforce small, focused test cases; keep each spec independent; and always verify what your tests are actually attempting to validate. Happy testing.




