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 stabledata-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 arbitrarycy.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

keith mark | Sciencx (2025-08-19T18:09:52+00:00) Automated Testing for Web Apps with Cypress. Retrieved from https://www.scien.cx/2025/08/19/automated-testing-for-web-apps-with-cypress/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.