November 2, 2024
This article delves into advanced best practices for end-to-end (E2E) testing. I began my journey with limited knowledge in this area, but as I gained experience, I recognized the importance of building robust, reliable tests. The challenges of flakiness and instability taught me valuable lessons that I’m sharing here. Instead of covering the basics, which are available in official documentation, this guide will focus on strategies to reduce test maintenance, improve stability, and enhance readability in complex projects.
Official Cypress best practices guide
Official Playwright best practices guide
Example: Let’s say you want to verify the checkout flow of an e-commerce application. Ask yourself: Are you testing only the ability to complete a purchase, or are you also verifying inventory updates and order confirmation emails? Defining scope is key.
Well-defined Test Purpose: Test the login functionality with valid credentials and verify successful redirection.
Scope Control: Avoid unnecessary database checks if the goal is purely UI validation.
In codebases I've worked on, I've tested with both JavaScript and TypeScript. From experience, using TypeScript significantly improves test maintainability by providing type safety and better IDE support. This helps catch bugs early, reduces runtime errors, and enhances code readability.
For instance, TypeScript enables you to define specific data structures for test inputs, making it clear what parameters are expected in each test function:
interface UserCredentials {
username: string;
password: string;
}
const login = ({ username, password }: UserCredentials) => {
cy.get('[data-testid="username"]').type(username);
cy.get('[data-testid="password"]').type(password);
cy.get('[data-testid="login-button"]').click();
};
Using TypeScript also helps when testing complex flows, as it ensures consistent data types across tests, which is particularly helpful when working with API responses.
Tests should be familiar to anyone with a testing background rather than deeply embedded with developer-specific logic. I’ve seen developers writing tests using complex JavaScript syntax that, while powerful, doesn’t align well with E2E testing best practices. The goal is to write clean, readable tests that anyone on the testing team can understand.
For instance, instead of embedding complicated logic in your test, aim for clear, framework-native syntax:
cy.get('.items').then(($items) => {
Array.from($items).forEach(item => {
if (item.innerText.includes('Special')) {
cy.wrap(item).click();
}
});
});
cy.get('.items')
.contains('Special')
.click();
The second example uses Cypress’s .contains()
method, which makes the intent clear and the code simpler to maintain. This approach leverages the framework’s functionality, making tests more robust and less prone to breaking due to minor UI changes.
Using GitHub Actions for your CI/CD pipeline allows you to automate the testing process seamlessly. By setting up a workflow, you can ensure that your E2E tests run every time code is pushed or a pull request is created. This helps catch issues early and maintain high software quality.
Here’s an example of a simple GitHub Actions workflow that runs E2E tests using Cypress. Create a file named .github/workflows/ci.yml
in your repository:
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup-node@v2
with:
node-version: '14'
- name: Install dependencies
run: npm install
- name: Run E2E Tests
run: npm run test:e2e
In this example, the workflow is triggered on pushes and pull requests to the main
branch. The steps include checking out the code, setting up Node.js, installing dependencies, and finally running the E2E tests defined in your package.json script.
By integrating E2E tests into your GitHub Actions workflow, you create a robust system that ensures any changes made to your codebase are validated against your test suite before merging. This practice not only enhances code quality but also fosters a culture of continuous improvement within your team.
Flaky tests can make CI/CD pipelines unreliable. Here are some strategies to reduce flakiness:
Example of Network Stubbing in Cypress
cy.intercept('POST', '/api/checkout', { statusCode: 200, body: { order: '12345' } });
cy.get('[data-testid="checkout-button"]').click();
cy.get('[data-testid="order-confirmation"]').should('contain', 'Order 12345');
In this example, we ensure that the order confirmation is controlled, reducing dependency on backend changes or network latency.
By following these practices, you’ll not only write more reliable tests but also improve the readability and maintainability of your test suite. Advanced testing is about balancing between simulating real-world interactions and creating tests that are stable and easy to maintain.