Automated Testing for Web Apps with Cypress



This content originally appeared on DEV Community and was authored by keith mark

Why automated tests matter

Automated end-to-end (E2E) tests give you confidence that core user flows like login, signup, checkout, and profile updates work every time. They:

  • Catch regressions early: Breakages are detected before production.
  • Speed up releases: CI runs tests on every pull request.
  • Document expected behavior: Tests double as living documentation.
  • Reduce manual QA: Engineers focus on higher-value work.

Cypress is a developer-friendly E2E framework that runs in the browser, offers great debuggability, and has first-class tooling for network control, retries, and time-travel debugging. Below, you’ll find a full login test you can drop into a project, plus a parallel Playwright example for comparison.

Install Cypress

npm install --save-dev cypress

Optional but recommended scripts in package.json:

{
  "scripts": {
    "cypress:open": "cypress open",
    "cypress:run": "cypress run",
    "start": "your-app-start-command"
  }
}

Project setup (minimal)

Create a cypress.config.js to set a baseUrl and surface credentials via env. This helps keep tests clean and avoids hardcoding secrets.

// cypress.config.js
const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000', // change to your dev URL
    supportFile: 'cypress/support/e2e.js'
  },
  env: {
    USER_EMAIL: process.env.CYPRESS_USER_EMAIL,
    USER_PASSWORD: process.env.CYPRESS_USER_PASSWORD
  },
  video: true,
  screenshotOnRunFailure: true,
});

You can set environment variables in your shell before running tests:

  • PowerShell:
$env:CYPRESS_USER_EMAIL="tester@example.com"
$env:CYPRESS_USER_PASSWORD="supersecret"
  • Bash:
export CYPRESS_USER_EMAIL="tester@example.com"
export CYPRESS_USER_PASSWORD="supersecret"

Create a small support command to log in programmatically if you want to reuse the flow across specs:

// cypress/support/commands.js
Cypress.Commands.add('uiLogin', (email, password) => {
  cy.visit('/login');

  cy.get('input[name="email"]').should('be.visible').clear().type(email, { delay: 10 });
  cy.get('input[name="password"]').clear().type(password, { log: false, delay: 10 });

  cy.intercept('POST', '/api/auth/login').as('loginRequest');
  cy.get('button[type="submit"]').click();
  cy.wait('@loginRequest').its('response.statusCode').should('be.oneOf', [200, 201]);

  cy.location('pathname', { timeout: 10000 }).should('include', '/dashboard');
});

And import it in cypress/support/e2e.js:

// cypress/support/e2e.js
import './commands';

Code Example: full login test (Cypress)

Create cypress/e2e/login.cy.js:

// cypress/e2e/login.cy.js
describe('Login Test', () => {
  beforeEach(() => {
    // Optionally cache sessions to speed up subsequent tests
    // Requires Cypress v12+; remove if not needed
    cy.session('logged-in-user', () => {
      cy.visit('/login');
      cy.get('input[name="email"]').should('be.visible').type(Cypress.env('USER_EMAIL'), { delay: 10 });
      cy.get('input[name="password"]').type(Cypress.env('USER_PASSWORD'), { log: false, delay: 10 });

      cy.intercept('POST', '/api/auth/login').as('loginRequest');
      cy.get('button[type="submit"]').click();

      cy.wait('@loginRequest').then((interception) => {
        expect([200, 201]).to.include(interception.response.statusCode);
      });

      // Wait for the app to finish client-side routing
      cy.location('pathname', { timeout: 10000 }).should('include', '/dashboard');
      // Optional tiny wait for content hydration; prefer network waits when possible
      cy.wait(300); // avoid growing this; see best practices
    });
  });

  it('should login successfully and show user dashboard', () => {
    // With cy.session, this visit lands on the dashboard without repeating form input
    cy.visit('/dashboard');

    // Confirm a logged-in-only element renders
    cy.get('[data-testid="welcome-banner"]').should('contain.text', 'Welcome');

    // Verify a downstream API loads (example: profile)
    cy.intercept('GET', '/api/profile').as('profile');
    cy.reload();
    cy.wait('@profile').its('response.statusCode').should('eq', 200);

    // Basic assertions on the page content
    cy.get('[data-testid="user-email"]').should('have.text', Cypress.env('USER_EMAIL'));
  });

  it('should show an error on invalid credentials', () => {
    cy.visit('/login');

    cy.get('input[name="email"]').type('wrong@example.com', { delay: 10 });
    cy.get('input[name="password"]').type('badpassword', { log: false, delay: 10 });

    cy.intercept('POST', '/api/auth/login').as('loginRequest');
    cy.get('button[type="submit"]').click();

    cy.wait('@loginRequest').its('response.statusCode').should('be.oneOf', [400, 401, 403]);
    cy.get('[data-testid="login-error"]').should('be.visible').and('contain.text', 'Invalid');
  });
});

Notes:

  • Selectors like input[name="email"] and [data-testid="welcome-banner"] should match your app’s DOM. Prefer stable data-testid attributes over brittle text or class selectors.
  • The small { delay: 10 } typing delay is purely to emulate human input and can help flaky UI debounce logic; keep it small.
  • Use cy.wait('@alias') over arbitrary cy.wait(1000) wherever possible.

Headless run via command line

  • Run your dev server, then run Cypress in headless mode:
# In one terminal
npm start

# In another terminal
npx cypress run --browser chrome --headless
  • Target a single spec:
npx cypress run --spec "cypress/e2e/login.cy.js" --headless
  • Record and parallelize (with Cypress Cloud, optional):
npx cypress run --record --key <your-project-key> --parallel

Run tests in CI (YAML)

Here’s a minimal GitHub Actions workflow that installs dependencies, starts the app, waits for it to be ready, and runs Cypress headlessly.

# .github/workflows/e2e.yml
name: E2E Tests

on:
  pull_request:
  push:
    branches: [ main ]

jobs:
  cypress-run:
    runs-on: ubuntu-latest
    env:
      CYPRESS_USER_EMAIL: ${{ secrets.CYPRESS_USER_EMAIL }}
      CYPRESS_USER_PASSWORD: ${{ secrets.CYPRESS_USER_PASSWORD }}
      # Set your app's port/URL if different
      APP_URL: http://localhost:3000
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Use Node 20
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build app
        run: npm run build --if-present

      - name: Start app
        run: npm start &

      - name: Wait for app
        run: npx wait-on $APP_URL --timeout 60000

      - name: Run Cypress
        run: npx cypress run --browser chrome --headless

If your app needs a database or services, add them as services or spin them up via Docker Compose. Never commit credentials; use CI secrets.

Parallel example: same test in Playwright (optional comparison)

Install Playwright:

npm i -D @playwright/test
npx playwright install --with-deps

Create tests/login.spec.ts:

// tests/login.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Login Test', () => {
  test('should login successfully', async ({ page }) => {
    const email = process.env.PLAYWRIGHT_USER_EMAIL || 'tester@example.com';
    const password = process.env.PLAYWRIGHT_USER_PASSWORD || 'supersecret';

    await page.goto('http://localhost:3000/login');

    // Optional small delay to mimic human typing; keep it small
    await page.locator('input[name="email"]').fill(email, { timeout: 10000 });
    await page.locator('input[name="password"]').fill(password);

    const [response] = await Promise.all([
      page.waitForResponse((res) => res.url().endsWith('/api/auth/login') && res.status() < 400),
      page.locator('button[type="submit"]').click(),
    ]);

    expect(response.ok()).toBeTruthy();

    await expect(page).toHaveURL(/\/dashboard/);
    await expect(page.getByTestId('welcome-banner')).toContainText('Welcome');
  });
});

Run headless:

npx playwright test --project=chromium --reporter=line

Both tools are excellent. Cypress offers time-travel UI, automatic retrying of commands, and is very popular for front-end teams. Playwright is powerful for multi-browser/device coverage and broader automation APIs. Use whichever fits your stack and team preferences.

Best practices in test code

  • Use stable selectors: Prefer data-testid attributes over text, CSS classes, or ARIA labels that frequently change.
  • Avoid fixed sleeps: Prefer cy.intercept + cy.wait('@alias') and assert on network responses or DOM readiness. Use small, bounded waits only when strictly necessary.
  • Control the network: Intercept external calls; stub responses for deterministic tests. Let one or two “happy-path” tests hit real backends in a staging environment if you truly need it.
  • Keep tests atomic: Each test should set up its own state. Use cy.session or API calls to seed data quickly.
  • Scope to critical paths: Cover login, key CRUD flows, and payments first. Push edge-case permutations into unit/integration layers where faster.
  • Make failures actionable: Assert specific, user-visible outcomes. Keep logs, screenshots, and videos on failure.
  • Separate config/secrets: Use env vars and CI secrets (never hardcode credentials or API keys in the repo).
  • Run locally and in CI: Keep parity. If it passes locally but not in CI, ensure resource timing and baseUrl are consistent and use wait-on for server readiness.
  • Keep execution fast: Parallelize in CI, cache dependencies, and reuse sessions to cut run time.
  • Accessibility checks (nice-to-have): Incorporate quick a11y smoke checks to prevent regressions.

Avoid spam, add delays carefully, handle API limits

  • Avoid spamming real services: When your app calls third-party APIs (payments, auth, messaging), mock them in tests using cy.intercept. Only a minimal set of tests should call real services in an isolated staging environment.
  • Use bounded delays sparingly: Prefer event-driven waits. If a delay is unavoidable (e.g., animation or debounce), keep it small (≤300ms) and document why. Example:
// Use network waits first; resort to a small, bounded delay only if needed
cy.wait('@loginRequest');
cy.location('pathname').should('include', '/dashboard');
cy.wait(200); // bounded; do not grow this
  • Handle rate limits: If a test must call a rate-limited API, centralize calls (e.g., seed via backend API once per run), throttle tests, and retry on 429s with exponential backoff in your app code (not the test). In tests, assert the app’s backoff behavior instead of looping aggressive retries.

Drawbacks and cautions

  • Flakiness risk: E2E tests touch the full stack; network and timing can introduce flakes. Mitigate with deterministic data, network stubbing, and event-based waits.
  • Slower than unit tests: Keep E2E coverage focused on business-critical journeys; push logic to faster layers where possible.
  • Environment drift: CI and local can diverge. Lock Node versions, use wait-on, and ensure the same build flags.
  • Data management: Seed test data and clean up; use disposable accounts or a resettable test DB.
  • Security and keys: Never commit secrets. Use separate test credentials and scopes; rotate keys regularly.
  • Policy violations and blocking: Respect Terms of Service for any third-party API. Excessive test traffic can cause temporary blocking or permanent bans for abuse. Use stubs/mocks by default and isolate real calls in a controlled staging environment with quotas.

Conclusion

Automated E2E tests with Cypress give you high confidence that user-critical journeys keep working through rapid change. With a clean config, stable selectors, disciplined network control, and CI integration, you’ll ship faster with fewer regressions. Use minimal, carefully placed delays and prefer event-driven waits to avoid flakiness. When you must interact with external APIs, respect rate limits and policies, store credentials as secrets, and isolate any real traffic to staging. If you prefer a different runtime model or broader automation APIs, the Playwright example shows how similar the test can be choose the tool that best fits your team’s workflow.

  • Headless: npx cypress run --browser chrome --headless
  • CI: Use the provided GitHub Actions YAML with secrets and wait-on
  • Full login test: See cypress/e2e/login.cy.js above
  • Optional comparison: See tests/login.spec.ts for Playwright


This content originally appeared on DEV Community and was authored by keith mark