automative

by

photo

Best E2E Automation Testing Practices

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.

If you're interested in learning more, explore these guides:

When you start writing a test, it’s essential to have a clear purpose. Identify:

  • What functionality you are testing — define the specific behavior or workflow.
  • Your expectations — know what the desired outcome should be.
  • The test boundaries — determine the scope of the test to avoid unnecessary interactions.

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.


Examples:

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.

Using TypeScript for Stronger Tests

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.


Framework-Specific Syntax

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:

❌ Avoid Complex Logic

cy.get('.items').then(($items) => {
  Array.from($items).forEach(item => {
    if (item.innerText.includes('Special')) {
      cy.wrap(item).click();
    }
  });
});

✅ Use Framework Features

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.


Integrating E2E Tests with GitHub Actions

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.


Reducing Flakiness with Reliable Test Patterns

Flaky tests can make CI/CD pipelines unreliable. Here are some strategies to reduce flakiness:

  • Avoid Overlapping Tests: Ensure that tests do not interfere with each other by isolating their execution context. Use before and after hooks to set up and tear down test data or states.
  • Keep Tests Small and Focused: Write tests that focus on a single functionality or user interaction. This makes it easier to identify the cause of failures and reduces the complexity of each test case.
  • Regularly Review and Refactor Tests: As your application evolves, so should your tests. Regularly review your test suite for flakiness and refactor tests that frequently fail or require excessive maintenance.

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.