~/resources/patternssection live
$ qa open patterns --type all

//Patterns

Build tests that scale.

Production-ready code patterns for QA automation — page objects, fixtures, custom commands, test data builders, and more. Working examples in Cypress, Playwright, and Selenium.

>search patterns…⌘K
28
Patterns
8
Pattern types
Pattern
Framework

Showing 28 of 28 patterns

// 📐 PAGE OBJECTS · 5 PATTERNS

Page Object Model — typed and chainable

Page objectsCyp

A typed, chainable page object for a login page. Encapsulates selectors, exposes intent-named actions, and returns `this` for fluent test code.

export class LoginPage {
  visit(): this {
    cy.visit('/login');
    return this;
  }

  fillEmail(email: string): this {
    cy.get('[data-testid="email-input"]').type(email);
    return this;
  }

  fillPassword(password: string): this {
    cy.get('[data-testid="password-input"]').type(password, { log: false });
    return this;
  }

  submit(): this {
    cy.get('[data-testid="submit-btn"]').click();
    return this;
  }

  assertInvalidCredentials(): this {
    cy.get('[data-testid="error-message"]')
      .should('be.visible')
      .and('contain', 'Invalid credentials');
    return this;
  }
}

// Setup

Place loginPage.ts in cypress/pages/. No extra packages required — the class uses global cy commands. Ensure cypress/tsconfig.json includes "types": ["cypress"] so TypeScript resolves cy.

// How to use

Instantiate once per describe block (const loginPage = new LoginPage()) and chain methods: loginPage.visit().fillEmail('…').submit(). Each method returns this, so assertions can be chained inline too.

Page Object Model — Playwright typed

Page objectsPW

A Playwright page object using `Locator` fields for resilient selectors and `Promise<void>` action methods. The class accepts a `Page` in its constructor for easy reuse across fixtures.

import { Page, Locator } from '@playwright/test';

export class LoginPage {
  private readonly emailInput: Locator;
  private readonly passwordInput: Locator;
  private readonly submitButton: Locator;
  private readonly errorMessage: Locator;

  constructor(private readonly page: Page) {
    this.emailInput = page.getByTestId('email-input');
    this.passwordInput = page.getByTestId('password-input');
    this.submitButton = page.getByTestId('submit-btn');
    this.errorMessage = page.getByTestId('error-message');
  }

  async visit(): Promise<void> {
    await this.page.goto('/login');
  }

  async login(email: string, password: string): Promise<void> {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async getErrorText(): Promise<string | null> {
    return this.errorMessage.textContent();
  }
}

// Setup

Place login-page.ts in tests/pages/. No extra packages are needed beyond @playwright/test. Use getByTestId to stay selector-agnostic — configure testIdAttribute in playwright.config.ts if your app uses a different attribute.

// How to use

Create a new instance per test.beforeEach so each test gets a fresh page state: loginPage = new LoginPage(page). Playwright's Locator fields are lazily evaluated, so instantiation is cheap.

Page Object Model — abstract base class

Page objectsCyp

An abstract `BasePage` with shared utilities (navigation, screenshot, URL assertion) that concrete pages extend. Eliminates boilerplate and keeps shared behaviour in one place.

export abstract class BasePage {
  abstract readonly path: string;

  visit(): this {
    cy.visit(this.path);
    return this;
  }

  waitForLoad(): this {
    cy.get('[data-testid="page-loader"]').should('not.exist');
    return this;
  }

  takeScreenshot(name: string): this {
    cy.screenshot(name);
    return this;
  }

  assertUrl(pattern: string | RegExp): this {
    const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
    cy.url().should('match', regex);
    return this;
  }

  assertTitle(expected: string): this {
    cy.title().should('include', expected);
    return this;
  }
}

// Setup

Place both files under cypress/pages/. Import concrete pages in your spec files; never import BasePage directly. TypeScript will enforce that path is declared on each subclass.

// How to use

Extend BasePage for each page: class DashboardPage extends BasePage { readonly path = '/dashboard'; }. Methods returning this are inherited, so you can chain new DashboardPage().visit().waitForLoad().assertWelcomeBanner('Alice').

Page Object Model — Selenium PageFactory

Page objectsSel

A Selenium 4 page object using `@FindBy` annotations and `PageFactory.initElements` to bind fields to DOM elements. Uses an explicit `WebDriverWait` for reliable element interactions.

package pages;

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

import java.time.Duration;

public class LoginPage {

    private final WebDriver driver;
    private final WebDriverWait wait;

    @FindBy(id = "email")
    private WebElement emailInput;

    @FindBy(id = "password")
    private WebElement passwordInput;

    @FindBy(css = "[data-testid='submit-btn']")
    private WebElement submitButton;

    @FindBy(css = "[data-testid='error-message']")
    private WebElement errorMessage;

    public LoginPage(WebDriver driver) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
        PageFactory.initElements(driver, this);
    }

    public void visit(String baseUrl) {
        driver.get(baseUrl + "/login");
    }

    public void login(String email, String password) {
        wait.until(ExpectedConditions.visibilityOf(emailInput));
        emailInput.clear();
        emailInput.sendKeys(email);
        passwordInput.sendKeys(password);
        submitButton.click();
    }

    public String getErrorMessage() {
        wait.until(ExpectedConditions.visibilityOf(errorMessage));
        return errorMessage.getText();
    }
}

// Setup

Add selenium-java and testng (or JUnit 5) to your pom.xml. PageFactory.initElements must be called in the constructor — fields annotated with @FindBy are proxied lazily so no NoSuchElementException is thrown at construction time.

// How to use

Instantiate with new LoginPage(driver) in a @BeforeMethod. Call loginPage.visit(baseUrl) then loginPage.login(email, password). Use getErrorMessage() to assert validation text.

Page Object Model — component composition

Page objectsPW

Breaks a complex page into smaller component objects. `CheckoutPage` owns a `ShippingForm` component, each scoped to its own DOM region. Tests interact with components through the page object.

import { Page, Locator } from '@playwright/test';

export class ShippingForm {
  private readonly root: Locator;

  constructor(page: Page) {
    this.root = page.getByTestId('shipping-form');
  }

  async fillAddress(address: string): Promise<void> {
    await this.root.getByLabel('Street address').fill(address);
  }

  async fillCity(city: string): Promise<void> {
    await this.root.getByLabel('City').fill(city);
  }

  async fillPostcode(postcode: string): Promise<void> {
    await this.root.getByLabel('Postcode').fill(postcode);
  }

  async selectCountry(country: string): Promise<void> {
    await this.root.getByLabel('Country').selectOption(country);
  }
}

// Setup

Place component classes under tests/pages/components/. Scoping locators to a root element (page.getByTestId('shipping-form')) keeps component selectors isolated so duplicate labels on the same page don't cause ambiguity.

// How to use

Access sub-components via the parent page object: const checkout = new CheckoutPage(page); await checkout.shippingForm.fillAddress('1 High Street');. Components can be reused across multiple page objects that embed the same UI region.

// ⌨️ CUSTOM COMMANDS · 4 PATTERNS

cy.login — session-cached login command

Custom commandsCyp

A `cy.login` command that wraps `cy.session` to cache the authenticated session across tests. The session is keyed by email so multiple user roles can be cached simultaneously.

declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password?: string): Chainable<void>;
    }
  }
}

Cypress.Commands.add('login', (email: string, password = 'Password123!') => {
  cy.session(
    [email],
    () => {
      cy.visit('/login');
      cy.get('[data-testid="email-input"]').type(email);
      cy.get('[data-testid="password-input"]').type(password, { log: false });
      cy.get('[data-testid="submit-btn"]').click();
      cy.url().should('not.include', '/login');
    },
    {
      cacheAcrossSpecs: true,
      validate() {
        // Re-run setup if the session cookie has expired
        cy.request({ url: '/api/me', failOnStatusCode: false })
          .its('status')
          .should('eq', 200);
      },
    },
  );
});

export {};

// Setup

Import ./commands in cypress/support/e2e.ts. Set cacheAcrossSpecs: true only when tests in different spec files share the same user; omit it for spec-isolated sessions.

// How to use

Call cy.login('admin@example.com') in beforeEach — Cypress will reuse the cached session on every subsequent call with the same email, skipping the UI login flow entirely.

cy.dataCy — data-cy selector helper

Custom commandsCyp

A tiny `cy.dataCy` command that wraps `cy.get('[data-cy="..."]')`. Keeps selectors DRY, makes intent clear, and makes a future attribute rename a one-line change.

declare global {
  namespace Cypress {
    interface Chainable {
      /**
       * Select an element by its data-cy attribute.
       * Prefer this over cy.get('[data-cy="..."]') for readability.
       */
      dataCy(value: string): Chainable<JQuery<HTMLElement>>;
    }
  }
}

Cypress.Commands.add('dataCy', (value: string) => {
  return cy.get(`[data-cy="${value}"]`);
});

export {};

// Setup

Import ./commands in cypress/support/e2e.ts. Add data-cy attributes to your HTML elements — they are stripped by most bundlers in production builds if you configure babel-plugin-react-remove-properties.

// How to use

Replace cy.get('[data-cy="submit-btn"]') with cy.dataCy('submit-btn'). Chain Cypress assertions and actions normally: cy.dataCy('submit-btn').should('be.visible').click().

Playwright — loggedInPage fixture

Custom commandsPW

Extends Playwright's `test` object with a `loggedInPage` fixture that authenticates before the test body runs. Tests import from the fixture file instead of `@playwright/test`.

import { test as base, Page } from '@playwright/test';

type LoginFixtures = {
  loggedInPage: Page;
};

export const test = base.extend<LoginFixtures>({
  loggedInPage: async ({ page }, use) => {
    await page.goto('/login');
    await page.getByTestId('email-input').fill('user@example.com');
    await page.getByTestId('password-input').fill('Password123!');
    await page.getByTestId('submit-btn').click();
    await page.waitForURL('/dashboard');

    // Hand the authenticated page to the test
    await use(page);
  },
});

export { expect } from '@playwright/test';

// Setup

Run npm install --save-dev @playwright/test. Place fixture files under tests/fixtures/. Tests import test and expect from the fixture module, not from @playwright/test directly.

// How to use

Destructure loggedInPage in the test signature: async ({ loggedInPage: page }) => { ... }. Rename it to page via destructuring to keep test code readable. The fixture runs once per test, not once per suite.

cy.createUser — API seeding before UI test

Custom commandsCyp

A `cy.createUser` command that POSTs to a test-only API endpoint to seed a user before the UI test runs. Faster than driving the registration UI and leaves no leftover state.

type User = { id: string; email: string; name: string };

declare global {
  namespace Cypress {
    interface Chainable {
      createUser(overrides?: Partial<Omit<User, 'id'>>): Chainable<User>;
    }
  }
}

Cypress.Commands.add('createUser', (overrides = {}) => {
  const payload = {
    email: `user-${Date.now()}@example.com`,
    name: 'Test User',
    role: 'member',
    ...overrides,
  };

  return cy
    .request({
      method: 'POST',
      url: `${Cypress.env('apiUrl')}/test/users`,
      body: payload,
      headers: { 'x-test-api-key': Cypress.env('testApiKey') },
    })
    .its('body');
});

export {};

// Setup

Set apiUrl and testApiKey in cypress.env.json or as environment variables (CYPRESS_apiUrl, CYPRESS_testApiKey). The test endpoint should only be available in non-production environments.

// How to use

Call cy.createUser({ name: 'Alice Smith' }) in beforeEach and delete the record in afterEach for full isolation. Pass overrides to customise email, name, or role without changing the default shape.

// 📁 FIXTURES · 4 PATTERNS

Playwright — worker-scoped database fixture

FixturesPW

A worker-scoped fixture that shares a single PostgreSQL connection pool client across all tests in the same worker process. Avoids creating a new connection per test while keeping workers isolated from each other.

import { test as base } from '@playwright/test';
import { Pool, PoolClient } from 'pg';

type DbFixtures = {
  db: PoolClient;
};

// One pool per worker process
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export const test = base.extend<Record<string, never>, DbFixtures>({
  db: [
    async ({}, use) => {
      const client = await pool.connect();
      try {
        await use(client);
      } finally {
        client.release();
      }
    },
    { scope: 'worker' },
  ],
});

export { expect } from '@playwright/test';

// Setup

Run npm install pg @types/pg. Set DATABASE_URL in your environment or a .env file loaded via dotenv. Import test from this fixture in any spec that needs direct DB access.

// How to use

Use db for setup or teardown queries: await db.query('DELETE FROM users WHERE email LIKE \'test-%\''). The connection is released automatically after each test thanks to the finally block.

Playwright — per-test user fixture with teardown

FixturesPW

A test-scoped fixture that creates a fresh user via the API before the test and deletes it afterwards. The `use`/teardown pattern guarantees cleanup even when the test fails.

import { test as base } from '@playwright/test';

type UserRecord = {
  id: string;
  email: string;
  name: string;
};

type UserFixtures = {
  testUser: UserRecord;
};

export const test = base.extend<UserFixtures>({
  testUser: async ({ request }, use) => {
    // Create a unique user before the test
    const response = await request.post('/api/test/users', {
      data: {
        email: `user-${Date.now()}@example.com`,
        name: 'Test User',
      },
    });
    const user: UserRecord = await response.json();

    await use(user);

    // Delete the user after the test, even if the test failed
    await request.delete(`/api/test/users/${user.id}`);
  },
});

export { expect } from '@playwright/test';

// Setup

No extra packages beyond @playwright/test. The fixture uses Playwright's built-in request context which shares auth state with the test's browser context. Import test from this file in specs that need an isolated user.

// How to use

Destructure testUser in the test signature: async ({ testUser, page }) => { ... }. The user is always deleted after the test — no afterEach block required in the spec file.

Cypress — typed JSON fixture loading

FixturesCyp

Loads a typed JSON fixture file with `cy.fixture<T>()` and uses it to drive role-based login tests. The generic parameter gives full IntelliSense on the fixture data inside the test.

{
  "admin": {
    "email": "admin@example.com",
    "password": "AdminPass123!",
    "role": "admin"
  },
  "member": {
    "email": "member@example.com",
    "password": "MemberPass123!",
    "role": "member"
  },
  "readonly": {
    "email": "readonly@example.com",
    "password": "ReadPass123!",
    "role": "readonly"
  }
}

// Setup

Place JSON fixture files under cypress/fixtures/. Cypress automatically resolves the file path relative to that directory — no import statements required. Declare matching TypeScript types in the spec file or a shared types/ folder.

// How to use

Load fixtures in a before hook (not beforeEach) to avoid re-reading the file on every test. Access nested properties with full type safety: users.admin.email works without casting.

pytest — parameterised browser fixture

FixturesSel

A pytest `conftest.py` fixture that provisions a `WebDriver` instance based on a `--browser` CLI option. Supports Chrome, Firefox, and Safari. The driver is torn down after each test.

import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options as ChromeOptions
from selenium.webdriver.firefox.options import Options as FirefoxOptions


def pytest_addoption(parser):
    parser.addoption(
        "--browser",
        action="store",
        default="chrome",
        choices=["chrome", "firefox", "safari"],
        help="Browser to run tests against",
    )


@pytest.fixture(scope="session")
def browser_name(request):
    return request.config.getoption("--browser")


@pytest.fixture
def driver(browser_name):
    if browser_name == "chrome":
        opts = ChromeOptions()
        opts.add_argument("--headless=new")
        opts.add_argument("--no-sandbox")
        opts.add_argument("--disable-dev-shm-usage")
        d = webdriver.Chrome(options=opts)
    elif browser_name == "firefox":
        opts = FirefoxOptions()
        opts.add_argument("--headless")
        d = webdriver.Firefox(options=opts)
    else:
        d = webdriver.Safari()

    d.implicitly_wait(10)
    yield d
    d.quit()

// Setup

Run pip install selenium pytest. For Chrome/Firefox, install the matching driver binary (chromedriver, geckodriver) or use webdriver-manager. Safari on macOS needs safaridriver --enable run once as an administrator.

// How to use

Run tests with pytest --browser=firefox to target Firefox. The driver fixture scope is per-test by default — add scope='session' for a shared browser if your tests are order-independent.

// 🗃️ TEST DATA · 3 PATTERNS

Test data builder — fluent API

Test dataAny

A `UserBuilder` with a fluent API for constructing test users. Each method returns `this` for chaining, and `build()` returns an immutable copy. Works with any test framework.

export type User = {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'member' | 'readonly';
  isVerified: boolean;
};

export class UserBuilder {
  private user: User = {
    id: crypto.randomUUID(),
    email: 'user@example.com',
    name: 'Test User',
    role: 'member',
    isVerified: true,
  };

  withEmail(email: string): this {
    this.user = { ...this.user, email };
    return this;
  }

  withName(name: string): this {
    this.user = { ...this.user, name };
    return this;
  }

  asAdmin(): this {
    this.user = { ...this.user, role: 'admin' };
    return this;
  }

  asReadonly(): this {
    this.user = { ...this.user, role: 'readonly' };
    return this;
  }

  unverified(): this {
    this.user = { ...this.user, isVerified: false };
    return this;
  }

  build(): User {
    return { ...this.user };
  }
}

// Setup

No dependencies required — crypto.randomUUID() is built into Node 16+ and all modern browsers. Place under src/builders/ and import wherever test data is needed.

// How to use

Each call to build() returns a fresh object copy, so a single builder can produce multiple independent users. Override only what matters for the test; defaults handle the rest.

Faker factory — realistic test users

Test dataAny

A `createUser` factory that uses `@faker-js/faker` to generate realistic user data. Accepts optional overrides so tests can fix specific fields while faker fills the rest.

import { faker } from '@faker-js/faker';

export type User = {
  id: string;
  email: string;
  name: string;
  username: string;
  role: 'admin' | 'member' | 'readonly';
  avatarUrl: string;
  createdAt: string;
};

export function createUser(overrides: Partial<User> = {}): User {
  return {
    id: faker.string.uuid(),
    email: faker.internet.email().toLowerCase(),
    name: faker.person.fullName(),
    username: faker.internet.username().toLowerCase(),
    role: 'member',
    avatarUrl: faker.image.avatar(),
    createdAt: faker.date.past().toISOString(),
    ...overrides,
  };
}

export function createUsers(count: number, overrides: Partial<User> = {}): User[] {
  return Array.from({ length: count }, () => createUser(overrides));
}

// Setup

Run npm install --save-dev @faker-js/faker. For reproducible test runs, seed faker before your suite: faker.seed(12345). Reset with faker.seed() for random data per run.

// How to use

Call createUser() for a fully random user or createUser({ role: 'admin' }) to fix specific fields. Use createUsers(10) to generate bulk data for pagination or load tests.

Cypress — seed and cleanup per test

Test dataCyp

Custom commands `cy.seedTestData` and `cy.cleanupTestData` that hit a test API to set up and tear down data around each test, ensuring full isolation without leaking state across tests.

type SeedData = { orderId: string; userId: string };

declare global {
  namespace Cypress {
    interface Chainable {
      seedTestData(): Chainable<SeedData>;
      cleanupTestData(data: SeedData): Chainable<void>;
    }
  }
}

Cypress.Commands.add('seedTestData', () => {
  return cy
    .request({
      method: 'POST',
      url: `${Cypress.env('apiUrl')}/test/seed`,
      headers: { 'x-test-api-key': Cypress.env('testApiKey') },
    })
    .its('body');
});

Cypress.Commands.add('cleanupTestData', ({ userId }: SeedData) => {
  cy.request({
    method: 'DELETE',
    url: `${Cypress.env('apiUrl')}/test/seed/${userId}`,
    headers: { 'x-test-api-key': Cypress.env('testApiKey') },
    failOnStatusCode: false,
  });
});

export {};

// Setup

Expose POST /test/seed and DELETE /test/seed/:userId endpoints in your app server, protected by x-test-api-key. Set apiUrl and testApiKey in cypress.env.json.

// How to use

Always pair seedTestData in beforeEach with cleanupTestData in afterEach. Use failOnStatusCode: false on the delete request so a cleanup failure doesn't mask a test failure.

// 🔐 AUTH · 3 PATTERNS

Playwright — storageState global setup

AuthPW

A `globalSetup` function that logs in once programmatically and saves the browser storage state to disk. The authenticated project in `playwright.config.ts` reuses this state so no test needs to log in.

import { chromium, FullConfig } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';

export default async function globalSetup(config: FullConfig): Promise<void> {
  const { baseURL } = config.projects[0].use;
  const authDir = path.resolve('.auth');

  if (!fs.existsSync(authDir)) {
    fs.mkdirSync(authDir, { recursive: true });
  }

  const browser = await chromium.launch();
  const page = await browser.newPage();

  await page.goto(`${baseURL}/login`);
  await page.getByTestId('email-input').fill(process.env.TEST_EMAIL ?? 'user@example.com');
  await page.getByTestId('password-input').fill(process.env.TEST_PASSWORD ?? 'Password123!');
  await page.getByTestId('submit-btn').click();
  await page.waitForURL('**/dashboard');

  await page.context().storageState({ path: path.join(authDir, 'user.json') });
  await browser.close();
}

// Setup

Add .auth/ to .gitignore. Set TEST_EMAIL and TEST_PASSWORD as environment variables or in a .env file. Run npx playwright install chromium if you haven't already.

// How to use

Tests in the authenticated project automatically receive the saved cookies and localStorage — no cy.login() or beforeEach login needed. Run npx playwright test --project=authenticated to target only authenticated tests.

Cypress — cy.session with validation callback

AuthCyp

A `cy.login` command built on `cy.session` with a `validate` callback that checks the `/api/me` endpoint. If the session expires, Cypress automatically re-runs the setup function.

declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password?: string): Chainable<void>;
    }
  }
}

Cypress.Commands.add('login', (email: string, password = 'Password123!') => {
  cy.session(
    // Session key — include email so different roles cache separately
    [email],
    () => {
      cy.visit('/login');
      cy.get('[data-testid="email-input"]').type(email);
      cy.get('[data-testid="password-input"]').type(password, { log: false });
      cy.get('[data-testid="submit-btn"]').click();
      cy.url().should('not.include', '/login');
    },
    {
      cacheAcrossSpecs: true,
      validate() {
        cy.request({ url: '/api/me', failOnStatusCode: false })
          .its('status')
          .should('eq', 200);
      },
    },
  );
});

export {};

// Setup

Import ./commands in cypress/support/e2e.ts. Ensure your app sets a session cookie (not just localStorage) so Cypress can persist and restore it. Set experimentalSessionAndOrigin to true in Cypress 12 and earlier.

// How to use

Call cy.login('admin@example.com') in beforeEach. On the first call it runs the full UI login; on subsequent calls it restores the cached session instantly. The validate callback guards against expired sessions.

Cypress — JWT injection via API login

AuthCyp

A `cy.loginByApi` command that POSTs credentials to the auth endpoint, extracts the JWT, and injects it into both `localStorage` and a cookie. Bypasses the login UI entirely for maximum speed.

declare global {
  namespace Cypress {
    interface Chainable {
      loginByApi(email: string, password?: string): Chainable<void>;
    }
  }
}

Cypress.Commands.add('loginByApi', (email: string, password = 'Password123!') => {
  cy.request({
    method: 'POST',
    url: `${Cypress.env('apiUrl')}/auth/login`,
    body: { email, password },
  }).then(({ body }) => {
    const { token } = body as { token: string };

    // Store in Cypress.env so other commands can read it without touching the window
    Cypress.env('authToken', token);

    // cy.window() accesses the AUT window — window.localStorage would set the test runner's storage
    cy.window().then((win) => {
      win.localStorage.setItem('auth_token', token);
    });

    // Also set as a cookie for apps that use cookie-based auth checks
    cy.setCookie('auth_token', token, {
      httpOnly: false,
      secure: false,
    });
  });
});

export {};

// Setup

Set apiUrl in cypress.env.json. This command must be called before cy.visit() so the token is present when the app loads. If your app reads the token from Authorization header only, configure cy.intercept to inject it instead.

// How to use

Use cy.loginByApi('user@example.com') followed immediately by cy.visit('/dashboard'). This is ~10× faster than UI login. Use it for tests where the login flow itself is not under test.

// 🔌 API HELPERS · 3 PATTERNS

Cypress — typed API client with retry

API helpersCyp

A typed `apiRequest` helper that wraps `cy.request` with automatic Bearer token injection, type-safe response casting, and retry logic for intermittent 5xx errors.

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

interface ApiOptions<B = unknown> {
  method?: HttpMethod;
  body?: B;
  retries?: number;
}

function getToken(): string {
  return Cypress.env<string>('authToken') ?? '';
}

export function apiRequest<T = unknown>(
  path: string,
  { method = 'GET', body, retries = 2 }: ApiOptions = {},
): Cypress.Chainable<T> {
  return cy
    .request({
      method,
      url: `${Cypress.env('apiUrl')}${path}`,
      body,
      headers: {
        Authorization: `Bearer ${getToken()}`,
        'Content-Type': 'application/json',
      },
      failOnStatusCode: false,
    })
    .then((response) => {
      if (response.status >= 500 && retries > 0) {
        cy.log(`[apiClient] Retrying ${method} ${path} (${retries} left)`);
        return apiRequest<T>(path, { method, body, retries: retries - 1 });
      }
      expect(response.status, `${method} ${path} status`).to.be.lessThan(400);
      return response.body as T;
    });
}

// Setup

Import apiRequest in spec files: import { apiRequest } from '../support/apiClient'. Set apiUrl in cypress.env.json. The helper reads the JWT from localStorage — ensure cy.loginByApi or similar has been called first.

// How to use

Call apiRequest<User[]>('/users') for a GET or apiRequest<User>('/users', { method: 'POST', body: payload }) for a POST. The generic T parameter types the resolved value so tests get full IntelliSense.

Playwright — dedicated API request context

API helpersPW

Uses `playwright.request.newContext` to create a dedicated API context with shared auth headers. Keeps API tests completely separate from browser tests and reuses the context across the `describe` block.

import { test, expect, APIRequestContext } from '@playwright/test';

type User = { id: string; email: string; name: string; role: string };

test.describe('Users API', () => {
  let api: APIRequestContext;

  test.beforeAll(async ({ playwright }) => {
    api = await playwright.request.newContext({
      baseURL: process.env.API_URL ?? 'http://localhost:3001',
      extraHTTPHeaders: {
        Authorization: `Bearer ${process.env.API_TOKEN ?? ''}`,
        'Content-Type': 'application/json',
      },
    });
  });

  test.afterAll(async () => {
    await api.dispose();
  });

  test('GET /users returns a list of users', async () => {
    const response = await api.get('/users');
    expect(response.ok()).toBeTruthy();
    const users: User[] = await response.json();
    expect(Array.isArray(users)).toBe(true);
    expect(users[0]).toMatchObject({
      id: expect.any(String),
      email: expect.any(String),
    });
  });

  test('POST /users creates a new user', async () => {
    const payload = {
      email: `test-${Date.now()}@example.com`,
      name: 'Test User',
      role: 'member',
    };
    const response = await api.post('/users', { data: payload });
    expect(response.status()).toBe(201);
    const user: User = await response.json();
    expect(user.email).toBe(payload.email);
  });
});

// Setup

Set API_URL and API_TOKEN as environment variables. The newContext isolates cookies and headers from the browser context — ideal for backend API tests that run alongside UI tests in the same suite.

// How to use

Create the context in beforeAll (not beforeEach) to reuse the same connection pool. Always call api.dispose() in afterAll to release resources. Type response bodies with a generic: const users: User[] = await response.json().

REST Assured — shared base test class

API helpersSel

A `BaseApiTest` class that configures a shared REST Assured `RequestSpecification` with base URI, auth header, and request/response logging. Subclass it in every API test class to avoid repeated setup.

package api;

import io.restassured.RestAssured;
import io.restassured.builder.RequestSpecBuilder;
import io.restassured.filter.log.RequestLoggingFilter;
import io.restassured.filter.log.ResponseLoggingFilter;
import io.restassured.response.Response;
import io.restassured.specification.RequestSpecification;
import org.testng.annotations.BeforeClass;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.lessThan;

public abstract class BaseApiTest {

    protected RequestSpecification spec;

    @BeforeClass
    public void setUpSpec() {
        String baseUri = System.getenv().getOrDefault("API_URL", "http://localhost:3001");
        String token   = System.getenv("API_TOKEN");

        RequestSpecBuilder builder = new RequestSpecBuilder()
            .setBaseUri(baseUri)
            .addHeader("Content-Type", "application/json")
            .addHeader("Accept", "application/json")
            .addFilter(new RequestLoggingFilter())
            .addFilter(new ResponseLoggingFilter());

        if (token != null && !token.isBlank()) {
            builder.addHeader("Authorization", "Bearer " + token);
        }

        spec = builder.build();
        RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
    }

    protected Response get(String path) {
        return given().spec(spec).when().get(path);
    }

    protected Response post(String path, Object body) {
        return given().spec(spec).body(body).when().post(path);
    }

    protected void assertFastResponse(Response response, long maxMs) {
        response.then().time(lessThan(maxMs));
    }
}

// Setup

Add rest-assured and testng to pom.xml. Set API_URL and API_TOKEN as environment variables before running mvn test. Subclass BaseApiTest in each test class: public class UsersApiTest extends BaseApiTest { ... }.

// How to use

Use the inherited get() and post() helpers in subclasses: Response r = get("/users"); r.then().statusCode(200). Override setUpSpec() and call super.setUpSpec() to add test-class-specific headers.

// 👁️ VISUAL · 3 PATTERNS

Percy — Cypress visual snapshots

VisualCyp

Integrates Percy into a Cypress suite for cross-browser visual regression. Snapshots are taken at key UI states and compared in the Percy dashboard on every CI run.

// Enable Percy visual testing — must appear before other support imports
import '@percy/cypress';
import './commands';

// Setup

Run npm install --save-dev @percy/cypress @percy/cli. Set PERCY_TOKEN as an environment variable (from your Percy project settings). Add percy exec -- cypress run to your CI pipeline command.

// How to use

Take snapshots at meaningful states, not every click. Name snapshots descriptively ('Home page — mobile') — Percy uses the name to group baselines. Run npx percy exec -- cypress run locally to see comparisons.

Playwright — toHaveScreenshot visual assertions

VisualPW

Uses Playwright's built-in `toHaveScreenshot` matcher for pixel-level visual regression without external services. Baseline images are committed to the repo and compared on every run.

import { test, expect } from '@playwright/test';

test.describe('Visual regression', () => {
  test('home page matches baseline', async ({ page }) => {
    await page.goto('/');
    // Wait for fonts and lazy images before snapping
    await page.waitForLoadState('networkidle');
    await expect(page).toHaveScreenshot('home-page.png', {
      fullPage: true,
      maxDiffPixels: 100,
    });
  });

  test('login page matches baseline', async ({ page }) => {
    await page.goto('/login');
    await page.waitForLoadState('networkidle');
    await expect(page).toHaveScreenshot('login-page.png', {
      maxDiffPixels: 50,
    });
  });

  test('summary card component matches baseline', async ({ page }) => {
    await page.goto('/dashboard');
    const card = page.getByTestId('summary-card').first();
    await expect(card).toHaveScreenshot('summary-card.png');
  });
});

// Setup

No extra packages needed — toHaveScreenshot is built into @playwright/test. Generate initial baseline images by running npx playwright test --update-snapshots. Commit the *.png files alongside the spec. Set maxDiffPixels or threshold to tolerate anti-aliasing differences.

// How to use

Update baselines after intentional UI changes with npx playwright test --update-snapshots. Scope assertions to a specific Locator (not the full page) to reduce snapshot noise from unrelated UI changes.

Applitools Eyes — Playwright integration

VisualPW

Integrates Applitools Eyes with Playwright for AI-powered visual testing across viewports. Opens Eyes in `beforeEach`, captures checks at key states, and closes Eyes in `afterEach`.

import { test } from '@playwright/test';
import { Eyes, Target, Configuration, BatchInfo } from '@applitools/eyes-playwright';

const batch = new BatchInfo({ name: 'QA Codes — Visual Suite' });

test.describe('Applitools visual checks', () => {
  let eyes: Eyes;

  test.beforeEach(async ({ page }) => {
    eyes = new Eyes();
    const config = new Configuration();
    config.setBatch(batch);
    config.setApiKey(process.env.APPLITOOLS_API_KEY ?? '');
    eyes.setConfiguration(config);
    await eyes.open(
      page,
      'QA Codes App',
      test.info().title,
      { width: 1280, height: 720 },
    );
  });

  test.afterEach(async () => {
    await eyes.close();
  });

  test('home page — desktop and mobile viewports', async ({ page }) => {
    await page.goto('/');
    await eyes.check('Home — desktop', Target.window().fully());

    await page.setViewportSize({ width: 375, height: 812 });
    await eyes.check('Home — mobile', Target.window().fully());
  });

  test('login form renders consistently', async ({ page }) => {
    await page.goto('/login');
    await page.waitForLoadState('networkidle');
    await eyes.check(
      'Login form',
      Target.region(page.getByTestId('login-form')),
    );
  });
});

// Setup

Run npm install --save-dev @applitools/eyes-playwright. Set APPLITOOLS_API_KEY as an environment variable (from your Applitools dashboard). Eyes uses the Ultrafast Grid by default — set config.setForceFullPageScreenshot(true) for single-browser mode.

// How to use

Use Target.window().fully() for full-page checks and Target.region(locator) for component checks. Applitools ignores minor rendering differences (anti-aliasing, font rendering) automatically — set a stricter MatchLevel if needed.

// ♿ A11Y · 3 PATTERNS

axe — Cypress accessibility audit

A11yCyp

Integrates `cypress-axe` to run WCAG 2AA accessibility audits on each route. A custom `violationCallback` logs actionable details for any violations found during CI runs.

import 'cypress-axe';
import './commands';

// Setup

Run npm install --save-dev cypress-axe axe-core. Import cypress-axe in cypress/support/e2e.ts. Call cy.injectAxe() after cy.visit() — it must be called per page load as it injects a script tag.

// How to use

Run the full audit with cy.checkA11y() or scope to a component: cy.checkA11y('[data-testid="nav"]'). Set includedImpacts: ['critical', 'serious'] in CI to fail only on high-severity issues; use ['minor'] locally for triage.

axe — Playwright accessibility helper

A11yPW

A reusable `auditA11y` helper built on `@axe-core/playwright` that runs a WCAG audit and returns typed violations. Tests import the helper and assert on the violation array.

import { Page } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

export type A11yViolation = {
  id: string;
  impact: string;
  description: string;
  nodes: number;
};

export async function auditA11y(
  page: Page,
  tags = ['wcag2a', 'wcag2aa'],
): Promise<A11yViolation[]> {
  const results = await new AxeBuilder({ page }).withTags(tags).analyze();
  return results.violations.map((v) => ({
    id: v.id,
    impact: v.impact ?? 'unknown',
    description: v.description,
    nodes: v.nodes.length,
  }));
}

// Setup

Run npm install --save-dev @axe-core/playwright. The helper wraps the AxeBuilder API — no global setup required. Import it in any spec file that needs an accessibility audit.

// How to use

Call await auditA11y(page) after navigation. The failure message includes the violation ID, impact level, and number of affected nodes so developers can act on it without opening the axe report manually.

Pa11y CI — automated accessibility pipeline

A11yAny

A Pa11y CI configuration file that audits multiple URLs against WCAG2AA in a single CI step. Pairs with an npm script that starts the app server before running the audit.

{
  "defaults": {
    "standard": "WCAG2AA",
    "timeout": 60000,
    "wait": 1000,
    "threshold": 0,
    "ignore": [
      "notice",
      "warning"
    ],
    "chromeLaunchConfig": {
      "args": ["--no-sandbox", "--disable-setuid-sandbox"]
    }
  },
  "urls": [
    "http://localhost:3000/",
    "http://localhost:3000/login",
    "http://localhost:3000/dashboard",
    "http://localhost:3000/orders",
    "http://localhost:3000/profile"
  ]
}

// Setup

Run npm install --save-dev pa11y-ci start-server-and-test. Place .pa11yci.json at the project root. In CI, use the test:a11y:ci script which starts your app server first, waits for it to be ready, then runs the audit.

// How to use

Set threshold to a positive integer (e.g. 5) to allow a known number of violations while new ones still fail the build. Use ignore: ["notice", "warning"] to surface only errors. Add authenticated URLs by configuring Pa11y's actions array to log in first.