Skip to main content

Chapter 6 — Testing, CI/CD, and DevOps

The Testing Trophy, Vitest, Playwright, and Production Pipelines

Stage 3 | Quality is a structural property, not a checklist


Chapter Overview

Testing is not about coverage percentages. It is about confidence: confidence that a change does not break existing behavior, confidence that a deployment will not cause an incident, and confidence that new engineers can refactor without fear. This chapter covers the modern testing stack, CI/CD pipeline design, and how to make quality measurable and automated.

Chapter 6 Map

6.1 The Testing Trophy
├── Why not the pyramid
├── Testing types and their roles
└── Coverage vs confidence

6.2 Vitest — unit and integration testing
├── Setup and configuration
├── Component testing with React Testing Library
└── Mocking patterns

6.3 Playwright — E2E and accessibility
├── Setup and best practices
├── Page Object Model
└── Visual regression testing

6.4 CI/CD pipeline design
├── GitHub Actions workflow architecture
├── Azure DevOps equivalents
└── Preview environments

6.5 Quality metrics
├── What to measure (and what not to)
└── Fitness functions for quality

6.1 The Testing Trophy

Kent C. Dodds's Testing Trophy has replaced the pyramid as the reference model for modern frontend testing:

/\
/ \
/ E2E \ Small: 5–15 critical user flows
/──────\
/Integration\ Large: ~70% of your test suite
/────────────\
/ Unit Tests \ Medium: pure logic, utilities, reducers
/────────────────\
/ Static Analysis \ Always: TypeScript + ESLint + Prettier
/────────────────────\

Why the Trophy inverts the Pyramid:

The classic pyramid said: many unit tests, some integration tests, few E2E tests. This works for backend code but fails for frontend because:

  • UI unit tests that mock child components give false confidence (you tested that the mock works, not the real component)
  • The most valuable tests are those that simulate real user behavior
  • Integration tests that render full component trees with real dependencies catch the bugs that matter

Testing philosophy:

Write tests that resemble how users use your application. — Kent C. Dodds

A button that is always rendered but never clicked in tests is not tested in any meaningful way.

6.1.1 What Each Layer Does

Static Analysis (always on)
TypeScript → catches type errors at compile time
ESLint → catches code quality issues, some logic bugs
Prettier → consistent formatting (removes style debates)
Stylelint → CSS consistency
Husky + lint-staged → run checks before every commit

Unit Tests (fast, isolated)
Pure functions → utility functions, formatters, calculators
Reducers → Redux/Zustand/useReducer logic
Hooks (isolated) → custom hook behavior
Tools: Vitest

Integration Tests (the core of your suite)
Component trees → rendered with real children, real hooks
User interactions → click, type, submit
API interactions → mocked at the network layer (MSW)
Tools: Vitest + RTL + MSW

E2E Tests (high confidence, slow)
Critical paths → login, checkout, key user flows
Cross-page flows → multi-step wizards, navigation
Real network calls → or stubbed at proxy level
Tools: Playwright

6.2 Vitest — Unit and Integration Testing

6.2.1 Configuration

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
environment: 'jsdom', // simulate browser environment
setupFiles: ['./src/test/setup.ts'],
globals: true, // no need to import describe/it/expect
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
thresholds: {
lines: 80,
functions: 80,
branches: 70, // branches are harder to hit
},
exclude: [
'node_modules',
'src/test',
'**/*.d.ts',
'**/*.config.*',
'**/types.ts',
],
},
},
});
// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, beforeAll, afterAll } from 'vitest';
import { server } from './mocks/server'; // MSW server

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup(); // unmount React trees
server.resetHandlers();
});
afterAll(() => server.close());

6.2.2 Testing React Components

// src/components/ProductCard/ProductCard.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { ProductCard } from './ProductCard';

const mockProduct = {
id: 'prod_1',
name: 'Industrial Cleaner X500',
price: 299.99,
inStock: true,
imageUrl: '/cleaner.jpg',
};

describe('ProductCard', () => {
it('renders product name and price', () => {
render(<ProductCard product={mockProduct} onAddToCart={vi.fn()} />);

expect(screen.getByText('Industrial Cleaner X500')).toBeInTheDocument();
expect(screen.getByText('€299.99')).toBeInTheDocument();
});

it('calls onAddToCart with product id when button clicked', async () => {
const user = userEvent.setup();
const onAddToCart = vi.fn();

render(<ProductCard product={mockProduct} onAddToCart={onAddToCart} />);

await user.click(screen.getByRole('button', { name: /add to cart/i }));

expect(onAddToCart).toHaveBeenCalledOnce();
expect(onAddToCart).toHaveBeenCalledWith('prod_1');
});

it('disables the add to cart button when out of stock', () => {
render(
<ProductCard
product={{ ...mockProduct, inStock: false }}
onAddToCart={vi.fn()}
/>
);

expect(screen.getByRole('button', { name: /out of stock/i })).toBeDisabled();
});

it('is accessible — no violations', async () => {
const { container } = render(
<ProductCard product={mockProduct} onAddToCart={vi.fn()} />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});

6.2.3 Mocking with MSW (Mock Service Worker)

MSW intercepts real network requests — components behave exactly as in production:

// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
// Mock GET /api/products
http.get('/api/products', ({ request }) => {
const url = new URL(request.url);
const category = url.searchParams.get('category');

return HttpResponse.json({
data: mockProducts.filter(p =>
!category || p.category === category
),
meta: { total: 100, page: 1 },
});
}),

// Mock POST /api/cart/items
http.post('/api/cart/items', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ item: { ...body, id: 'cart_item_1' } },
{ status: 201 }
);
}),

// Simulate network error
http.get('/api/products/999', () => {
return HttpResponse.error();
}),
];

// src/test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// Using MSW in a test to simulate an error
import { server } from '../test/mocks/server';
import { http, HttpResponse } from 'msw';

it('shows error message when product fetch fails', async () => {
server.use(
http.get('/api/products', () => {
return new HttpResponse(null, { status: 500 });
})
);

render(<ProductList />);

await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(
/failed to load products/i
);
});
});

6.2.4 Testing Custom Hooks

// src/hooks/useCart.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useCart } from './useCart';

describe('useCart', () => {
it('starts empty', () => {
const { result } = renderHook(() => useCart());
expect(result.current.items).toHaveLength(0);
expect(result.current.total).toBe(0);
});

it('adds items and updates total', () => {
const { result } = renderHook(() => useCart());

act(() => {
result.current.addItem({ id: '1', name: 'Widget', price: 9.99, qty: 2 });
});

expect(result.current.items).toHaveLength(1);
expect(result.current.total).toBe(19.98);
});

it('removes items', () => {
const { result } = renderHook(() => useCart());

act(() => result.current.addItem({ id: '1', name: 'Widget', price: 9.99, qty: 1 }));
act(() => result.current.removeItem('1'));

expect(result.current.items).toHaveLength(0);
});
});

6.3 Playwright — E2E and Accessibility Testing

6.3.1 Setup and Configuration

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './e2e',
timeout: 30_000,
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,

reporter: [
['html', { outputFolder: 'playwright-report' }],
['github'], // annotations in GitHub Actions
],

use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry', // record trace on failure
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},

projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
],
});

6.3.2 Page Object Model

// e2e/pages/ProductPage.ts
import type { Page, Locator } from '@playwright/test';

export class ProductPage {
readonly page: Page;
readonly productTitle: Locator;
readonly addToCartButton: Locator;
readonly cartCount: Locator;

constructor(page: Page) {
this.page = page;
this.productTitle = page.getByRole('heading', { level: 1 });
this.addToCartButton = page.getByRole('button', { name: /add to cart/i });
this.cartCount = page.getByTestId('cart-count');
}

async goto(productId: string) {
await this.page.goto(`/products/${productId}`);
}

async addToCart() {
await this.addToCartButton.click();
// Wait for optimistic update
await this.page.waitForResponse('/api/cart/items');
}

async getCartCount(): Promise<number> {
return parseInt(await this.cartCount.textContent() || '0', 10);
}
}

// e2e/tests/product.spec.ts
import { test, expect } from '@playwright/test';
import { ProductPage } from '../pages/ProductPage';

test('user can add product to cart', async ({ page }) => {
const productPage = new ProductPage(page);

await productPage.goto('prod_123');

expect(await productPage.page.title()).toContain('Industrial Cleaner');
await expect(productPage.productTitle).toHaveText('Industrial Cleaner X500');

const initialCount = await productPage.getCartCount();
await productPage.addToCart();

await expect(productPage.cartCount).toHaveText(String(initialCount + 1));
});

6.3.3 Visual Regression Testing

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

test.describe('Visual regression — Product listing', () => {
test('matches snapshot at desktop', async ({ page }) => {
await page.goto('/products');
await page.waitForLoadState('networkidle');

// Remove dynamic content (dates, live counts) before snapshot
await page.evaluate(() => {
document.querySelectorAll('[data-dynamic]').forEach(el => {
el.textContent = '[dynamic]';
});
});

await expect(page).toHaveScreenshot('product-listing-desktop.png', {
maxDiffPixels: 100, // allow minor rendering differences
});
});
});

6.4 CI/CD Pipeline Design

6.4.1 GitHub Actions Workflow Architecture

# .github/workflows/ci.yml
name: CI

on:
pull_request:
branches: [main, develop]
push:
branches: [main]

jobs:
# Job 1: Static Analysis (fast, run first)
lint:
name: Lint and Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run type-check

# Job 2: Unit + Integration Tests (parallel with lint)
test:
name: Unit and Integration Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm run test:coverage
- uses: actions/upload-artifact@v4
if: failure()
with:
name: coverage-report
path: coverage/

# Job 3: Build (after tests pass)
build:
name: Build
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: .next/ # or dist/

# Job 4: E2E Tests (after successful build)
e2e:
name: End-to-End Tests
runs-on: ubuntu-latest
needs: [build]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
- name: Start server and run E2E tests
run: pnpm run test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/

# Job 5: Lighthouse Performance Check (on PRs only)
lighthouse:
name: Lighthouse Performance
runs-on: ubuntu-latest
needs: [build]
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile
- run: pnpm run start &
- run: npx lhci autorun --config=lighthouserc.json
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

# Job 6: Deploy to preview (on PRs)
preview:
name: Deploy Preview
runs-on: ubuntu-latest
needs: [build]
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.ORG_ID }}
vercel-project-id: ${{ secrets.PROJECT_ID }}

6.4.2 Branch Protection Rules

For production-grade repositories, configure these branch protection settings on main:

✓ Require pull request reviews before merging
→ Minimum 1 approving review (2 for critical paths)
→ Dismiss stale pull request approvals when new commits are pushed
→ Require review from code owners

✓ Require status checks to pass before merging
→ lint
→ test
→ build
→ e2e (on PR)

✓ Require branches to be up to date before merging
✓ Require signed commits
✓ Include administrators
✗ Allow force pushes (disabled)
✗ Allow deletions (disabled)

6.4.3 Preview Environments

Preview environments per PR are table stakes in 2026. Every PR gets:

  • A unique URL: https://pr-123.preview.example.com
  • Isolated database (or shared staging with PR-scoped data)
  • Accessible to QA, design, and product owners without local setup
# Vercel automatic preview (simplest)
# Add vercel.json to repo, connect to Vercel — previews are automatic

# Custom preview on Cloudflare Pages
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
projectName: my-app
directory: dist
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
# Outputs preview URL as PR comment automatically

6.5 Quality Metrics

6.5.1 What to Measure

Measure these (objectively valuable):

MetricTargetTool
Test pass rate100% (no failing tests merged)Vitest in CI
E2E pass rate100% on mainPlaywright in CI
Lighthouse performance≥ 90LHCI
Lighthouse accessibility≥ 95LHCI
Bundle size deltaNo unreviewed increasesbundlewatch
TypeScript errors0tsc --noEmit in CI
ESLint errors0eslint in CI

Do NOT measure these as targets:

  • Line coverage % — 90% coverage with bad tests gives false confidence. A component with onClick={() => {}} has 100% coverage but is not tested.
  • Number of tests — more tests is not better. Redundant tests slow CI and require maintenance.
  • Test speed in isolation — optimize total pipeline time, not individual test speed.

6.5.2 Ratchet Pattern for Quality

A ratchet prevents quality from going backward without making it a blocker for forward progress:

// .bundlewatch.json
{
"files": [
{
"path": "./dist/assets/*.js",
"maxSize": "200kb", // fail CI if any JS chunk exceeds 200kb
"compression": "gzip"
},
{
"path": "./dist/assets/*.css",
"maxSize": "50kb",
"compression": "gzip"
}
]
}

// In CI: bundlewatch --config .bundlewatch.json
// Reports size change as PR comment, fails if limits exceeded

Chapter 6 — Interview Questions

Q20: How do you handle testing in front-end architecture?

Answer: I follow the Testing Trophy model: heavy investment in integration tests, light unit tests for pure logic, and a small suite of E2E tests for critical user paths.

My stack in 2026:

  • Vitest for unit and integration tests — significantly faster than Jest, native TypeScript, compatible with the Vite build
  • React Testing Library for component rendering — queries that simulate user perception (getByRole, getByText) rather than implementation details (querySelector, getByClass)
  • MSW (Mock Service Worker) for API mocking — intercepts real network requests so components behave exactly as in production
  • Playwright for E2E — multi-browser, reliable waiting, accessibility testing built in
  • axe-core + Playwright for accessibility regression in CI

What I avoid:

Snapshot tests for complex components — they break constantly and reviewers approve the diff without understanding it. I use them only for stable presentational components.

Testing implementation details — if a test breaks when I rename an internal variable, it's not testing user behavior. I throw it away.

Chasing coverage percentages — 80% coverage that tests real behavior is better than 95% coverage that tests mocks.

The integration tests in CI run in parallel across workers — our full suite of 400+ tests runs in under 90 seconds.


Chapter 6 Summary

What you should now know:

Testing Trophy
✓ Integration tests are the core (not unit)
✓ Test behavior, not implementation
✓ Static analysis is always-on (not a test type)

Vitest
✓ jsdom environment, setup file with MSW
✓ RTL: getByRole > getByText > getByTestId
✓ userEvent for realistic interactions
✓ MSW for network-level mocking

Playwright
✓ Page Object Model for maintainability
✓ Visual regression with screenshots
✓ Accessibility testing with axe-core
✓ Multi-browser and mobile device testing

CI/CD
✓ Pipeline stages: lint → test → build → e2e → lighthouse
✓ Jobs run in parallel where dependencies allow
✓ Preview environments per PR
✓ Branch protection rules on main

Quality Metrics
✓ Measure test pass rate, not coverage %
✓ Bundle size budgets enforced in CI
✓ Ratchet pattern prevents regression

Next: Chapter 7 — Frontend Architecture Patterns