End-to-End Testing
End-to-end tests validate critical user journeys through the entire application stack, from UI to database, ensuring the system works correctly from a user's perspective.
Overview
E2E tests are the most expensive and slowest tests in our testing strategy. They validate entire user journeys through the full application stack - from browser or mobile app, through backend APIs, to database - ensuring the system works correctly from a user's perspective. Use them sparingly for critical user flows only.
Why E2E tests are expensive:
- Slow execution: Full stack operations (rendering UI, API calls, database transactions) take 10-20 minutes vs 2-5 minutes for integration tests
- Environment complexity: Require running frontend, backend, database, and often mock external services
- Flaky failures: Network timing, asynchronous operations, and race conditions cause intermittent failures
- Maintenance burden: UI changes break tests even when functionality is unchanged
- Debugging difficulty: Failures could be in frontend code, backend code, infrastructure, or test code itself
What E2E tests provide: Despite their cost, E2E tests verify integration across the full stack that other tests cannot. They catch issues like:
- Frontend making API calls with wrong parameters
- API returning data in a format the UI can't process
- Authentication flows that work in isolation but fail end-to-end
- Critical user journeys (payment processing, account creation) that combine many components
The key is using E2E tests selectively for high-value scenarios while relying on faster integration tests, contract tests, and unit tests for comprehensive coverage.
E2E tests should represent only 3-5% of your total test suite. Most testing should happen at the integration and unit test levels. Reserve E2E tests for critical user journeys that absolutely must work - login, payment processing, account creation. For other scenarios, prefer integration tests with TestContainers (test backend and database) plus component tests (test UI components in isolation). This provides better coverage, faster feedback, and easier debugging than full E2E tests.
Applies to: Angular · React · React Native · Android · iOS
E2E testing applies to frontend web and mobile applications validating complete user journeys through the UI.
Core Principles
- Critical Journeys Only: Test essential user flows (login, payment, account creation)
- Realistic Scenarios: Use production-like data and environments
- Stable Selectors: Use data-testid attributes, not fragile CSS selectors
- Independent Tests: Each test should set up and tear down its own data
- Fast Execution: Optimize for speed with parallel execution and smart waits
Web E2E Testing with Cypress
Installation
Install Cypress for E2E testing and Testing Library for user-centric queries. Testing Library provides semantic selectors (findByRole, findByLabelText) that make tests more maintainable than CSS selectors.
npm install --save-dev cypress @testing-library/cypress
Configuration
Configure Cypress with timeouts, viewport sizes, retry logic, and environment variables. The retries setting helps handle flaky tests in CI by automatically retrying failed tests before marking them as failures.
// cypress.config.js
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
defaultCommandTimeout: 10000,
requestTimeout: 10000,
responseTimeout: 10000,
retries: {
runMode: 2, // Retry twice in CI
openMode: 0 // No retries in interactive mode
},
env: {
apiUrl: 'http://localhost:8080/api'
},
setupNodeEvents(on, config) {
// Implement node event listeners
return config;
}
}
});
Critical Payment Flow Test
// cypress/e2e/payment-flow.cy.ts
describe('Payment Flow', () => {
beforeEach(() => {
// Reset database state via API
cy.request('POST', `${Cypress.env('apiUrl')}/test/reset-db`);
// Create test user
cy.request('POST', `${Cypress.env('apiUrl')}/test/users`, {
username: 'testuser',
password: 'TestPass123!',
balance: 1000.00
});
// Login
cy.visit('/login');
cy.findByLabelText(/username/i).type('testuser');
cy.findByLabelText(/password/i).type('TestPass123!');
cy.findByRole('button', { name: /sign in/i }).click();
cy.url().should('include', '/dashboard');
});
it('should complete a payment successfully', () => {
// Navigate to payment page
cy.findByRole('link', { name: /make payment/i }).click();
// Fill payment form
cy.findByLabelText(/recipient/i).type('John Doe');
cy.findByLabelText(/amount/i).type('100.00');
cy.findByLabelText(/reference/i).type('Test payment');
// Submit payment
cy.findByRole('button', { name: /submit payment/i }).click();
// Verify confirmation
cy.findByText(/payment successful/i).should('be.visible');
cy.findByTestId('transaction-id').should('exist');
// Verify balance updated
cy.findByTestId('account-balance').should('contain', '900.00');
// Verify payment appears in history
cy.findByRole('link', { name: /payment history/i }).click();
cy.findByText('John Doe').should('be.visible');
cy.findByText('$100.00').should('be.visible');
cy.findByText('COMPLETED').should('be.visible');
});
it('should prevent payment with insufficient funds', () => {
cy.findByRole('link', { name: /make payment/i }).click();
// Attempt payment exceeding balance
cy.findByLabelText(/recipient/i).type('John Doe');
cy.findByLabelText(/amount/i).type('2000.00');
cy.findByRole('button', { name: /submit payment/i }).click();
// Verify error message
cy.findByText(/insufficient funds/i).should('be.visible');
// Verify balance unchanged
cy.findByTestId('account-balance').should('contain', '1000.00');
});
it('should validate payment form fields', () => {
cy.findByRole('link', { name: /make payment/i }).click();
// Submit empty form
cy.findByRole('button', { name: /submit payment/i }).click();
// Verify validation errors
cy.findByText(/recipient is required/i).should('be.visible');
cy.findByText(/amount is required/i).should('be.visible');
// Enter invalid amount
cy.findByLabelText(/amount/i).type('-50');
cy.findByRole('button', { name: /submit payment/i }).click();
cy.findByText(/amount must be positive/i).should('be.visible');
});
});
Custom Commands
// cypress/support/commands.ts
import '@testing-library/cypress/add-commands';
declare global {
namespace Cypress {
interface Chainable {
login(username: string, password: string): Chainable<void>;
createPayment(amount: number, recipient: string): Chainable<string>;
resetDatabase(): Chainable<void>;
}
}
}
Cypress.Commands.add('login', (username: string, password: string) => {
cy.session([username, password], () => {
cy.visit('/login');
cy.findByLabelText(/username/i).type(username);
cy.findByLabelText(/password/i).type(password);
cy.findByRole('button', { name: /sign in/i }).click();
cy.url().should('include', '/dashboard');
});
});
Cypress.Commands.add('createPayment', (amount: number, recipient: string) => {
cy.request('POST', `${Cypress.env('apiUrl')}/payments`, {
amount,
recipient,
currency: 'USD'
}).then((response) => {
expect(response.status).to.eq(201);
return response.body.transactionId;
});
});
Cypress.Commands.add('resetDatabase', () => {
cy.request('POST', `${Cypress.env('apiUrl')}/test/reset-db`);
});
Page Object Pattern
// cypress/pages/PaymentPage.ts
export class PaymentPage {
visit() {
cy.visit('/payments/new');
}
fillRecipient(recipient: string) {
cy.findByLabelText(/recipient/i).type(recipient);
return this;
}
fillAmount(amount: string) {
cy.findByLabelText(/amount/i).type(amount);
return this;
}
fillReference(reference: string) {
cy.findByLabelText(/reference/i).type(reference);
return this;
}
submit() {
cy.findByRole('button', { name: /submit payment/i }).click();
return this;
}
verifySuccess() {
cy.findByText(/payment successful/i).should('be.visible');
return this;
}
verifyError(message: string) {
cy.findByText(new RegExp(message, 'i')).should('be.visible');
return this;
}
}
// Usage in tests
import { PaymentPage } from '../pages/PaymentPage';
it('should complete payment', () => {
const paymentPage = new PaymentPage();
paymentPage
.visit()
.fillRecipient('John Doe')
.fillAmount('100.00')
.fillReference('Test payment')
.submit()
.verifySuccess();
});
React Native E2E Testing with Detox
Installation
Install Detox for React Native E2E testing. Detox uses native test runners (XCTest for iOS, Espresso for Android) to provide fast, reliable tests that run on simulators and emulators.
npm install --save-dev detox jest
Configuration
Configure Detox with app build paths, device configurations, and test runner settings. The configuration defines separate builds for iOS and Android, specifying simulator/emulator targets for each platform.
// .detoxrc.js
module.exports = {
testRunner: {
args: {
$0: 'jest',
config: 'e2e/jest.config.js'
},
jest: {
setupTimeout: 120000
}
},
apps: {
'ios.debug': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/BankApp.app',
build: 'xcodebuild -workspace ios/BankApp.xcworkspace -scheme BankApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build'
},
'android.debug': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug'
}
},
devices: {
simulator: {
type: 'ios.simulator',
device: {
type: 'iPhone 15 Pro'
}
},
emulator: {
type: 'android.emulator',
device: {
avdName: 'Pixel_6_API_34'
}
}
},
configurations: {
'ios.debug': {
device: 'simulator',
app: 'ios.debug'
},
'android.debug': {
device: 'emulator',
app: 'android.debug'
}
}
};
Payment Flow Test (React Native)
// e2e/payment-flow.test.ts
describe('Payment Flow', () => {
beforeAll(async () => {
await device.launchApp();
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('should complete payment successfully', async () => {
// Login
await element(by.id('username-input')).typeText('testuser');
await element(by.id('password-input')).typeText('TestPass123!');
await element(by.id('login-button')).tap();
// Wait for dashboard
await waitFor(element(by.id('dashboard-screen')))
.toBeVisible()
.withTimeout(5000);
// Navigate to payment
await element(by.id('make-payment-button')).tap();
// Fill payment form
await element(by.id('recipient-input')).typeText('John Doe');
await element(by.id('amount-input')).typeText('100.00');
await element(by.id('reference-input')).typeText('Test payment');
// Submit payment
await element(by.id('submit-payment-button')).tap();
// Verify success
await expect(element(by.text('Payment Successful'))).toBeVisible();
await expect(element(by.id('transaction-id'))).toExist();
// Verify balance updated
await element(by.id('back-to-dashboard')).tap();
await expect(element(by.id('account-balance'))).toHaveText('$900.00');
});
it('should show error for insufficient funds', async () => {
// Login and navigate to payment
await element(by.id('username-input')).typeText('testuser');
await element(by.id('password-input')).typeText('TestPass123!');
await element(by.id('login-button')).tap();
await waitFor(element(by.id('dashboard-screen'))).toBeVisible();
await element(by.id('make-payment-button')).tap();
// Attempt large payment
await element(by.id('recipient-input')).typeText('John Doe');
await element(by.id('amount-input')).typeText('2000.00');
await element(by.id('submit-payment-button')).tap();
// Verify error message
await expect(element(by.text('Insufficient Funds'))).toBeVisible();
});
it('should validate form fields', async () => {
// Login and navigate
await element(by.id('username-input')).typeText('testuser');
await element(by.id('password-input')).typeText('TestPass123!');
await element(by.id('login-button')).tap();
await waitFor(element(by.id('dashboard-screen'))).toBeVisible();
await element(by.id('make-payment-button')).tap();
// Submit empty form
await element(by.id('submit-payment-button')).tap();
// Verify validation errors
await expect(element(by.text('Recipient is required'))).toBeVisible();
await expect(element(by.text('Amount is required'))).toBeVisible();
});
});
Helper Functions
// e2e/helpers.ts
export const login = async (username: string, password: string) => {
await element(by.id('username-input')).typeText(username);
await element(by.id('password-input')).typeText(password);
await element(by.id('login-button')).tap();
await waitFor(element(by.id('dashboard-screen'))).toBeVisible();
};
export const navigateToPayment = async () => {
await element(by.id('make-payment-button')).tap();
await waitFor(element(by.id('payment-screen'))).toBeVisible();
};
export const fillPaymentForm = async (recipient: string, amount: string, reference?: string) => {
await element(by.id('recipient-input')).typeText(recipient);
await element(by.id('amount-input')).typeText(amount);
if (reference) {
await element(by.id('reference-input')).typeText(reference);
}
};
Best Practices
Use Stable Selectors
// Bad: Fragile CSS selectors
cy.get('.payment-form > div:nth-child(2) > input');
// Good: Semantic queries
cy.findByLabelText(/amount/i);
// Good: data-testid attributes
cy.findByTestId('payment-amount-input');
// Add data-testid in components
<input
data-testid="payment-amount-input"
aria-label="Amount"
type="number"
/>
Independent Tests
// Good: Each test is independent
beforeEach(() => {
cy.resetDatabase(); // Clean state
cy.createTestUser(); // Fresh test data
cy.login('testuser', 'password');
});
it('should process payment', () => {
// Test implementation
});
Smart Waits
// Bad: Arbitrary wait
cy.wait(5000);
// Good: Wait for specific condition
cy.findByText(/payment successful/i, { timeout: 10000 }).should('be.visible');
// Good: Wait for network request
cy.intercept('POST', '/api/payments').as('createPayment');
cy.findByRole('button', { name: /submit/i }).click();
cy.wait('@createPayment');
Minimize E2E Test Count
Focus on critical flows:
- User authentication (login/logout)
- Payment processing
- Account creation
- Password reset
- Critical business workflows
Avoid E2E tests for:
- Form validation (use component tests)
- Edge cases (use unit tests)
- Error handling (use integration tests)
CI/CD Integration
# .gitlab-ci.yml
cypress-e2e:
stage: e2e
image: cypress/browsers:node22-chrome130
services:
- postgres:16
script:
- npm ci
- npm run build
- npm start & npx wait-on http://localhost:3000
- npx cypress run --browser chrome
artifacts:
when: always
paths:
- cypress/videos/
- cypress/screenshots/
expire_in: 30 days
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
detox-e2e:
stage: e2e
image: reactnativecommunity/react-native-android:latest
script:
- npm ci
- detox build --configuration android.debug
- detox test --configuration android.debug --headless
artifacts:
when: on_failure
paths:
- artifacts/
expire_in: 7 days
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
Further Reading
- Testing Strategy - Overall testing approach and when to use E2E tests
- Integration Testing - Testing with real dependencies as an alternative to E2E
- Contract Testing - API contract validation to reduce E2E test needs
- React Testing - Component testing for React applications
- Angular Testing - Component testing for Angular applications
- React Native Overview - React Native development and testing
External Resources:
- Cypress Documentation - Modern E2E testing for web applications
- Detox Documentation - E2E testing for React Native
- Testing Library - User-centric testing utilities
Summary
Key Takeaways:
- Minimal E2E Coverage: E2E tests should be 3-5% of total test suite
- Critical Journeys Only: Test essential user flows like payment, login, account creation
- Stable Selectors: Use data-testid and semantic queries, not fragile CSS selectors
- Independent Tests: Each test sets up and tears down its own data
- Smart Waits: Wait for specific conditions, not arbitrary timeouts
- Cypress for Web: Use Cypress with Testing Library for web applications
- Detox for Mobile: Use Detox for React Native E2E testing
- CI Integration: Run E2E tests in GitLab pipelines with artifacts for debugging