Test Automation Best Practices

May 6, 2026 · View on GitHub

Test Automation Best Practices

The definitive reference for production-grade test automation engineering.

29 battle-tested patterns — from unit testing to security scanning — in a single, runnable full-stack project.

CI Coverage Status Quality Gate Status Coverage Bugs Code Smells Passed Tests Failed Tests

Demo Animation

Live Allure Report · BrowserStack Report · Percy Report · Live Demo · Report a Bug


Why this repository exists

Most tutorials show you how to write a test. This repository shows you how to build a test suite — one that scales with your team, survives production incidents, and stays maintainable after hundreds of commits.

Every pattern here solves a real problem that teams hit in production:

PatternThe problem it solves
Page Object ModelSelector changes in one place, not 50 files
Hybrid E2ESlow, flaky UI setup replaced with sub-millisecond API calls
E2E Code Coverage500 UI tests with 40% coverage means blind spots you can't see
Mutation Testing100% line coverage that still misses bugs — coverage ≠ confidence
TestcontainersMock databases lie; real containers don't
Security E2E TestsScanners find known CVEs; these tests verify your own defences fire
Production TestingDocker tests pass but CDN routing, real devices, and live rate limits only surface in production

Tech stack

LayerTechnology
FrontendReact 19, TypeScript, react-i18next (EN / ES / EL)
BackendExpress 5, Mongoose 9, Zod, Swagger
DatabaseMongoDB
E2EPlaywright, playwright-bdd (Cucumber), Allure
Unit / ComponentJest, React Testing Library, Supertest
IntegrationTestcontainers (real MongoDB in Docker)
Performancek6 (API load + browser UI tests)
MutationStryker Mutator
SecurityTrivy, npm audit, Playwright security suite
CI/CDGitHub Actions, Docker Compose, Vercel Preview
Quality GatesSonarCloud, Coveralls, NYC (80% threshold), MegaLinter
MobileReact Native 0.81, Expo 54, TypeScript, i18next (EN / ES / EL)
Mobile E2EWebdriverIO 9, Appium 3, XCUITest (iOS), UiAutomator2 (Android)
Mobile BuildsEAS Build, Expo Dev Client, EAS Workflows

Table of Contents


Architecture

┌─────────────────────────────────────────────────────────────────┐
│                        Docker Compose                           │
│                                                                 │
│  ┌─────────────┐    ┌──────────────────┐    ┌───────────────┐  │
│  │  Frontend   │───▶│   Backend API    │───▶│    MongoDB    │  │
│  │  React 19   │    │   Express 5      │    │   (colorsdb)  │  │
│  │  Port 3000  │    │   Port 5001      │    │   Port 27017  │  │
│  └─────────────┘    └──────────────────┘    └───────────────┘  │
│                                                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │   Playwright  ──▶  app:3000    k6  ──▶  api:5001        │   │
│  └─────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘

Frontend (src/) — React 19 + TypeScript color-picker. On load it fetches /api/colors, renders each as a button, and updates the header background color on click. Instrumented with babel-plugin-istanbul for E2E coverage collection.

Backend (server/) — Express 5 REST API. Zod validates every request body. Mongoose persists to MongoDB. Swagger serves the OpenAPI spec at /api-docs. Seeds three default colors (Turquoise, Red, Yellow) on startup.

API endpoints:

MethodEndpointDescription
GET/api/colorsList all colors
GET/api/colors/:nameGet a single color by name
POST/api/colorsCreate a new color
PUT/api/colors/:nameUpdate an existing color
DELETE/api/colors/:nameDelete a color

The mobile app connects to the same Express 5 backend — locally via EXPO_PUBLIC_API_URL=http://localhost:5001, and in production via the Vercel deployment.


Mobile App

Mobile Architecture

┌──────────────────────────────────────────────────────────────────┐
│                   React Native Mobile App (Expo 54)              │
│                                                                  │
│  ┌─────────────────────────┐    ┌──────────────────────────────┐ │
│  │       iOS App           │    │       Android App            │ │
│  │   (XCUITest / WDA)      │    │   (UiAutomator2)             │ │
│  └───────────┬─────────────┘    └──────────────┬───────────────┘ │
│              │                                  │                 │
│  ┌───────────▼──────────────────────────────────▼───────────────┐ │
│  │      WebdriverIO 9 + Appium 3  (localhost:4723)              │ │
│  └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────┬────────────────────────────────────┘
                              │ REST API (same backend)

         ┌────────────────────────────────────┐
         │  Express 5 · Port 5001 / Vercel    │
         │  ──────────────────────────────    │
         │  GET/POST/PUT/DELETE /api/colors   │
         └────────────────────────────────────┘

React Native app (mobile/) — Expo 54 + React Native 0.81 color-picker for iOS and Android. Mirrors the web app's feature set: fetch colors from the API, render color chips, open an HSL color-picker modal to add a color, delete with confirmation, and switch between English, Spanish, and Greek via i18next + expo-localization. Accessibility labels and testID props are applied throughout, providing stable selectors for Appium.

Shared monorepo package (packages/shared/) — The @color-app/shared package is consumed by both the web app and the mobile app. It provides the createApiClient factory, the STRICT_NAME_REGEX color-name validation rule, and all three translation files (EN / ES / EL). This guarantees that both surfaces validate identically and never translate a string differently.

API client (mobile/src/api/client.ts) — Wraps createApiClient from the shared package. The base URL is resolved in order: EXPO_PUBLIC_API_URL environment variable → Constants.expoConfig.extra.apiUrl (set in app.json) → production Vercel URL. Switching between local dev and production requires only a .env.local file — no code change.

// mobile/src/api/client.ts — API URL resolution priority
const apiUrl =
  process.env.EXPO_PUBLIC_API_URL ||
  Constants.expoConfig?.extra?.apiUrl ||
  'https://test-automation-best-practices.vercel.app'

export const api = createApiClient(apiUrl)
// api.getColors() · api.addColor(name, hex) · api.deleteColor(name)

EAS Build — Native iOS (.ipa / .app) and Android (.apk) binaries are built on Expo's cloud infrastructure via EAS Build. Two EAS Workflow files orchestrate parallel platform builds.

# mobile/.eas/workflows/create-development-builds.yml
name: Create Development Builds

jobs:
  build_android:
    type: build
    params:
      platform: android
      profile: development # includes expo-dev-client for live reloading
  build_ios:
    type: build
    params:
      platform: ios
      profile: development
# mobile/eas.json — build profiles
{
  'cli': { 'version': '>= 16.0.0' },
  'build':
    {
      'development': { 'developmentClient': true, 'distribution': 'internal' },
      'simulator': { 'ios': { 'simulator': true } },
      'preview': { 'android': { 'buildType': 'apk' }, 'distribution': 'internal' },
      'production': { 'autoIncrement': true }
    }
}

Local dev — run expo start to launch the Metro bundler. Use expo start --ios / expo start --android to open on a simulator. Point the app to the local backend by creating mobile/.env.local:

EXPO_PUBLIC_API_URL=http://localhost:5001
# Mobile development commands
cd mobile
npm start                    # Metro bundler
npm run ios                  # Open iOS simulator
npm run android              # Open Android emulator
npm run build:ios            # EAS simulator build
npm run build:android        # EAS preview APK build
npm run workflow:dev         # Trigger EAS workflow (both platforms)

Mobile Test Automation

Unit Tests (Jest + React Testing Library for Native)

Files: mobile/src/colorUtils.test.ts · mobile/src/components/ColorPickerInner.test.tsx · mobile/src/components/ColorPickerModal.test.tsx · mobile/src/components/ConfirmDialog.test.tsx

The unit suite uses Jest with the react-native preset and @testing-library/react-native to render and query components natively — no browser, no device. It mirrors the web test strategy: pure utility functions are covered directly, and component tests fire user events and assert rendered output.

Color utility coveragecolorUtils.ts exposes HSL → RGB → hex conversion chains and a WCAG luminance-based readableOn() helper. Tests exercise every HSL sector (0° red, 30° orange, 60° yellow, 120° green, 180° cyan, 240° blue) to catch off-by-one errors in the colour-wheel maths:

// mobile/src/colorUtils.test.ts
describe('hslToRgb', () => {
  test('hue=0 (red) → [255,0,0]', () => expect(hslToRgb(0, 1, 0.5)).toEqual([255, 0, 0]))
  test('hue=120 (green) → [0,255,0]', () => expect(hslToRgb(120, 1, 0.5)).toEqual([0, 255, 0]))
  test('hue=240 (blue) → [0,0,255]', () => expect(hslToRgb(240, 1, 0.5)).toEqual([0, 0, 255]))
})

describe('readableOn — WCAG contrast', () => {
  test('returns dark text on light background', () => expect(readableOn('#ffffff')).toBe('#111111'))
  test('returns light text on dark background', () => expect(readableOn('#000000')).toBe('#ffffff'))
})

Component testsConfirmDialog, ColorPickerInner, and ColorPickerModal are rendered with @testing-library/react-native. Tests assert that testID selectors resolve, buttons fire callbacks, validation errors appear for empty input, and loading states disable interactive controls:

// mobile/src/components/ConfirmDialog.test.tsx
import { render, fireEvent } from '@testing-library/react-native'
import { ConfirmDialog } from '../ConfirmDialog'

test('calls onConfirm when Delete button pressed', () => {
  const onConfirm = jest.fn()
  const { getByTestId } = render(
    <ConfirmDialog colorName="Red" hex="#e74c3c" onConfirm={onConfirm} onCancel={jest.fn()} busy={false} />
  )
  fireEvent.press(getByTestId('confirm-delete-btn'))
  expect(onConfirm).toHaveBeenCalledTimes(1)
})

test('Delete button is disabled while busy', () => {
  const { getByTestId } = render(
    <ConfirmDialog colorName="Red" hex="#e74c3c" onConfirm={jest.fn()} onCancel={jest.fn()} busy={true} />
  )
  expect(getByTestId('confirm-delete-btn').props.accessibilityState.disabled).toBe(true)
})

Coverage is collected and reported in multiple formats (JSON, LCOV, text, Clover). JUnit XML is written to allure-results/junit-mobile-unit-tests.xml for Allure ingestion and CI reporting.

# Run from mobile/
npm run test:unit              # Jest with coverage
// mobile/jest.config.js — coverage configuration
{
  "preset": "react-native",
  "testMatch": ["<rootDir>/src/**/*.test.(ts|tsx|js|jsx)"],
  "collectCoverageFrom": ["src/**/*.{ts,tsx}", "!src/**/*.d.ts", "!src/i18n.ts", "!src/api/client.ts"],
  "coverageReporters": ["json", "lcov", "text", "clover"],
  "reporters": ["default", ["jest-junit", { "outputFile": "allure-results/junit-mobile-unit-tests.xml" }]]
}

E2E Tests (WebdriverIO 9 + Appium 3)

Files: mobile/e2e/pageObjects/ColorPickerScreen.ts · mobile/e2e/tests/colorPicker.test.ts · mobile/e2e/wdio.ios.conf.ts · mobile/e2e/wdio.android.conf.ts

The E2E suite drives the real native app on iOS Simulator (XCUITest driver) and Android Emulator (UiAutomator2 driver). WebdriverIO acts as the test runner and Appium as the automation server; the @wdio/appium-service can start Appium automatically, but in CI the server runs as a separate step before the suite.

Platform-aware Page ObjectColorPickerScreen.ts centralises every selector. iOS uses accessibility IDs (~testID) while Android uses UiSelector with a resource-id wildcard, because Android prefixes testID values with the bundle identifier. A single buildLocator(testID) helper abstracts this difference so test code stays platform-agnostic:

// mobile/e2e/pageObjects/ColorPickerScreen.ts
import { browser } from '@wdio/globals'

const isIOS = () => browser.isIOS

function buildLocator(testID: string): string {
  if (isIOS()) {
    return `~${testID}` // iOS: accessibility ID
  }
  return `//*[@resource-id[contains(., '${testID}')]]` // Android: resource-id wildcard
}

export class ColorPickerScreen {
  get title() {
    return $(buildLocator('app-title'))
  }
  get addButton() {
    return $(buildLocator('add-color-btn'))
  }
  get colorNameInput() {
    return $(buildLocator('color-name-input'))
  }
  get pickerSaveBtn() {
    return $(buildLocator('picker-save-btn'))
  }
  get confirmDeleteBtn() {
    return $(buildLocator('confirm-delete-btn'))
  }

  colorChip(name: string) {
    return $(buildLocator(`chip-select-${name}`))
  }
  deleteChipButton(name: string) {
    return $(buildLocator(`chip-delete-${name}`))
  }
  langButton(code: string) {
    return $(buildLocator(`lang-btn-${code}`))
  }

  async waitForLoad() {
    await this.title.waitForDisplayed({ timeout: 15_000 })
    await this.addButton.waitForDisplayed({ timeout: 15_000 })
  }

  async openAddColorModal() {
    await this.addButton.click()
    await this.colorNameInput.waitForDisplayed({ timeout: 10_000 })
  }

  async submitNewColor(name: string) {
    await this.colorNameInput.setValue(name)
    await this.pickerSaveBtn.click()
  }

  async scrollToChip(name: string) {
    // iOS: use mobile:scroll command; Android: use UiScrollable via shell
    if (isIOS()) {
      await browser.execute('mobile:scroll', { direction: 'right', element: await this.colorChip(name).elementId })
    } else {
      const el = await $(
        `android=new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().resourceIdMatches(".*chip-select-${name}.*"))`
      )
      await el.waitForExist()
    }
  }

  async deleteColor(name: string) {
    await this.deleteChipButton(name).click()
    await this.confirmDeleteBtn.waitForDisplayed({ timeout: 10_000 })
    await this.confirmDeleteBtn.click()
    await this.colorChip(name).waitForExist({ reverse: true, timeout: 10_000 })
  }
}

Test suite — tests follow the same Arrange–Act–Assert pattern as the web suite. The afterEach hook calls the production API directly to delete any color created during the test, keeping the state clean without a reset/restart cycle:

// mobile/e2e/tests/colorPicker.test.ts
import { browser } from '@wdio/globals'
import { ColorPickerScreen } from '../pageObjects/ColorPickerScreen'

const screen = new ColorPickerScreen()
const API_BASE = 'https://test-automation-best-practices.vercel.app'
const BUNDLE_ID = 'com.jpourdanis.colorpicker'
const createdColors: string[] = []

describe('Color Picker App', () => {
  before(async () => {
    await screen.waitForLoad()
  })

  afterEach(async () => {
    // API-driven teardown — no UI interaction needed for cleanup
    for (const name of createdColors) {
      await fetch(`${API_BASE}/api/colors/${name}`, { method: 'DELETE' }).catch(() => {})
    }
    createdColors.length = 0
  })

  it('shows the app title', async () => {
    await expect(screen.title).toBeDisplayed()
  })

  it('displays a hex color value', async () => {
    const text = await screen.currentColor.getText()
    expect(text).toMatch(/^#[0-9A-Fa-f]{6}$/)
  })

  it('opens the color picker modal on Add tap', async () => {
    await screen.openAddColorModal()
    await expect(screen.colorNameInput).toBeDisplayed()
    await screen.closeAddColorModal()
  })

  it('shows a validation error on empty name submit', async () => {
    await screen.openAddColorModal()
    await screen.pickerSaveBtn.click()
    await expect(screen.pickerError).toBeDisplayed()
    await screen.closeAddColorModal()
  })

  it('adds a color via API then verifies chip appears after app restart', async function () {
    if (browser.isAndroid) {
      return this.skip()
    } // flaky on Android emulator
    const name = `E2EColor${Date.now()}`
    createdColors.push(name)

    await fetch(`${API_BASE}/api/colors`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, hex: '#7b2fbe' })
    })

    // Restart the app to pick up the newly seeded color from the API
    await browser.terminateApp(BUNDLE_ID)
    await browser.activateApp(BUNDLE_ID)
    await screen.waitForLoad()
    await screen.scrollToChip(name)
    await expect(screen.colorChip(name)).toBeDisplayed()
  })

  it('switches UI language', async function () {
    if (browser.isAndroid) {
      return this.skip()
    } // flaky on Android emulator
    await screen.langButton('es').click()
    await expect(screen.title).toHaveText('Selector de Color')
    await screen.langButton('en').click()
  })
})

Platform configurations — separate config files pass the correct Appium capabilities for each platform. App paths are resolved from environment variables so CI can inject the binary path built by EAS without changing source:

// mobile/e2e/wdio.ios.conf.ts
export const config: Options.Testrunner = {
  runner: 'local',
  framework: 'mocha',
  mochaOpts: { timeout: 90_000 },
  specs: ['./e2e/tests/**/*.test.ts'],
  reporters: ['spec', ['allure', { outputDir: 'e2e/allure-results' }]],

  capabilities: [
    {
      platformName: 'iOS',
      'appium:automationName': 'XCUITest',
      'appium:deviceName': process.env.IOS_DEVICE || 'iPhone 16',
      'appium:platformVersion': process.env.IOS_VERSION || '18.5',
      'appium:app': process.env.APP_PATH || 'e2e/artifacts/ColorPicker.app',
      'appium:newCommandTimeout': 240,
      'appium:wdaLaunchTimeout': 1_200_000,
      'appium:derivedDataPath': '.wda-derived-data/',
      'appium:noReset': false,
      'appium:videoRecording': { timeLimit: 120 } // saved on failure
    }
  ]
}
// mobile/e2e/wdio.android.conf.ts
capabilities: [
  {
    platformName: 'Android',
    'appium:automationName': 'UiAutomator2',
    'appium:deviceName': process.env.ANDROID_DEVICE || 'Pixel_6_API_35',
    'appium:platformVersion': process.env.ANDROID_VERSION || '35',
    'appium:app': process.env.APP_PATH || 'e2e/artifacts/android.apk',
    'appium:autoGrantPermissions': true,
    'appium:noReset': false,
    'appium:newCommandTimeout': 240
  }
]

Appium driver setup — install native drivers once per machine before running E2E tests:

cd mobile

# Install Appium drivers
npm run appium:install-drivers            # XCUITest (iOS)
npm run appium:install-drivers:android    # UiAutomator2 (Android)

Running the E2E suite:

cd mobile

# iOS Simulator
npm run test:ios

# Android Emulator
npm run test:android

# Override device / binary path via env
APP_PATH=/path/to/ColorPicker.app IOS_DEVICE="iPhone 15" npm run test:ios
APP_PATH=/path/to/android.apk ANDROID_DEVICE="Pixel_7_API_34" npm run test:android

Note

E2E tests require a pre-built app binary (e2e/artifacts/ColorPicker.app for iOS or e2e/artifacts/android.apk for Android). Build one with npm run build:ios / npm run build:android via EAS, then download the artifact into e2e/artifacts/ before running the suite. Videos of failing tests are saved automatically to e2e/videos/.


Quick Start

Prerequisites

  • Node.js 18+
  • Docker

Install

# Frontend
npm install
npx playwright install

# Backend
cd server && npm install && cd ..

Run the full stack

docker compose up -d

Important

Local Docker prerequisite — remove BuildKit cache mounts. The Dockerfile and server/Dockerfile use --mount=type=cache which is CI-only. Before running locally, replace those RUN lines:

# Remove this CI-only line:
RUN --mount=type=cache,target=/root/.npm npm ci --legacy-peer-deps

# Replace with:
RUN npm ci --legacy-peer-deps

Tip

Colima users — create .docker-local/config.json in the project root:

{ "DOCKER_HOST": "unix:///Users/your-user/.colima/default/docker.sock" }

Running Tests

# All E2E tests (auto-starts Docker stack)
npx playwright test

# Individual suites
npx playwright test e2e/tests/a11y.spec.ts
npx playwright test e2e/tests/security.spec.ts
npx playwright test --headed                     # Watch the browser

# BDD / Cucumber
npm run test:bdd

# Unit & component tests
npm run test:unit

# Backend unit tests
cd server && npm test

# Integration tests (requires Docker)
cd server && npm run test:int

# Visual regression (Docker — consistent rendering)
npm run test:e2e:docker
npm run test:e2e:docker:update     # Regenerate baselines

# Cross-browser (Chrome + Firefox + Safari)
npm run test:cross-browser

# Performance (k6)
npm run test:perf:api:smoke
npm run test:perf:ui:load

# Coverage report + gate (80% threshold)
npm run coverage
npm run coverage:check

# Mutation testing (70% threshold)
cd server && npm run mutation

Best Practices

Part 1 — Core Framework & Test Design


1. Page Object Model (POM)

Files: e2e/pages/HomePage.ts · e2e/baseFixtures.ts · e2e/tests/pom-refactored.spec.ts

The problem: Selectors hardcoded across 50 test files. One UI redesign breaks dozens of tests — you spend days hunting them down.

The solution: Define every selector once inside a class. Register it as a Playwright fixture so tests get a pre-wired instance, no manual new calls needed.

Why it matters:

  • One selector change = one file change
  • Tests read like user stories: homePage.clickColorButton('Red') is immediately understood by non-engineers
  • Fixtures eliminate boilerplate beforeEach(() => new HomePage(page)) blocks
// e2e/pages/HomePage.ts
import { Page, Locator } from '@playwright/test'

export class HomePage {
  readonly page: Page
  readonly header: Locator
  readonly currentColorText: Locator
  readonly turquoiseBtn: Locator
  readonly redBtn: Locator
  readonly yellowBtn: Locator
  readonly addColorBtn: Locator
  readonly pickerCard: Locator
  readonly confirmCard: Locator
  readonly confirmDeleteBtn: Locator

  constructor(page: Page) {
    this.page = page
    this.header = page.locator('header')
    this.currentColorText = page.getByText('Current color:')
    // Exact aria-label prevents matching the chip-x "Remove color: X" buttons
    this.turquoiseBtn = page.getByRole('button', { name: 'Change background to Turquoise', exact: true })
    this.redBtn = page.getByRole('button', { name: 'Change background to Red', exact: true })
    this.yellowBtn = page.getByRole('button', { name: 'Change background to Yellow', exact: true })
    this.addColorBtn = page.getByRole('button', { name: '+ Add color', exact: true })
    this.pickerCard = page.locator('.picker-card')
    this.confirmCard = page.locator('.confirm-card')
    this.confirmDeleteBtn = page.getByRole('button', { name: 'Delete', exact: true })
  }

  async goto() {
    await this.page.goto('/')
  }

  async clickColorButton(colorName: string) {
    await this.page.locator('button.chip-main', { hasText: colorName }).click()
  }

  async clickDeleteChip(colorName: string) {
    await this.page.getByRole('button', { name: `Remove color: ${colorName}`, exact: true }).click()
  }
}
// e2e/baseFixtures.ts — register once, use everywhere
export const test = baseTest.extend<{ homePage: HomePage; allureBddMapper: void }>({
  homePage: async ({ page }, use) => {
    await use(new HomePage(page))
  }
  // ... coverage collection and logging fixtures
})
// e2e/tests/pom-refactored.spec.ts
import { test, expect } from '../baseFixtures'
import { convertHexToRGB } from '../helper'

const colors = [
  { name: 'Turquoise', hex: '1abc9c' },
  { name: 'Red', hex: 'e74c3c' },
  { name: 'Yellow', hex: 'f1c40f' }
]

test.describe('POM Refactored: Background color tests', () => {
  test.beforeEach(async ({ homePage }) => {
    await homePage.goto()
  })

  for (const color of colors) {
    test(`verify ${color.name} (#${color.hex}) is applied as the background color`, async ({ homePage }) => {
      await homePage.clickColorButton(color.name)
      await expect(homePage.currentColorText).toContainText(color.hex)

      const rgb = convertHexToRGB(`#${color.hex}`)
      await expect(homePage.header).toHaveCSS('background-color', `rgb(${rgb.red}, ${rgb.green}, ${rgb.blue})`)
    })
  }
})
npx playwright test e2e/tests/pom-refactored.spec.ts

2. Behavior-Driven Development (BDD) with Cucumber

Files: e2e/features/home.feature · e2e/features/error-handling.feature · e2e/features/i18n.feature · e2e/tests/bdd.spec.ts

The problem: Automated tests are written in TypeScript that Product Managers, Business Analysts, and QA leads cannot read. Requirements and tests drift apart silently.

The solution: Express tests in plain English (Gherkin) using playwright-bdd to compile .feature files into native Playwright tests. Three feature files cover distinct areas: background color theming, API error resilience, and language switching.

Why it matters:

  • Living documentation — feature files are the executable source of truth for requirements; they can't go stale
  • Improved collaboration — PMs can write or review scenarios without touching TypeScript
  • Reusability — step definitions reuse the same Page Object Models as regular tests
# e2e/features/home.feature
@epic:UI_Components
@feature:Theming
Feature: Home Page Background Color
  As a user
  I want to change the background color
  So that I can customize my viewing experience

  @story:Background_Color_Customization
  @severity:normal
  @jira:UI-456
  Scenario Outline: Change background color
    Given I am on the home page
    When I click the "<color>" color button
    Then the active color text should be "<hex>"
    And the background color should be "<rgb>"

    Examples:
      | color     | hex     | rgb               |
      | Turquoise | #1abc9c | rgb(26, 188, 156) |
      | Red       | #e74c3c | rgb(231, 76, 60)  |
      | Yellow    | #f1c40f | rgb(241, 196, 15) |
// e2e/tests/bdd.spec.ts — step definitions
const { Given, When, Then } = createBdd()
let homePage: HomePage

Given('I am on the home page', async ({ page }) => {
  homePage = new HomePage(page)
  await homePage.goto()
})

When('I click the {string} color button', async ({}, color: string) => {
  await homePage.clickColorButton(color)
})

Then('the active color text should be {string}', async ({}, hex: string) => {
  await expect(homePage.currentColorText).toContainText(hex)
})

Then('the background color should be {string}', async ({}, rgb: string) => {
  await expect(homePage.header).toHaveCSS('background-color', rgb)
})

// Error-handling steps — mock must be registered before navigation
Given('the API returns a server error for the colors list', async ({ page }) => {
  await page.route('**/api/colors', (route) => route.fulfill({ status: 500 }))
})

// i18n steps
When('I select the language {string}', async ({}, code: string) => {
  await homePage.page.selectOption('select', code)
})

Then('the {string} button label should be {string}', async ({}, _color: string, label: string) => {
  await expect(homePage.page.locator('button.chip-main', { hasText: label })).toBeVisible()
})
npm run test:bdd

3. Avoiding Static Waits

File: e2e/tests/visual.spec.ts

The problem: page.waitForTimeout(2000) has two failure modes — too short on a slow CI runner, too long on a fast local machine. The test becomes non-deterministic.

The solution: Synchronise against the actual network event with waitForResponse. It resolves the moment the response arrives — no wasted time, no false failures.

// e2e/tests/visual.spec.ts
test('should display all core elements and handle button interaction', async ({ homePage, page }) => {
  await expect(homePage.header).toBeVisible()
  await expect(homePage.turquoiseBtn).toBeVisible()
  await expect(homePage.redBtn).toBeVisible()
  await expect(homePage.yellowBtn).toBeVisible()

  // ✅ Register listener BEFORE the action — deterministic, no static waits
  const responsePromise = page.waitForResponse(
    (resp) => resp.url().includes('/api/colors/Yellow') && resp.status() === 200
  )
  await homePage.clickColorButton('Yellow')
  await responsePromise

  await expect(homePage.currentColorText).toContainText('#f1c40f')
  await expect(homePage.header).toHaveCSS('background-color', 'rgb(241, 196, 15)')
})

Note

Always register waitForResponse before the action that triggers the request. Playwright sets up the listener first; the click fires second.


4. Data-Driven Testing

File: e2e/tests/data-driven.spec.ts

The problem: Testing three colors by copy-pasting the same test block three times. Add a fourth color and you have four places to maintain the same assertion logic.

The solution: Define data once, loop to generate tests. Adding a new case is one line in the data array.

// e2e/tests/data-driven.spec.ts
import { test, expect } from '../baseFixtures'

const testData = [
  { name: 'Turquoise', expectedHex: '#1abc9c', expectedRgb: 'rgb(26, 188, 156)' },
  { name: 'Red', expectedHex: '#e74c3c', expectedRgb: 'rgb(231, 76, 60)' },
  { name: 'Yellow', expectedHex: '#f1c40f', expectedRgb: 'rgb(241, 196, 15)' }
]

test.describe('Data-Driven Testing', () => {
  test.beforeEach(async ({ homePage }) => {
    await homePage.goto()
  })

  for (const data of testData) {
    test(`changing color to ${data.name} should reflect in UI and DOM`, async ({ homePage }) => {
      await homePage.clickColorButton(data.name)
      await expect(homePage.currentColorText).toContainText(data.expectedHex)
      await expect(homePage.header).toHaveCSS('background-color', data.expectedRgb)
    })
  }
})
npx playwright test e2e/tests/data-driven.spec.ts

5. Random Data Generation with faker.js

File: e2e/tests/random-data.spec.ts

The problem: Hardcoded color names collide when tests run in parallel against a shared database. Static data never exercises edge cases like special characters or very long strings.

The solution: Generate unique, realistic data at runtime with @faker-js/faker. Randomness naturally discovers edge cases over repeated runs.

// e2e/tests/random-data.spec.ts
import { test, expect } from '../baseFixtures'
import { faker } from '@faker-js/faker'

test.describe('Random Data Testing with faker.js', () => {
  let createdColorName: string | null = null

  test.afterEach(async ({ request }) => {
    if (createdColorName) {
      await request.delete(`/api/colors/${createdColorName}`).catch(() => {})
      createdColorName = null
    }
  })

  test('should create dynamic random color via API and verify through UI', async ({ homePage, page, request }) => {
    const randomColorName = faker.string.alphanumeric(15)
    const randomHex = faker.color.rgb({ format: 'hex' })
    const newColor = { name: randomColorName, hex: randomHex }
    createdColorName = newColor.name

    // 1. Arrange — API setup, no UI needed
    const createResponse = await request.post('/api/colors', { data: newColor })
    expect(createResponse.ok()).toBeTruthy()

    // 2. Act — navigate and interact with the UI
    await homePage.goto()
    const customBtn = page.getByRole('button', {
      name: `Change background to ${newColor.name}`,
      exact: true
    })
    const responsePromise = page.waitForResponse(
      (resp) => resp.url().includes(`/api/colors/${encodeURIComponent(newColor.name)}`) && resp.status() === 200
    )
    await customBtn.click()
    await responsePromise

    // 3. Assert
    await expect(homePage.currentColorText).toContainText(newColor.hex)
  })
})
npx playwright test e2e/tests/random-data.spec.ts

Part 2 — Comprehensive Test Coverage


6. Unit & Component Testing

Files: src/App.test.tsx · src/ColorPicker.test.tsx · src/ConfirmDialog.test.tsx · server/index.test.js

The problem: Relying solely on E2E tests creates a slow, top-heavy suite. When a complex E2E test fails, it's hard to know whether a pure function, a component, or the network caused it.

The solution: Test the smallest units in isolation — pure functions and React components — without a browser, network, or database. Hundreds of unit tests complete in seconds.

Why it matters:

  • When a unit test fails you know exactly which function is broken — no ambiguity about network or database
  • Edge cases handled at this layer stay out of the E2E suite entirely
  • Forms the base of the Test Automation Pyramid — the faster the base, the faster the whole pipeline
// src/ColorPicker.test.tsx — pure-function coverage across all HSL sectors
import { hslToRgb, rgbToHex, hexToRgb, rgbToHsl, readableOn } from './ColorPicker'

describe('hslToRgb', () => {
  test('hue=0 (red) → [255,0,0]', () => expect(hslToRgb(0, 1, 0.5)).toEqual([255, 0, 0]))
  test('hue=30 (orange) → [255,128,0]', () => expect(hslToRgb(30, 1, 0.5)).toEqual([255, 128, 0]))
  test('hue=60 (yellow) → [255,255,0]', () => expect(hslToRgb(60, 1, 0.5)).toEqual([255, 255, 0]))
  test('hue=120 (green) → [0,255,0]', () => expect(hslToRgb(120, 1, 0.5)).toEqual([0, 255, 0]))
  test('hue=180 (cyan) → [0,255,255]', () => expect(hslToRgb(180, 1, 0.5)).toEqual([0, 255, 255]))
  test('hue=240 (blue) → [0,0,255]', () => expect(hslToRgb(240, 1, 0.5)).toEqual([0, 0, 255]))
})

describe('readableOn', () => {
  test('dark text on light background', () => expect(readableOn('#ffffff')).toBe('#111'))
  test('light text on dark background', () => expect(readableOn('#000000')).toBe('#fff'))
})
// src/ConfirmDialog.test.tsx — keyboard & ARIA coverage
import { render, screen, fireEvent } from '@testing-library/react'
import { ConfirmDialog } from './ConfirmDialog'

function makeProps(overrides = {}) {
  return {
    title: 'Delete color?',
    body: 'Are you sure you want to delete Red?',
    swatch: '#e74c3c',
    confirmLabel: 'Delete',
    cancelLabel: 'Cancel',
    busy: false,
    onConfirm: jest.fn(),
    onCancel: jest.fn(),
    ...overrides,
  }
}

test('dialog has correct ARIA attributes', () => {
  render(<ConfirmDialog {...makeProps()} />)
  const dialog = screen.getByRole('alertdialog')
  expect(dialog).toHaveAttribute('aria-modal', 'true')
})

test('Escape key calls onCancel when not busy', () => {
  const onCancel = jest.fn()
  render(<ConfirmDialog {...makeProps({ onCancel })} />)
  fireEvent.keyDown(globalThis as unknown as Window, { key: 'Escape' })
  expect(onCancel).toHaveBeenCalledTimes(1)
})

test('Enter key calls onConfirm when not busy', () => {
  const onConfirm = jest.fn()
  render(<ConfirmDialog {...makeProps({ onConfirm })} />)
  fireEvent.keyDown(globalThis as unknown as Window, { key: 'Enter' })
  expect(onConfirm).toHaveBeenCalledTimes(1)
})
npm run test:unit          # React components
cd server && npm test      # Express API

7. Hybrid E2E Testing

File: e2e/tests/hybrid.spec.ts

The problem: Clicking through the UI to create the test data needed for a "verify color change" test is slow and exposes the test to flakiness in areas that aren't the actual focus.

The solution: Use the request fixture for the Arrange phase (API calls, sub-millisecond), the browser for the Act phase (UI interactions), and API calls again for Teardown. Touch the UI only for what you're testing.

// e2e/tests/hybrid.spec.ts
import { test, expect } from '../baseFixtures'
import { faker } from '@faker-js/faker'

test.describe('Hybrid E2E Testing', () => {
  let createdColorName: string | null = null

  test.afterEach(async ({ request }) => {
    if (createdColorName) {
      await request.delete(`/api/colors/${createdColorName}`).catch(() => {})
      createdColorName = null
    }
  })

  test('should create color via API and verify through UI', async ({ homePage, page, request }) => {
    const uniqueName = faker.string.alphanumeric(15)
    const newColor = { name: uniqueName, hex: '#8e44ad' }
    createdColorName = newColor.name

    // 1. Arrange — fast state setup via API
    const createResponse = await request.post('/api/colors', { data: newColor })
    expect(createResponse.ok()).toBeTruthy()

    // 2. Act — navigate to the UI which now reflects the new state
    await homePage.goto()
    const customBtn = page.getByRole('button', {
      name: `Change background to ${newColor.name}`,
      exact: true
    })
    const responsePromise = page.waitForResponse(
      (resp) => resp.url().includes(`/api/colors/${newColor.name}`) && resp.status() === 200
    )
    await customBtn.click()
    await responsePromise

    // 3. Assert — UI reflects state correctly
    await expect(homePage.currentColorText).toContainText(newColor.hex)
    await expect(homePage.header).toHaveCSS('background-color', 'rgb(142, 68, 173)')
  })
})

8. Network Mocking & Interception

Files: e2e/tests/network-mocking.spec.ts · e2e/tests/error-handling.spec.ts

The problem: It's nearly impossible to test how the UI handles a 500 Internal Server Error, a 404, or a missing image when connected to a real, functioning database.

The solution: page.route() intercepts requests before they leave the browser. Fulfill with mocked data, abort to simulate failures, or return specific status codes on demand.

Important

Always register page.route() before the navigation or action that triggers the request.

// e2e/tests/network-mocking.spec.ts
import { test, expect } from '../baseFixtures'
import enTranslations from '../../src/locales/en.json'

test.describe('Network Mocking & Interception', () => {
  test('should handle missing image gracefully by showing alt text', async ({ homePage, page }) => {
    await page.route('**/logo.svg', (route) => route.abort())
    await homePage.goto()

    const logoImg = page.getByRole('img', { name: 'logo' })
    await expect(logoImg).toBeVisible()
    await expect(logoImg).toHaveAttribute('alt', 'logo')
  })

  test('should display colors that do not exist in the database', async ({ homePage, page }) => {
    await page.route('**/api/colors', async (route) => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify([{ name: 'Magenta', hex: '#ff00ff' }])
      })
    })
    await page.route('**/api/colors/Magenta', async (route) => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ name: 'Magenta', hex: '#ff00ff' })
      })
    })
    await homePage.goto()

    const customBtn = page.getByRole('button', { name: 'Change background to Magenta', exact: true })
    await expect(customBtn).toBeVisible()
    await customBtn.click()
    await expect(homePage.header).toHaveCSS('background-color', 'rgb(255, 0, 255)')
  })

  test('should gracefully handle a color not found in the database', async ({ homePage, page }) => {
    await page.route('**/api/colors', async (route) => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify([
          { name: 'Turquoise', hex: '#1abc9c' },
          { name: 'Red', hex: '#e74c3c' }
        ])
      })
    })
    // Simulate a 404 for the "Red" color endpoint
    await page.route('**/api/colors/Red', async (route) => {
      await route.fulfill({
        status: 404,
        contentType: 'application/json',
        body: JSON.stringify({ error: 'Color not found' })
      })
    })

    await homePage.goto()
    await expect(homePage.header).toHaveCSS('background-color', 'rgb(26, 188, 156)')

    // Use i18n-aware accessible locator
    const redBtn = page.getByRole('button', {
      name: `${enTranslations.changeColor} ${enTranslations.colors.red}`,
      exact: true
    })
    await redBtn.click()

    // Background must not change — 404 should be handled silently
    await expect(homePage.header).toHaveCSS('background-color', 'rgb(26, 188, 156)')
  })
})
// e2e/tests/error-handling.spec.ts — network failure → loading state
test('should handle fetch colors network failure gracefully', async ({ homePage, page }) => {
  await page.route('**/api/colors', (route) => route.abort('failed'))
  await homePage.goto()
  await expect(page.locator('text=Loading colors...')).toBeVisible()
})

test('should handle color click network failure gracefully', async ({ homePage, page }) => {
  await homePage.goto()
  await page.route('**/api/colors/Turquoise', (route) => route.abort('failed'))

  const requestPromise = page.waitForRequest('**/api/colors/Turquoise')
  await homePage.turquoiseBtn.click()
  await requestPromise

  await expect(page.getByRole('alert')).toContainText('Failed to load color: Turquoise')
})
npx playwright test e2e/tests/network-mocking.spec.ts e2e/tests/error-handling.spec.ts

9. API Schema Validation with Zod

Files: server/index.js · e2e/tests/api.spec.ts

The problem: APIs are implicit contracts. Without schema enforcement, a backend developer can rename a field and the API still returns 200 OK — silently breaking every frontend that depends on it.

The solution: Zod operates as a dual-sided contract. The server uses it to reject malformed requests; Playwright tests use it to validate response shapes. The .strict() modifier also rejects unknown fields — closing the door on extra-field injection attacks.

// server/index.js — reject invalid requests at the boundary
const STRICT_NAME_REGEX = /^[a-zA-Z0-9]([a-zA-Z0-9 +]*[a-zA-Z0-9])?$/
const STRICT_NAME_MSG =
  'name must contain alphanumeric characters and spaces only, and at least one alphanumeric character'

const colorZodSchema = z
  .object({
    name: z.string({ required_error: 'name is required' }).regex(STRICT_NAME_REGEX, STRICT_NAME_MSG),
    hex: z
      .string({ required_error: 'hex is required' })
      .regex(/^#[0-9A-Fa-f]{6}$/, 'hex must be a valid 6-digit hex format (e.g., #1abc9c)')
  })
  .strict() // rejects unknown fields — closes the door on injection via extra keys
// e2e/tests/api.spec.ts — validate response shapes and negative paths
import { test, expect } from '@playwright/test'
import { faker } from '@faker-js/faker'
import { z } from 'zod'

const ColorSchema = z.object({
  name: z.string(),
  hex: z.string().regex(/^#[0-9A-Fa-f]{6}$/)
})

test('GET /api/colors/:name returns correct schema', async ({ request }) => {
  const response = await request.get('/api/colors/Turquoise')
  expect(response.status()).toBe(200)
  ColorSchema.parse(await response.json()) // throws if response shape is wrong
})

test('POST creates a color and returns 201', async ({ request }) => {
  const uniqueName = faker.string.alphanumeric(15)
  const response = await request.post('/api/colors', { data: { name: uniqueName, hex: '#ffa500' } })
  expect(response.status()).toBe(201)
  ColorSchema.parse(await response.json())
})

test('rejects missing name with 400', async ({ request }) => {
  const response = await request.post('/api/colors', { data: { hex: '#ffa500' } })
  expect(response.status()).toBe(400)
  expect((await response.json()).error).toBe('Invalid input: expected string, received undefined')
})

test('rejects invalid hex format with 400', async ({ request }) => {
  const response = await request.post('/api/colors', { data: { name: 'Orange', hex: 'ffa500' } })
  expect(response.status()).toBe(400)
  expect((await response.json()).error).toContain('hex must be a valid 6-digit hex format')
})

test('rejects unknown extra fields with 400', async ({ request }) => {
  const response = await request.post('/api/colors', {
    data: { name: 'Purple', hex: '#800080', injected: 'payload' }
  })
  expect(response.status()).toBe(400)
})
npx playwright test e2e/tests/api.spec.ts

10. Visual Regression & Responsive Testing

File: e2e/tests/visual.spec.ts

The problem: Functional tests check the DOM. They happily pass when a CSS bug makes a button transparent, or a media query breaks the tablet layout entirely.

The solution: Pixel-level screenshot comparison across five viewports. Animated elements are masked to prevent non-deterministic diffs. Two snapshots per viewport: default state + post-interaction state.

// e2e/tests/visual.spec.ts
import { test, expect } from '../baseFixtures'

// Default-state snapshots: one per viewport, taken before any interaction
const snapshotViewports = [
  { label: 'desktop', width: 1280, height: 720, snapshot: 'home.png' },
  { label: 'desktop-xl', width: 1920, height: 1080, snapshot: 'home-desktop-xl.png' },
  { label: 'tablet', width: 768, height: 1024, snapshot: 'home-tablet.png' },
  { label: 'iphone-se', width: 375, height: 667, snapshot: 'home-iphone-se.png' },
  { label: 'iphone-landscape', width: 667, height: 375, snapshot: 'home-iphone-landscape.png' }
]

// Post-interaction snapshots: taken after clicking Yellow, proving layout holds after state change
const responsiveViewports = [
  { label: 'tablet (768×1024)', width: 768, height: 1024, snapshot: 'home-tablet-responsive.png' },
  { label: 'iPhone SE (375×667)', width: 375, height: 667, snapshot: 'home-mobile.png' },
  { label: 'iPhone landscape (667×375)', width: 667, height: 375, snapshot: 'home-iphone-landscape-responsive.png' }
]

for (const vp of snapshotViewports) {
  test.describe(`Visual Regression – ${vp.label} (${vp.width}×${vp.height})`, () => {
    test.use({ viewport: { width: vp.width, height: vp.height } })

    test('homepage should match snapshot', async ({ page }) => {
      await page.goto('/')
      await page.waitForSelector('header')
      const screenshot = await page.screenshot({
        fullPage: true,
        mask: [page.locator('.App-logo')] // mask animated elements to prevent false diffs
      })
      expect(screenshot).toMatchSnapshot(vp.snapshot, { maxDiffPixelRatio: 0.05 })
    })
  })
}

for (const vp of responsiveViewports) {
  test.describe(`Responsive Design – ${vp.label}`, () => {
    test.use({ viewport: { width: vp.width, height: vp.height } })

    test.beforeEach(async ({ homePage }) => {
      await homePage.goto()
    })

    test('should display all core elements and handle button interaction', async ({ homePage, page }) => {
      await expect(homePage.header).toBeVisible()
      await expect(homePage.currentColorText).toBeVisible()
      await expect(homePage.turquoiseBtn).toBeVisible()
      await expect(homePage.redBtn).toBeVisible()
      await expect(homePage.yellowBtn).toBeVisible()

      const responsePromise = page.waitForResponse(
        (resp) => resp.url().includes('/api/colors/Yellow') && resp.status() === 200
      )
      await homePage.clickColorButton('Yellow')
      await responsePromise

      await expect(homePage.currentColorText).toContainText('#f1c40f')
      await expect(homePage.header).toHaveCSS('background-color', 'rgb(241, 196, 15)')

      const screenshot = await page.screenshot({ fullPage: true, mask: [page.locator('.App-logo')] })
      expect(screenshot).toMatchSnapshot(vp.snapshot, { maxDiffPixelRatio: 0.05 })
    })
  })
}

Important

Snapshots must be generated inside Docker. macOS and Linux render fonts differently — Docker locks rendering to a consistent Linux environment, eliminating false positives across machines.

npm run test:e2e:docker:update    # Generate / update baselines
npm run test:e2e:docker           # Compare against baselines

11. Accessibility (a11y) Testing

File: e2e/tests/a11y.spec.ts

The problem: Manual accessibility testing is slow and forgotten under deadline pressure. Inaccessible code reaches production, creating legal compliance risk and excluding users with disabilities.

The solution: Automated WCAG auditing via @axe-core/playwright on every CI push. Google Lighthouse provides a scored accessibility threshold. i18n-aware locators ensure tests hold in every language.

// e2e/tests/a11y.spec.ts
import { test, expect } from '../baseFixtures'
import AxeBuilder from '@axe-core/playwright'
import { playAudit } from 'playwright-lighthouse'
import enTranslations from '../../src/locales/en.json'
import esTranslations from '../../src/locales/es.json'
import elTranslations from '../../src/locales/el.json'

test.describe('Accessibility Tests', () => {
  test.beforeEach(async ({ homePage }) => {
    await homePage.goto()
  })

  test('should not have any automatically detectable accessibility issues', async ({ homePage, page }) => {
    await expect(homePage.header).toBeVisible()
    const { violations } = await new AxeBuilder({ page }).analyze()
    expect(violations).toEqual([])
  })

  test('should maintain accessibility after state change (color update)', async ({ homePage, page }) => {
    await homePage.clickColorButton('Yellow')
    await expect(homePage.currentColorText).toContainText('#f1c40f')

    const { violations } = await new AxeBuilder({ page }).analyze()
    const contrastViolations = violations.filter((v) => v.id === 'color-contrast')
    expect(contrastViolations).toEqual([])
  })

  test('should meet the accessibility threshold using Google Lighthouse', async ({ homePage, page }) => {
    await expect(homePage.header).toBeVisible()
    await playAudit({
      page,
      thresholds: { accessibility: 90 },
      port: 9222 + (process.env.TEST_WORKER_INDEX ? parseInt(process.env.TEST_WORKER_INDEX) : 0)
    })
  })
})

// i18n Accessibility — verify accessible locators work in all three languages
test.describe('i18n Accessibility Tests', () => {
  const languages = [
    { code: 'en', i18n: enTranslations },
    { code: 'es', i18n: esTranslations },
    { code: 'el', i18n: elTranslations }
  ]

  for (const lang of languages) {
    test(`should maintain accessibility in ${lang.code} language`, async ({ homePage, page }) => {
      await homePage.goto()

      // Switch language via the dropdown
      const languageDropdown = page.getByRole('combobox', { name: enTranslations.languageSelector })
      await languageDropdown.selectOption(lang.code)

      // Use dynamic, translation-aware locators — not brittle CSS selectors
      await expect(page.getByRole('heading', { name: lang.i18n.title })).toBeVisible()
      await expect(
        page.getByRole('button', { name: `${lang.i18n.changeColor} ${lang.i18n.colors.turquoise}` })
      ).toBeVisible()
      await expect(page.getByRole('button', { name: `${lang.i18n.changeColor} ${lang.i18n.colors.red}` })).toBeVisible()

      const { violations } = await new AxeBuilder({ page }).analyze()
      expect(violations).toEqual([])
    })
  }
})
npx playwright test e2e/tests/a11y.spec.ts

12. Performance Testing with k6

Files: performance/api-performance.spec.ts · performance/ui-performance.spec.ts

The problem: "The app feels slow" is not actionable. Without objective baselines, memory leaks and slow queries silently degrade until the system crashes under load.

The solution: Two complementary k6 test types — API-level HTTP load tests for backend throughput (with CRUD lifecycle), and browser-level UI tests for frontend rendering performance.

// performance/api-performance.spec.ts
import http from 'k6/http'
import { check, group, sleep } from 'k6'
import { Counter, Rate } from 'k6/metrics'
import { getConfig } from './utils/utils.ts'

const API_URL = 'http://127.0.0.1:5001'
const successfulActionsRate = new Rate('successful_actions_rate')
const successfulActionsCount = new Counter('successful_actions_count')

const configs = JSON.parse(open('./configs/test-config.json'))
const testConfig = getConfig(configs, __ENV.TEST_TYPE)

export const options = {
  stages: testConfig.stages,
  thresholds: testConfig.thresholds
}

export function setup() {
  const serverCheck = http.get(`${API_URL}/api/colors`)
  if (serverCheck.status !== 200) {
    throw new Error(`Server is not reachable. Status: ${serverCheck.status}`)
  }
}

export default function apiPerformanceTest() {
  // Generate valid data conforming to the Zod schema
  const newColorName = 'TestColor ' + Math.random().toString(36).substring(2, 8)
  const newColorHex =
    '#' +
    Math.floor(Math.random() * 16777215)
      .toString(16)
      .padStart(6, '0')

  group('Color Management', function () {
    // Create → Read → Delete lifecycle
    const createResponse = http.post(
      `${API_URL}/api/colors`,
      JSON.stringify({ name: newColorName, hex: newColorHex }),
      { headers: { 'Content-Type': 'application/json' } }
    )
    const colorCreated = check(createResponse, { 'Color creation status is 201': (r) => r.status === 201 })

    if (colorCreated) {
      successfulActionsRate.add(1)
      successfulActionsCount.add(1)

      const getResponse = http.get(`${API_URL}/api/colors/${newColorName}`)
      check(getResponse, {
        'Retrieve color status is 200': (r) => r.status === 200,
        'Retrieved correct hex': (r) => r.json('hex') === newColorHex
      })

      http.del(`${API_URL}/api/colors/${newColorName}`)
    } else {
      successfulActionsRate.add(0)
    }
  })

  sleep(Math.random() * 2 + 1)
}
npm run test:perf:api:smoke
npm run test:perf:ui:load

13. API Property-Based Testing with Schemathesis

The problem: Even with Zod validation, developers miss complex edge cases — null bytes, strings at the exact max length, malformed Unicode. Manual test authoring can't cover what you don't think of.

The solution: Point Schemathesis at the OpenAPI spec. It reads schema constraints and auto-generates thousands of adversarial inputs designed to trigger 5xx responses.

npm run test:api:schemathesis

14. Integration Testing with Testcontainers

File: server/index.int.test.js

The problem: In-memory databases and mocks give false confidence. Unique-constraint violations, connection-pool exhaustion, and MongoDB-specific query behavior only appear with a real database.

The solution: Spin up a throwaway MongoDB container via @testcontainers/mongodb. Every test gets a clean, seeded database. The container is automatically destroyed after the run.

// server/index.int.test.js
const { MongoDBContainer } = require('@testcontainers/mongodb')
const mongoose = require('mongoose')
const fs = require('fs')
const path = require('path')

let app, seedDatabase, Color, mongodbContainer

describe('Server Integration Tests (Testcontainers)', () => {
  jest.setTimeout(60000)

  beforeAll(async () => {
    // Load DOCKER_HOST from local config (supports Colima)
    if (!process.env.DOCKER_HOST) {
      try {
        const configPath = path.resolve(__dirname, '../.docker-local/config.json')
        if (fs.existsSync(configPath)) {
          const config = JSON.parse(fs.readFileSync(configPath, 'utf8'))
          if (config.DOCKER_HOST) process.env.DOCKER_HOST = config.DOCKER_HOST
        }
      } catch (e) {
        /* fallback to default Testcontainers detection */
      }
    }

    mongodbContainer = await new MongoDBContainer('mongo:7.0.5').start()
    const uri = `${mongodbContainer.getConnectionString()}?directConnection=true`
    process.env.MONGO_URI = uri
    await mongoose.connect(uri)

    const server = require('./index')
    app = server.app
    seedDatabase = server.seedDatabase
    Color = server.Color
  })

  beforeEach(async () => {
    await seedDatabase() // reset to 3 default colors before every test
  })

  afterAll(async () => {
    await mongoose.disconnect()
    await mongodbContainer.stop()
  })

  describe('GET /api/colors', () => {
    it('should retrieve all seeded colors', async () => {
      const res = await request(app).get('/api/colors')
      expect(res.status).toBe(200)
      expect(res.body).toHaveLength(3)
      expect(res.body.map((c) => c.name)).toContain('Turquoise')
    })

    it('should not expose _id or __v fields', async () => {
      const res = await request(app).get('/api/colors')
      expect(res.body[0]).not.toHaveProperty('_id')
      expect(res.body[0]).not.toHaveProperty('__v')
    })
  })
  // ... 40 tests across 9 describe blocks
})

The suite covers 40 tests across 9 describe blocks:

BlockWhat it verifies
GET /api/colorsFull list, empty state, _id/__v excluded
GET /api/colors/:nameAll 3 seeds via it.each, case-sensitivity, 400 for invalid name
POST /api/colorsCreation + DB persistence, 409 duplicate, all validation paths
PUT /api/colors/:nameHex update, rename (old 404 / new 200), empty body 400
DELETE /api/colors/:nameDeletion + DB verify, double-delete 404
seedDatabaseIdempotency, clears custom data on re-seed
Method Not AllowedPATCH on both routes → 405 with correct Allow header
Concurrent writes5 parallel POSTs — exactly one 201, rest are 409
End-to-end CRUDCreate → Read → Update → verify persisted → Delete → verify gone
cd server && npm run test:int

Tip

Colima / Custom Docker Socket If you are using Colima, the integration test suite reads DOCKER_HOST automatically from .docker-local/config.json in the project root.


Part 3 — CI/CD & Execution Strategy


15. Test Automation Pyramid: Unit Tests First

           ╱╲
          ╱E2E╲           Top    — slowest, highest cost
         ╱─────╲
        ╱ Integ ╲         Middle — medium speed
       ╱──────────╲
      ╱  Unit Tests ╲     Base   — milliseconds, run on every save
     ╱────────────────╲

The problem: Running all tests (Unit → Integration → E2E) every commit wastes CI minutes. A broken utility function still burns 10 minutes downloading Docker images and spinning up browsers.

The solution: The CI pipeline's needs graph maps to the pyramid layers. If the base fails, nothing above it runs.

# .github/workflows/ci.yml
jobs:
  backend-unit-tests: # Layer 1 — runs immediately
  frontend-unit-tests: # Layer 1 — runs immediately

  backend-integration-tests:
    needs: [backend-unit-tests] # Layer 2 — only if Layer 1 passes

  e2e-sharded:
    needs: [backend-integration-tests, frontend-unit-tests] # Layer 3

16. Cross-Platform Testing with Docker

Files: Dockerfile · docker-compose.yml

The problem: Visual regression tests fail randomly in CI because Linux, macOS, and Windows render fonts and anti-aliasing differently.

The solution: All tests run inside the official mcr.microsoft.com/playwright Docker image — one consistent Linux renderer regardless of the developer's OS.


17. Cross-Browser Testing Strategy

File: playwright.config.ts

The problem: Running every test on all three browser engines triples CI execution time, destroying the developer feedback loop.

The solution: PRs run on Chromium only. Full cross-browser coverage is gated behind an environment variable, scheduled weekly.

// playwright.config.ts
projects: [
  { name: 'Chrome', use: { ...devices['Desktop Chrome'] } },
  ...(process.env.CROSS_BROWSER === 'true'
    ? [
        { name: 'Firefox', use: { ...devices['Desktop Firefox'] } },
        { name: 'WebKit',  use: { ...devices['Desktop Safari']  } },
      ]
    : []),
],
npm run test                  # Fast (Chromium only)
npm run test:cross-browser    # Full coverage (all browsers)

18. Parallel Execution & Sharding

The problem: A growing E2E suite running sequentially on one machine eventually blocks deployments for an hour.

The solution: fullyParallel: true in Playwright for local CPU parallelism. GitHub Actions matrix sharding distributes the suite across multiple independent runners simultaneously.

# .github/workflows/ci.yml
strategy:
  fail-fast: false
  matrix:
    shardIndex: [1, 2, 3, 4]
    shardTotal: [4]

steps:
  - run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}

19. Testing in Production & Ephemeral Environments

The problem: Tests passing against a local Docker container don't prove the app works on production infrastructure (edge networks, serverless routing, environment variables).

The solution: Every PR triggers a Vercel preview deployment. The E2E suite runs against the live preview URL — real infrastructure, real network, real confidence.

# .github/workflows/ci.yml
- name: Deploy to Vercel (Preview)
  id: vercel-deploy
  uses: amondnet/vercel-action@v25
  with:
    vercel-token: ${{ secrets.VERCEL_TOKEN }}

- name: E2E against Vercel Preview
  env:
    BASE_URL: ${{ steps.vercel-deploy.outputs.preview-url }}
  run: npm run test:e2e:prod
# Run against any live URL from your local machine
BASE_URL=https://test-automation-best-practices.vercel.app npm run test:e2e:prod

20. Weekly Builds & Scheduled Runs

The problem: A third-party API silently ships a breaking change at 3 AM. Without scheduled runs, nobody knows until users report it.

The solution: Full suite runs every Sunday at midnight UTC, regardless of commits. Acts as a heartbeat monitor — catches time/date bugs, dependency drift, and environmental degradation.

# .github/workflows/ci.yml
on:
  schedule:
    - cron: '0 0 * * 0' # Every Sunday at midnight UTC

21. Production Testing

Files: playwright.config.ts · browserstack.yml · .percy.yml · e2e/tests/visual.spec.ts · .github/workflows/ci.yml

The problem: Docker-based E2E tests run on a single browser engine on a Linux VM. They cannot catch Safari-specific layout bugs, Android rendering differences, or the real-world performance characteristics of a mobile network. Additionally, functional tests don't validate visual correctness — unintended layout shifts and color changes slip through.

The solution: A two-pronged production validation strategy:

  1. BrowserStack for Functional E2E Testing — Set BROWSERSTACK=true to switch Playwright from local Docker projects to five BrowserStack cloud targets (three desktop browsers + two real mobile devices) running against the live Vercel deployment.
  2. Percy for Visual Regression Testing — Capture pixel-perfect snapshots at multiple viewports against production, with cloud-based comparison and team approval workflows.

Why it matters:

  • Cross-browser confidence — Safari/WebKit renders fonts, CSS custom properties, and scrolling differently from Linux. Real mobile devices exercise touch events and viewport meta behaviour that emulation approximates.
  • Production URL validation — Running against live Vercel catches CDN configuration, edge-function routing, and environment-variable mismatches that Docker tests miss.
  • Visual regression detection — Unintended layout shifts and styling changes that pass all functional tests are caught by pixel-perfect comparison.
  • Team collaboration — Non-technical team members review and approve visual changes in Percy UI before merge.
  • Single source of truth — Both tests run the same test code against production; only environment variables differ.

BrowserStack E2E Testing

// playwright.config.ts — projects switch automatically when BROWSERSTACK=true
const isBrowserStack = process.env.BROWSERSTACK === 'true'

projects: isBrowserStack
  ? [
      { name: 'bs-chrome-win11', use: { ...devices['Desktop Chrome'] }, testIgnore: BS_IGNORE },
      { name: 'bs-firefox-win11', use: { ...devices['Desktop Firefox'] }, testIgnore: BS_IGNORE },
      { name: 'bs-safari-ventura', use: { ...devices['Desktop Safari'] }, testIgnore: BS_IGNORE },
      { name: 'bs-pixel-7', use: { ...devices['Pixel 7'] }, testIgnore: BS_IGNORE },
      { name: 'bs-iphone-15', use: { ...devices['iPhone 15'] }, testIgnore: BS_IGNORE }
    ]
  : [
      /* local Chrome / BDD / cross-browser projects */
    ]
# browserstack.yml — authentication, build metadata, and parallelism
userName: ${BROWSERSTACK_USERNAME}
accessKey: ${BROWSERSTACK_ACCESS_KEY}
projectName: Test Automation Best Practices
buildName: Production E2E (BrowserStack)
parallelsPerPlatform: 2
networkLogs: true
video: true
# .github/workflows/ci.yml — manual trigger or weekly schedule
e2e-browserstack:
  name: E2E Tests (BrowserStack - Production)
  if: |
    (github.event_name == 'workflow_dispatch' && github.event.inputs.run_browserstack == 'true') ||
    github.event_name == 'schedule'
  steps:
    - run: npm install -D browserstack-node-sdk
    - name: Run E2E tests on BrowserStack
      env:
        BROWSERSTACK: 'true'
        BASE_URL: https://test-automation-best-practices.vercel.app/
        BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
        BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
      run: npx browserstack-node-sdk playwright test e2e/tests/

Percy Visual Regression Testing

# .percy.yml — viewport and browser configuration
version: 2
allowed-hosts:
  - localhost
  - test-automation-best-practices.vercel.app

browsers:
  - name: chrome
    widths:
      - 1280 # Desktop
      - 1920 # Desktop XL
      - 768 # Tablet
      - 375 # Mobile
// e2e/tests/visual.spec.ts — Percy snapshots for production
import { percySnapshot } from '@percy/playwright'

test.describe('Percy Visual Regression – Production', () => {
  test.skip(!process.env.PERCY_TOKEN, 'Skipping Percy tests — PERCY_TOKEN not set.')

  const percyViewports = [
    { label: 'desktop', width: 1280, height: 720 },
    { label: 'desktop-xl', width: 1920, height: 1080 },
    { label: 'tablet', width: 768, height: 1024 },
    { label: 'mobile', width: 375, height: 667 }
  ]

  for (const vp of percyViewports) {
    test.describe(`Percy – ${vp.label} (${vp.width}×${vp.height})`, () => {
      test.use({ viewport: { width: vp.width, height: vp.height } })

      test('homepage initial state', async ({ page }) => {
        await page.goto('/')
        await page.waitForLoadState('networkidle')
        await percySnapshot(page, `Homepage – ${vp.label}`, { widths: [vp.width] })
      })

      test('homepage after color change', async ({ page, homePage }) => {
        await homePage.goto()
        const responsePromise = page.waitForResponse(
          (resp) => resp.url().includes('/api/colors/Yellow') && resp.status() === 200
        )
        await homePage.clickColorButton('Yellow')
        await responsePromise
        await page.waitForLoadState('networkidle')
        await percySnapshot(page, `Homepage with Yellow – ${vp.label}`, { widths: [vp.width] })
      })
    })
  }
})

Production Performance Testing with k6

The browserstack.yml workflow chains two additional jobs after Percy to load-test the live production environment: performance-testing-api-prod and performance-testing-ui-prod. Both run only on the manual trigger and the weekly schedule, using TEST_TYPE: production-load / production-load-ui — configurations tuned to stay within the 100 POST/15-min rate limit.

Why run performance tests against production:

  • Real infrastructure — CDN edge latency, serverless cold starts, and connection-pool behaviour only surface against a live deployment; a Docker container on a CI runner hides them all
  • Rate-limit validation — the test suite deliberately handles 429 responses and backs off, proving the limiter fires at the correct threshold under real load
  • End-to-end latency baseline — p95 thresholds enforced in CI catch regressions before they affect users

API performance test — full CRUD lifecycle per VU (Create → Read → Update → Delete), with automatic backoff on 429:

// performance/api-performance.spec.ts
const API_URL = __ENV.API_URL ? __ENV.API_URL.replace(/\/$/, '') : 'http://127.0.0.1:5001'

export function setup() {
  const serverCheck = http.get(`${API_URL}/api/colors`)
  if (serverCheck.status !== 200) throw new Error(`Server is not reachable. Status: ${serverCheck.status}`)
}

export default function apiPerformanceTest() {
  group('Color Management', function () {
    const createResponse = http.post(`${API_URL}/api/colors`, colorPayload, requestParams)

    if (createResponse.status === 429) {
      rateLimitedRequests.add(1)
      sleep(testConfig.sleepMin || 10) // back off — stay within 100 req/15 min
      return
    }

    if (check(createResponse, { 'Color creation status is 201': (r) => r.status === 201 })) {
      http.get(`${API_URL}/api/colors/${newColorName}`)      // Read
      http.put(`${API_URL}/api/colors/${newColorName}`, ...) // Update
      http.del(`${API_URL}/api/colors/${newColorName}`)      // Delete
    }
  })

  sleep(sleepMin + Math.random() * (sleepMax - sleepMin)) // paced to respect rate limit
}

UI performance test — browser-driven Chromium scenario (page load → color selection → language toggle), with health-check before the run:

// performance/ui-performance.spec.ts
const BASE_URL = __ENV.BASE_URL ? __ENV.BASE_URL.replace(/\/$/, '') : 'http://127.0.0.1:3000'

export function setup() {
  const serverCheck = http.get(`${API_URL}/api/colors`)
  if (serverCheck.status !== 200) throw new Error(`Server is not reachable. Status: ${serverCheck.status}`)
}

export default async function performanceTest() {
  page = await context.newPage()

  await page.goto(BASE_URL)
  check(page, { 'Homepage header is visible': () => header.isVisible() })

  await colorButton.click() // random color selection
  await langButton.click() // i18n toggle
  check(page, { 'Color updated successfully': () => textContent.includes(expectedHex) })
}

CI jobs in browserstack.yml — sequenced after Percy so the full production validation chain is e2e-production → visual-regression-percy → performance-testing-api-prod → performance-testing-ui-prod:

performance-testing-api-prod:
  needs: [visual-regression-percy]
  steps:
    - name: Run API Production Load Test
      env:
        TEST_TYPE: production-load
        API_URL: ${{ env.PRODUCTION_URL }}
      run: npm run test:perf:api:load:prod

performance-testing-ui-prod:
  needs: [performance-testing-api-prod]
  steps:
    - name: Run UI Production Load Test
      env:
        K6_BROWSER_ENABLED: 'true'
        TEST_TYPE: production-load-ui
        BASE_URL: ${{ env.PRODUCTION_URL }}
      run: dbus-run-session -- npm run test:perf:ui:load:prod

Usage

# Run BrowserStack E2E tests locally
BROWSERSTACK=true \
BROWSERSTACK_USERNAME=your_user \
BROWSERSTACK_ACCESS_KEY=your_key \
BASE_URL=https://test-automation-best-practices.vercel.app/ \
npx browserstack-node-sdk playwright test e2e/tests/

# Run Percy visual regression tests locally
PERCY_TOKEN=your_percy_token \
BASE_URL=https://test-automation-best-practices.vercel.app/ \
npm run test:visual:percy

# Run production performance tests locally
TEST_TYPE=production-load API_URL=https://test-automation-best-practices.vercel.app npm run test:perf:api:load:prod
TEST_TYPE=production-load-ui BASE_URL=https://test-automation-best-practices.vercel.app npm run test:perf:ui:load:prod

# Or in CI/CD (all run automatically with respective triggers):
# - BrowserStack, Percy & Performance: Manual trigger or weekly schedule

Note

Production Testing Strategy:

  • BrowserStack Functional Tests — Validate user interactions and API contracts across real browsers and devices (Chrome, Firefox, Safari on desktop; Chrome, Safari on mobile)
  • Percy Visual Tests — Detect pixel-perfect visual regressions at multiple viewports (1280, 1920, 768, 375 widths) with team approval workflow
  • k6 Performance Tests — Load-test the live API (full CRUD lifecycle) and UI (browser-driven Chromium) against production thresholds; automatic 429 backoff validates rate-limiting behaviour
  • Sequenced pipelinee2e-production → percy → perf-api → perf-ui; each stage builds confidence before the next heavier test type runs
  • Same Test Code — All suites target the same PRODUCTION_URL; only TEST_TYPE, BROWSERSTACK, and PERCY_TOKEN differ between local and CI runs

22. Automated Container Health & Security Scanning with Trivy

The problem: Two failure modes — brittle sleep loops that don't understand container lifecycle cause tests to start before services are ready; third-party packages and base Docker images carry known CVEs discovered late in the cycle when fixes are expensive.

The solution: Native Docker healthchecks + the --wait flag in CI guarantee every service is healthy before tests run. Two-layer security scanning — npm audit for Node.js dependencies, Trivy for deep filesystem and container image analysis — catches vulnerabilities before they ship.

Container health checks

# docker-compose.yml
healthcheck:
  test: [
      'CMD',
      'node',
      '-e',
      "const http = require('http'); http.get('http://127.0.0.1:3000/api/colors',
      (r) => { process.exit(r.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"
    ]
  interval: 5s
  timeout: 5s
  start_period: 15s
  retries: 10
# ci.yml — no more wait scripts
- run: docker compose up -d --build --wait app api mongo
docker ps --format "{{.Names}}: {{.Status}}"
# test-automation-app: Up 2 minutes (healthy)

Security scanning

Caution

Trivy Supply Chain Incident (March 2026) Malicious actors compromised Aqua Security's build pipeline and injected credential-stealing code into Trivy versions 0.69.40.69.6 and repointed GitHub Action release tags. This project strict-pins the Action to an immutable commit SHA and hard-pins the Docker image version.

uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1
with:
  trivy-version: '0.69.3'
npm run security:audit                # npm audit
npm run security:scan:code            # Trivy filesystem scan
npm run security:scan:container:app   # Trivy container scan (frontend)
npm run security:scan:container:api   # Trivy container scan (backend)

Part 4 — Quality Gates & Reporting


23. Static Code Analysis with MegaLinter & SonarCloud

The problem: Inconsistent formatting, leaked secrets, and dead code accumulate silently. Manual code review can't catch everything at scale.

The solution: MegaLinter runs 100+ linters (ESLint, Prettier, Checkov, Secretlint) on every commit. SonarCloud tracks technical debt over time and enforces a strict quality gate on PRs.

npx --yes mega-linter-runner@latest   # Local MegaLinter run

24. E2E Code Coverage

Files: e2e/baseFixtures.ts · e2e/tests/coverage.spec.ts

The problem: You have hundreds of UI tests but no idea which application code they actually execute. Coverage reveals which features are completely untested.

The solution: babel-plugin-istanbul instruments the React build. After each test, baseFixtures.ts collects window.__coverage__ from every open page and writes it to .nyc_output/. NYC aggregates and reports.

// e2e/baseFixtures.ts — coverage collection wired into the context fixture
context: async ({ context }, use) => {
  // Inject the beforeunload listener that serialises Istanbul's __coverage__ object
  await context.addInitScript(() =>
    window.addEventListener('beforeunload', () =>
      (window as any).collectIstanbulCoverage(JSON.stringify((window as any).__coverage__))
    )
  )

  if (!fs.existsSync(istanbulCLIOutput)) {
    await fs.promises.mkdir(istanbulCLIOutput, { recursive: true })
  }

  // Expose the collection function to the browser context
  await context.exposeFunction('collectIstanbulCoverage', (coverageJSON: string) => {
    if (coverageJSON) {
      const coverage = JSON.parse(coverageJSON)
      const remapped: Record<string, any> = {}

      // Replaces the internal Docker path with the correct host path
      const hostWorkspace = process.env.HOST_WORKSPACE_PATH || process.cwd()
      const srcDir = path.join(hostWorkspace, 'src')

      for (const [key, value] of Object.entries(coverage)) {
        const newPath = key.replace(/^\/app\/src\//, srcDir + '/')
        const entry = value as any
        entry.path = newPath
        remapped[newPath] = entry
      }

      fs.writeFileSync(
        path.join(istanbulCLIOutput, `playwright_coverage_${generateUUID()}.json`),
        JSON.stringify(remapped)
      )
    }
  })

  await use(context)

  // After each test, flush coverage from all open pages
  for (const page of context.pages()) {
    await page.evaluate(() => (window as any).collectIstanbulCoverage(JSON.stringify((window as any).__coverage__)))
  }
}
// e2e/tests/coverage.spec.ts — import from baseFixtures to enable collection
import { test, expect } from '../baseFixtures'

test.beforeEach(async ({ page }) => {
  await page.goto('/')
})

test('check Turquoise (#1abc9c) is the default background color', async ({ page }) => {
  await expect(page.locator('text=Current color:')).toContainText('1abc9c')
  await expect(page.locator('header')).toHaveCSS('background-color', 'rgb(26, 188, 156)')
})

test.describe('Edge cases and error handling', () => {
  test('handle initial fetch error', async ({ page }) => {
    await page.route('/api/colors', (route) => route.fulfill({ status: 500 }))
    await page.goto('/')
    await expect(page.locator('.error-message')).toHaveText('Failed to load colors')
  })

  test('handle color response missing hex property', async ({ page }) => {
    await page.route('/api/colors/Yellow', (route) =>
      route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ name: 'Yellow' }) })
    )
    await page.click('text=Yellow')
    await expect(page.locator('text=Current color:')).not.toContainText('undefined')
  })
})
npm run coverage          # Run tests + generate report
npm run coverage:check    # Enforce 80% gate

25. Quality Gates & Coverage Limits

The problem: Without automated enforcement, coverage slowly erodes as new features ship without tests. Technical debt compounds silently.

The solution: nyc check-coverage fails the build if lines, functions, or branches drop below 80%. PRs cannot merge without meeting the gate.

"coverage:check": "nyc check-coverage --lines 80 --functions 80 --branches 80"
# ci.yml
- name: Enforce 80% Coverage Gate
  run: npm run coverage:check

26. Allure Reports with Historical Data & Flaky Test Detection

Link: Live Allure Report

The problem: Test failed in terminal output is useless to stakeholders. Screenshots, videos, and history are scattered across CI artifacts.

The solution: allure-playwright produces a rich visual dashboard with embedded screenshots, failure categorisation, historical trends, and Jira deep-links. Flaky tests are automatically identified without needing a retry pass.

// playwright.config.ts — flaky test auto-detection and Jira integration
reporter: [
  [
    'allure-playwright',
    {
      detail: true,
      suiteTitle: false,
      categories: [
        {
          name: 'Flaky Network Issues',
          messageRegex: '.*timeout.*|.*ECONNRESET.*|.*fetch failed.*',
          matchedStatuses: ['failed', 'broken'],
          flaky: true
        }
      ],
      links: {
        issue: {
          urlTemplate: 'https://your-company.atlassian.net/browse/%s',
          nameTemplate: 'Jira: %s'
        }
      }
    }
  ]
]

Gherkin → Allure metadata via auto-fixture — no manual reporting boilerplate:

// e2e/baseFixtures.ts — maps Gherkin tags to Allure metadata automatically
allureBddMapper: [
  async ({}, use, testInfo) => {
    for (const tag of testInfo.tags) {
      const cleanTag = tag.replace('@', '')
      if (cleanTag.startsWith('epic:')) allure.epic(cleanTag.split(':')[1].replace(/_/g, ' '))
      if (cleanTag.startsWith('feature:')) allure.feature(cleanTag.split(':')[1].replace(/_/g, ' '))
      if (cleanTag.startsWith('story:')) allure.story(cleanTag.split(':')[1].replace(/_/g, ' '))
      if (cleanTag.startsWith('severity:')) allure.severity(cleanTag.split(':')[1])
      if (cleanTag.startsWith('jira:')) allure.issue(cleanTag.split(':')[1])
    }
    await use()
  },
  { auto: true }
]
@epic:UI_Components @feature:Theming @story:Background_Color @severity:normal @jira:UI-456
Scenario Outline: Change background color
npx allure serve allure-results

27. Mutation Testing with Stryker Mutator

Files: server/index.js · server/index.test.js · server/stryker.config.json

The problem: 100% line coverage that still misses bugs. A test that calls POST /api/colors but never checks the response status would pass even if the endpoint always returned 500.

The solution: Stryker introduces deliberate code mutations — flipping === to !==, > to < — then runs the test suite. A killed mutant means your assertions caught the bug. A surviving mutant means your tests are too weak.

Why coverage ≠ confidence:

// A test with 100% line coverage but zero assertion quality:
test('create color', async () => {
  await request(app).post('/api/colors').send({ name: 'Red', hex: '#ff0000' })
  // No expect() — passes even if server returns 500
})

// A test that kills mutants:
test('returns 409 for a duplicate color name', async () => {
  const res = await request(app).post('/api/colors').send({ name: 'Red', hex: '#ff0000' })
  expect(res.status).toBe(409) // kills: 409 → 200
  expect(res.body.error).toContain('already exists') // kills: error string swapped
})
// server/stryker.config.json
{
  "mutate": ["index.js"],
  "testRunner": "jest",
  "reporters": ["clear-text", "html", "progress"],
  "thresholds": { "high": 80, "low": 70, "break": 70 }
}

The break: 70 setting causes Stryker to exit with code 1 if the mutation score falls below 70%, failing the CI pipeline.

cd server && npm run mutation

28. Automated Dependency Updates

File: .github/workflows/dependabot.yml

The problem: Manually updating dependencies is tedious. Ignoring them leads to accumulated security debt and painful major-version migrations.

The solution: Dependabot opens PRs weekly for both frontend and backend. The CI pipeline runs automatically against every Dependabot PR — you get concrete proof a dependency upgrade doesn't break anything before merging.

# .github/workflows/dependabot.yml
updates:
  - package-ecosystem: 'npm'
    directory: '/' # Frontend
    schedule: { interval: 'weekly', day: 'monday' }

  - package-ecosystem: 'npm'
    directory: '/server' # Backend
    schedule: { interval: 'weekly', day: 'monday' }

  - package-ecosystem: 'github-actions'
    directory: '/'
    schedule: { interval: 'monthly' }

29. Security E2E Testing with Playwright

File: e2e/tests/security.spec.ts

The problem: Dependency scanners find known CVEs in third-party packages. They cannot verify that your own Zod schema actually rejects { name: { $gt: '' } } when it hits the live stack.

The solution: A dedicated Playwright suite fires real attack payloads at the running application and asserts the defences fire at the correct layer.

GroupTestsThreat
XSS Prevention4HTML/script/javascript: tags rejected at Zod regex layer
Injection Prevention4$gt/$where NoSQL operators, SQL special chars blocked by alphanumeric-only regex
Input Validation5.strict() rejects extra fields; 413 for oversized bodies; 400 for malformed JSON
Security Headers2X-Powered-By absent; error responses always application/json
HTTP Method Restrictions5PATCH/DELETE/POST on wrong routes → 405 with correct Allow header
Rate Limiting1101 concurrent POSTs → at least one receives 429
// e2e/tests/security.spec.ts
import { test, expect } from '@playwright/test'

// Serial mode ensures the rate-limit group (which exhausts the POST quota)
// always runs last, leaving other POST-based groups unaffected
test.describe.configure({ mode: 'serial' })

test.describe('Security', () => {
  test.describe('XSS Prevention', () => {
    test('POST with HTML tag in name is rejected (400)', async ({ request }) => {
      const res = await request.post('/api/colors', {
        data: { name: '<img src=x onerror=alert(1)>', hex: '#ff0000' }
      })
      expect(res.status()).toBe(400)
      expect((await res.json()).error).toBeTruthy()
    })

    test('color names rendered in the UI do not trigger script execution', async ({ page }) => {
      let alerted = false
      page.on('dialog', () => {
        alerted = true
      })
      await page.goto('/')
      await page.waitForTimeout(1000)
      expect(alerted).toBe(false)
    })
  })

  test.describe('Injection Prevention', () => {
    test('NoSQL injection object in name is rejected (400)', async ({ request }) => {
      // Zod rejects non-string types before they reach MongoDB
      const res = await request.post('/api/colors', {
        data: { name: { $gt: '' }, hex: '#ffffff' }
      })
      expect(res.status()).toBe(400)
    })

    test('SQL injection characters in name are rejected (400)', async ({ request }) => {
      const res = await request.post('/api/colors', {
        data: { name: "'; DROP TABLE colors; --", hex: '#ff0000' }
      })
      expect(res.status()).toBe(400)
    })
  })

  test.describe('Input Validation', () => {
    test('oversized payload is rejected (413)', async ({ request }) => {
      // Express.json() default limit is 100 kb; this body is ~150 kb
      const res = await request.post('/api/colors', {
        headers: { 'Content-Type': 'application/json' },
        data: JSON.stringify({ name: 'A'.repeat(150_000), hex: '#ff0000' })
      })
      expect(res.status()).toBe(413)
    })

    test('malformed JSON body returns 400', async ({ request }) => {
      const res = await request.post('/api/colors', {
        headers: { 'Content-Type': 'application/json' },
        data: '{invalid json'
      })
      expect(res.status()).toBe(400)
    })
  })

  test.describe('HTTP Method Restrictions', () => {
    test('PATCH /api/colors returns 405', async ({ request }) => {
      const res = await request.patch('/api/colors')
      expect(res.status()).toBe(405)
      expect(res.headers()['allow']).toContain('GET')
    })

    test('POST /api-docs returns 405', async ({ request }) => {
      const res = await request.post('/api-docs')
      expect(res.status()).toBe(405)
      expect(res.headers()['allow']).toBe('GET')
    })
  })

  test.describe('Rate Limiting', () => {
    test('POST /api/colors returns 429 after exceeding rate limit', async ({ request }) => {
      // Fire 101 requests — the 100-req/15-min limiter must fire on at least one
      const responses = await Promise.all(
        Array.from({ length: 101 }, (_, i) =>
          request.post('/api/colors', { data: { name: `RateLimit${i}`, hex: '#aabbcc' } })
        )
      )
      expect(responses.map((r) => r.status())).toContain(429)
    })
  })
})
npx playwright test e2e/tests/security.spec.ts

Project Structure

e2e/
├── features/            # Gherkin BDD scenarios (home, error-handling, i18n)
├── pages/
│   └── HomePage.ts      # Page Object Model
├── tests/               # 14 Playwright test suites
│   ├── a11y.spec.ts
│   ├── api.spec.ts
│   ├── coverage.spec.ts
│   ├── data-driven.spec.ts
│   ├── error-handling.spec.ts
│   ├── hybrid.spec.ts
│   ├── network-mocking.spec.ts
│   ├── pom-refactored.spec.ts
│   ├── random-data.spec.ts
│   ├── security.spec.ts
│   ├── visual.spec.ts
│   └── ...
├── baseFixtures.ts      # Coverage collection, POM fixtures, Allure BDD mapper
└── global-setup.ts      # Global initialization
performance/
├── api-performance.spec.ts
└── ui-performance.spec.ts
server/
├── index.js             # Express API + Zod validation + MongoDB seed
├── index.test.js        # Jest + Supertest unit tests
├── index.int.test.js    # Integration tests (Testcontainers)
└── stryker.config.json  # Mutation testing config
src/
├── App.tsx              # React entry point
├── ColorPicker.tsx      # Color wheel + HSL/RGB/hex conversion utilities
├── ConfirmDialog.tsx    # Accessible confirmation dialog
└── locales/             # i18n JSON (en, es, el)
mobile/
├── .eas/workflows/
│   ├── create-development-builds.yml   # EAS parallel iOS + Android dev builds
│   └── create-production-builds.yml    # EAS parallel iOS + Android production builds
├── e2e/
│   ├── pageObjects/
│   │   └── ColorPickerScreen.ts        # Platform-aware POM (iOS accessibility ID / Android UiSelector)
│   ├── tests/
│   │   └── colorPicker.test.ts         # WebdriverIO + Appium E2E test suite
│   ├── wdio.ios.conf.ts                # iOS config (XCUITest, iPhone 16, iOS 18.5)
│   └── wdio.android.conf.ts            # Android config (UiAutomator2, Pixel 6, API 35)
├── src/
│   ├── api/client.ts                   # API client (wraps shared package, resolves base URL)
│   ├── components/
│   │   ├── ColorPickerInner.tsx        # HSL slider component
│   │   ├── ColorPickerInner.test.tsx
│   │   ├── ColorPickerModal.tsx        # Add-color modal with name validation
│   │   ├── ColorPickerModal.test.tsx
│   │   ├── ConfirmDialog.tsx           # Delete confirmation modal
│   │   └── ConfirmDialog.test.tsx
│   ├── colorUtils.ts                   # HSL→RGB→hex conversions + WCAG readability
│   ├── colorUtils.test.ts
│   └── i18n.ts                         # i18next init (EN / ES / EL via shared package)
├── App.tsx                             # Root component (colors list, picker, confirm, i18n)
├── index.ts                            # Expo entry point (registers root component)
├── app.json                            # Expo config (bundle IDs, EAS project ID, API URL)
├── eas.json                            # EAS build profiles (development/simulator/preview/production)
├── jest.config.js                      # Jest preset, coverage config, JUnit reporter
├── metro.config.js                     # Metro bundler (monorepo watch folders, React pin)
└── tsconfig.json                       # TypeScript config (extends expo base, strict)
packages/shared/                        # Monorepo shared package (@color-app/shared)
│   ├── createApiClient                 # Shared API client factory (web + mobile)
│   ├── STRICT_NAME_REGEX               # Color name validation (same rule on both surfaces)
│   └── locales/                        # EN / ES / EL translation files
.github/workflows/
├── ci.yml               # Full CI/CD pipeline
└── dependabot.yml       # Automated dependency updates

Contributing

Pull requests are welcome. Before submitting:

# Ensure linting passes
npm run lint

# Ensure unit tests pass
npm run test:unit
cd server && npm test

# Ensure E2E tests pass
npx playwright test

All PRs run through the full CI pipeline. The quality gates (80% coverage, 70% mutation score, SonarCloud quality gate) must pass before merging.


Built with Playwright · k6 · Allure · Stryker · Testcontainers

This project is tested with BrowserStack ❤️