Software Testing Strategies: Unit, Integration, E2E, TDD, BDD & CI Testing Complete Guide
Master software testing strategies: unit testing with Jest/Vitest, integration testing, E2E with Playwright/Cypress, TDD/BDD workflows, test pyramids, mocking patterns, test coverage, CI testing pipelines, performance testing, and snapshot/visual regression testing with real-world examples.
- Test Pyramid: Many unit tests > Some integration tests > Few E2E tests
- TDD (Red-Green-Refactor) ensures every line of code is test-driven, improving design quality
- Jest/Vitest for unit & integration tests; Playwright/Cypress for E2E testing
- Mock external dependencies, not internals; use MSW for API mocking
- Target 80% coverage, focus on business-critical paths and error handling
- Layer CI by speed: unit → integration → E2E, parallelize for speed
- The test pyramid is the best strategy for balancing speed, cost, and confidence
- TDD is not just a testing method — it is a design methodology
- Vitest is the go-to for Vite projects; Jest is the standard for non-Vite projects
- Playwright wins on cross-browser & CI speed; Cypress wins on developer experience
- A good mocking strategy makes tests both reliable and maintainable
- Coverage is a tool for finding blind spots, not the sole indicator of quality
- CI test pipelines should be fast, reliable, and provide clear failure reports
1. The Test Pyramid & Testing Strategy
The test pyramid, introduced by Mike Cohn, is the classic model for organizing test layers. The base consists of many fast unit tests, the middle has a moderate number of integration tests, and the top has a few end-to-end tests covering complete user flows.
| Layer | Speed | Cost | Confidence | Proportion |
|---|---|---|---|---|
| Unit Tests | Very Fast (<10ms) | Low | Medium | 70% |
| Integration Tests | Medium (100ms-5s) | Medium | High | 20% |
| E2E Tests | Slow (5s-60s) | High | Highest | 10% |
The Testing Trophy, proposed by Kent C. Dodds, is an alternative model that emphasizes integration tests as the most valuable layer, providing the highest confidence at a reasonable cost. This strategy is especially relevant in modern frontend development.
2. Unit Testing: Jest & Vitest
Unit tests verify the behavior of individual functions or components. Tests should be fast, deterministic, and independent of external services.
Jest Basic Example
// math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function divide(a: number, b: number): number {
if (b === 0) throw new Error("Division by zero");
return a / b;
}
export function fibonacci(n: number): number {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
// math.test.ts
import { add, divide, fibonacci } from "./math";
describe("Math utilities", () => {
describe("add", () => {
it("adds two positive numbers", () => {
expect(add(2, 3)).toBe(5);
});
it("handles negative numbers", () => {
expect(add(-1, -2)).toBe(-3);
});
it("handles zero", () => {
expect(add(0, 5)).toBe(5);
});
});
describe("divide", () => {
it("divides two numbers", () => {
expect(divide(10, 2)).toBe(5);
});
it("throws on division by zero", () => {
expect(() => divide(10, 0)).toThrow("Division by zero");
});
});
describe("fibonacci", () => {
it.each([
[0, 0], [1, 1], [2, 1], [5, 5], [10, 55],
])("fibonacci(%i) === %i", (input, expected) => {
expect(fibonacci(input)).toBe(expected);
});
});
});Vitest Setup & Example
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
thresholds: {
lines: 80,
branches: 80,
functions: 80,
statements: 80,
},
},
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
});
// src/utils/string.ts
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-");
}
// src/utils/string.test.ts
import { describe, it, expect } from "vitest";
import { slugify } from "./string";
describe("slugify", () => {
it("converts text to kebab-case", () => {
expect(slugify("Hello World")).toBe("hello-world");
});
it("removes special characters", () => {
expect(slugify("Hello! @World#")).toBe("hello-world");
});
it("collapses multiple dashes", () => {
expect(slugify("a---b")).toBe("a-b");
});
it("trims whitespace", () => {
expect(slugify(" hello ")).toBe("hello");
});
});Jest vs Vitest Comparison
| Feature | Jest | Vitest |
|---|---|---|
| Speed | Fast (worker parallel) | Very fast (native ESM + Vite) |
| ESM Support | Experimental | Native |
| TypeScript | Requires ts-jest / babel | Built-in |
| Config | jest.config.ts | Shares vite.config.ts |
| Coverage | Istanbul / c8 | v8 / Istanbul |
| Ecosystem | Largest (plugins, matchers) | Growing, Jest-compatible API |
3. Testing React Components
Use React Testing Library to test component behavior rather than implementation details. Write tests following the principle of "how users interact" with your components.
// LoginForm.tsx
import { useState } from "react";
interface LoginFormProps {
onSubmit: (email: string, password: string) => Promise<void>;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await onSubmit(email, password);
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email" placeholder="Email"
value={email} onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password" placeholder="Password"
value={password} onChange={(e) => setPassword(e.target.value)}
/>
{error && <div role="alert">{error}</div>}
<button type="submit" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</button>
</form>
);
}
// LoginForm.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";
describe("LoginForm", () => {
const user = userEvent.setup();
it("submits with email and password", async () => {
const mockSubmit = vi.fn().mockResolvedValue(undefined);
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByPlaceholderText("Email"), "test@example.com");
await user.type(screen.getByPlaceholderText("Password"), "secret123");
await user.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith("test@example.com", "secret123");
});
});
it("shows error message on failure", async () => {
const mockSubmit = vi.fn().mockRejectedValue(new Error("Invalid credentials"));
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByPlaceholderText("Email"), "bad@example.com");
await user.type(screen.getByPlaceholderText("Password"), "wrong");
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByRole("alert")).toHaveTextContent("Invalid credentials");
});
it("disables button while loading", async () => {
const mockSubmit = vi.fn(() => new Promise(() => {})); // never resolves
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByPlaceholderText("Email"), "test@example.com");
await user.type(screen.getByPlaceholderText("Password"), "secret123");
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(screen.getByRole("button")).toBeDisabled();
expect(screen.getByRole("button")).toHaveTextContent("Signing in...");
});
});4. Test-Driven Development (TDD)
TDD follows the Red-Green-Refactor cycle: write a failing test first, then write the minimum code to make it pass, then refactor. This process ensures you only write necessary code, with every line backed by a test.
TDD in Practice: Building a Shopping Cart
// Step 1: RED — Write a failing test
// cart.test.ts
import { Cart } from "./cart";
describe("Cart", () => {
let cart: Cart;
beforeEach(() => {
cart = new Cart();
});
it("starts empty", () => {
expect(cart.items).toEqual([]);
expect(cart.total).toBe(0);
});
it("adds an item", () => {
cart.add({ id: "1", name: "Widget", price: 9.99, quantity: 1 });
expect(cart.items).toHaveLength(1);
expect(cart.total).toBe(9.99);
});
it("increases quantity for duplicate items", () => {
cart.add({ id: "1", name: "Widget", price: 9.99, quantity: 1 });
cart.add({ id: "1", name: "Widget", price: 9.99, quantity: 2 });
expect(cart.items).toHaveLength(1);
expect(cart.items[0].quantity).toBe(3);
});
it("removes an item", () => {
cart.add({ id: "1", name: "Widget", price: 9.99, quantity: 1 });
cart.remove("1");
expect(cart.items).toHaveLength(0);
});
it("applies percentage discount", () => {
cart.add({ id: "1", name: "Widget", price: 100, quantity: 1 });
cart.applyDiscount({ type: "percent", value: 10 });
expect(cart.total).toBe(90);
});
});
// Step 2: GREEN — Implement minimum code
// cart.ts
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface Discount {
type: "percent" | "fixed";
value: number;
}
export class Cart {
items: CartItem[] = [];
private discount: Discount | null = null;
get total(): number {
const subtotal = this.items.reduce(
(sum, item) => sum + item.price * item.quantity, 0
);
if (!this.discount) return subtotal;
if (this.discount.type === "percent") {
return subtotal * (1 - this.discount.value / 100);
}
return Math.max(0, subtotal - this.discount.value);
}
add(item: CartItem): void {
const existing = this.items.find((i) => i.id === item.id);
if (existing) {
existing.quantity += item.quantity;
} else {
this.items.push({ ...item });
}
}
remove(id: string): void {
this.items = this.items.filter((i) => i.id !== id);
}
applyDiscount(discount: Discount): void {
this.discount = discount;
}
}5. Behavior-Driven Development (BDD)
BDD uses the Given-When-Then structure to write human-readable test specifications. Gherkin syntax allows product managers and developers to collaboratively define acceptance criteria.
# features/login.feature (Gherkin)
Feature: User Login
As a registered user
I want to log in to my account
So that I can access my dashboard
Scenario: Successful login
Given I am on the login page
When I enter "user@example.com" as email
And I enter "validpass123" as password
And I click the "Sign In" button
Then I should be redirected to the dashboard
And I should see "Welcome back" message
Scenario: Failed login with wrong password
Given I am on the login page
When I enter "user@example.com" as email
And I enter "wrongpass" as password
And I click the "Sign In" button
Then I should see "Invalid credentials" error
And I should remain on the login page
// step-definitions/login.steps.ts (Jest-Cucumber)
import { defineFeature, loadFeature } from "jest-cucumber";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const feature = loadFeature("./features/login.feature");
defineFeature(feature, (test) => {
test("Successful login", ({ given, when, and, then }) => {
given("I am on the login page", () => {
render(<LoginPage />);
});
when(/I enter "(.*)" as email/, (email) => {
userEvent.type(screen.getByLabelText("Email"), email);
});
and(/I enter "(.*)" as password/, (password) => {
userEvent.type(screen.getByLabelText("Password"), password);
});
and(/I click the "(.*)" button/, (buttonText) => {
userEvent.click(screen.getByRole("button", { name: buttonText }));
});
then("I should be redirected to the dashboard", () => {
expect(window.location.pathname).toBe("/dashboard");
});
});
});6. Mocking Strategies & Patterns
The core principle of mocking: mock what you do not own (external APIs, databases), do not mock what you own (internal modules). Prefer dependency injection to make code testable.
Function Mocks & Spies
// Mocking modules
import { vi, describe, it, expect, beforeEach } from "vitest";
import { fetchUser } from "./api";
import { UserService } from "./user-service";
// Mock the entire api module
vi.mock("./api", () => ({
fetchUser: vi.fn(),
}));
describe("UserService", () => {
beforeEach(() => {
vi.clearAllMocks(); // Reset between tests
});
it("returns user data", async () => {
const mockUser = { id: 1, name: "Alice", email: "alice@test.com" };
vi.mocked(fetchUser).mockResolvedValue(mockUser);
const service = new UserService();
const user = await service.getUser(1);
expect(fetchUser).toHaveBeenCalledWith(1);
expect(user).toEqual(mockUser);
});
it("handles API errors gracefully", async () => {
vi.mocked(fetchUser).mockRejectedValue(new Error("Network error"));
const service = new UserService();
const user = await service.getUser(1);
expect(user).toBeNull();
});
});
// Spies — verify calls without replacing implementation
it("logs when user is fetched", async () => {
const consoleSpy = vi.spyOn(console, "log");
const service = new UserService();
await service.getUser(1);
expect(consoleSpy).toHaveBeenCalledWith("Fetching user:", 1);
consoleSpy.mockRestore();
});MSW (Mock Service Worker)
// mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({
id: Number(params.id),
name: "Alice",
email: "alice@test.com",
});
}),
http.post("/api/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ id: 42, ...body },
{ status: 201 }
);
}),
http.get("/api/users", ({ request }) => {
const url = new URL(request.url);
const page = url.searchParams.get("page") || "1";
return HttpResponse.json({
users: [{ id: 1, name: "Alice" }],
page: Number(page),
totalPages: 5,
});
}),
];
// mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
// test/setup.ts
import { beforeAll, afterEach, afterAll } from "vitest";
import { server } from "../mocks/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());7. Integration Testing
Integration tests verify that multiple modules work correctly together. Unlike unit tests, integration tests use real (or near-real) dependencies.
API Route Integration Testing
// Testing Express/Fastify API routes with Supertest
import request from "supertest";
import { app } from "./app";
import { db } from "./database";
describe("POST /api/users", () => {
beforeAll(async () => {
await db.migrate.latest(); // Run migrations
});
afterEach(async () => {
await db("users").truncate(); // Clean between tests
});
afterAll(async () => {
await db.destroy(); // Close connection
});
it("creates a new user", async () => {
const response = await request(app)
.post("/api/users")
.send({ name: "Alice", email: "alice@test.com" })
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(Number),
name: "Alice",
email: "alice@test.com",
});
// Verify database state
const user = await db("users")
.where("email", "alice@test.com").first();
expect(user).toBeDefined();
});
it("rejects duplicate email", async () => {
await request(app)
.post("/api/users")
.send({ name: "Alice", email: "alice@test.com" });
const response = await request(app)
.post("/api/users")
.send({ name: "Bob", email: "alice@test.com" })
.expect(409);
expect(response.body.error).toBe("Email already exists");
});
it("validates required fields", async () => {
const response = await request(app)
.post("/api/users")
.send({ name: "" })
.expect(400);
expect(response.body.errors).toContainEqual(
expect.objectContaining({ field: "email", message: "Required" })
);
});
});Database Integration Testing (Testcontainers)
// Using Testcontainers for real database testing
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import { Pool } from "pg";
describe("UserRepository", () => {
let container;
let pool: Pool;
beforeAll(async () => {
container = await new PostgreSqlContainer()
.withDatabase("testdb")
.start();
pool = new Pool({
connectionString: container.getConnectionUri(),
});
// Run migrations
await pool.query(`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL
)
`);
}, 60000); // Testcontainers can take time to start
afterAll(async () => {
await pool.end();
await container.stop();
});
it("inserts and retrieves a user", async () => {
await pool.query(
"INSERT INTO users (name, email) VALUES ($1, $2)",
["Alice", "alice@test.com"]
);
const result = await pool.query(
"SELECT * FROM users WHERE email = $1",
["alice@test.com"]
);
expect(result.rows[0]).toMatchObject({
name: "Alice",
email: "alice@test.com",
});
});
});8. E2E Testing: Playwright & Cypress
E2E tests simulate real user interactions, verifying the complete flow from UI to backend. Playwright and Cypress are the two leading E2E frameworks.
Playwright Example
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
["html"],
["junit", { outputFile: "results/e2e.xml" }],
],
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "mobile", use: { ...devices["iPhone 14"] } },
],
webServer: {
command: "npm run dev",
port: 3000,
reuseExistingServer: !process.env.CI,
},
});
// e2e/checkout.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Checkout Flow", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/products");
});
test("complete purchase flow", async ({ page }) => {
// Add item to cart
await page.getByRole("button", { name: "Add to Cart" }).first().click();
await expect(page.getByTestId("cart-count")).toHaveText("1");
// Go to cart
await page.getByRole("link", { name: "Cart" }).click();
await expect(page).toHaveURL(/\/cart/);
// Proceed to checkout
await page.getByRole("button", { name: "Checkout" }).click();
// Fill shipping info
await page.getByLabel("Full Name").fill("Alice Smith");
await page.getByLabel("Address").fill("123 Main St");
await page.getByLabel("City").fill("Portland");
await page.getByRole("button", { name: "Continue" }).click();
// Confirm order
await expect(page.getByText("Order Summary")).toBeVisible();
await page.getByRole("button", { name: "Place Order" }).click();
// Verify success
await expect(page.getByText("Order Confirmed")).toBeVisible();
await expect(page.getByTestId("order-number")).toBeVisible();
});
test("handles empty cart", async ({ page }) => {
await page.goto("/cart");
await expect(page.getByText("Your cart is empty")).toBeVisible();
await expect(
page.getByRole("link", { name: "Continue Shopping" })
).toBeVisible();
});
});Cypress Example
// cypress/e2e/auth.cy.ts
describe("Authentication", () => {
beforeEach(() => {
cy.visit("/login");
});
it("logs in successfully", () => {
cy.intercept("POST", "/api/auth/login", {
statusCode: 200,
body: { token: "fake-jwt-token", user: { name: "Alice" } },
}).as("loginRequest");
cy.get("[data-testid=email-input]").type("alice@test.com");
cy.get("[data-testid=password-input]").type("password123");
cy.get("[data-testid=submit-btn]").click();
cy.wait("@loginRequest");
cy.url().should("include", "/dashboard");
cy.contains("Welcome, Alice").should("be.visible");
});
it("shows validation errors", () => {
cy.get("[data-testid=submit-btn]").click();
cy.contains("Email is required").should("be.visible");
cy.contains("Password is required").should("be.visible");
});
it("handles network errors gracefully", () => {
cy.intercept("POST", "/api/auth/login", {
forceNetworkError: true,
}).as("loginFailure");
cy.get("[data-testid=email-input]").type("alice@test.com");
cy.get("[data-testid=password-input]").type("password123");
cy.get("[data-testid=submit-btn]").click();
cy.contains("Network error").should("be.visible");
});
});Playwright vs Cypress Comparison
| Feature | Playwright | Cypress |
|---|---|---|
| Browser Support | Chromium, Firefox, WebKit | Chrome, Edge, Firefox |
| Multi-tab/Multi-origin | Native | Limited |
| Wait Strategy | Auto-wait + Web-first assertions | Built-in retry |
| Debugging | Trace Viewer, Inspector | Time-travel debugger |
| Network Interception | Powerful (route, fulfill, abort) | Powerful (intercept) |
| CI Speed | Faster (headless + parallel contexts) | Slower (requires Xvfb) |
| Language | TS, JS, Python, C#, Java | TS, JS |
9. Test Coverage
Coverage tools measure how much code is exercised by tests. Key metrics include line coverage, branch coverage, function coverage, and statement coverage.
// Configure coverage in Vitest
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "json-summary", "html", "lcov"],
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/**/*.test.{ts,tsx}",
"src/**/*.d.ts",
"src/test/**",
"src/types/**",
],
thresholds: {
lines: 80,
branches: 75,
functions: 80,
statements: 80,
},
// Watermarks for color-coding in reports
watermarks: {
lines: [60, 80],
branches: [50, 75],
functions: [60, 80],
statements: [60, 80],
},
},
},
});
// Run coverage:
// vitest run --coverage
// jest --coverage
// Configure coverage in Jest
// jest.config.ts
export default {
collectCoverageFrom: [
"src/**/*.{ts,tsx}",
"!src/**/*.d.ts",
"!src/**/*.test.{ts,tsx}",
],
coverageThreshold: {
global: {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
// Per-file thresholds for critical modules
"./src/services/payment.ts": {
branches: 95,
functions: 100,
lines: 95,
},
},
coverageReporters: ["text", "lcov", "json-summary"],
};Coverage Best Practices
| Practice | Description |
|---|---|
| Set reasonable thresholds | Global 80% line coverage, 95%+ for critical modules |
| Focus on branch coverage | Branch coverage reveals missing edge cases better than line coverage |
| Do not chase 100% | Testing getters/setters and boilerplate code adds no value |
| Enforce in CI | Fail CI when coverage drops, preventing regressions |
| Report to PRs | Use Codecov/Coveralls to show coverage diff in PRs |
10. CI/CD Testing Pipeline
Building an efficient test pipeline in CI is key to maintaining code quality. Layer tests by speed to get feedback as early as possible.
# .github/workflows/test.yml
name: Test Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npm run lint
- run: npm run typecheck
unit-tests:
runs-on: ubuntu-latest
needs: lint-and-typecheck
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npx vitest run --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npx vitest run --config vitest.integration.config.ts
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
e2e-tests:
runs-on: ubuntu-latest
needs: integration-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/11. Performance Testing
Performance testing ensures your application meets response time and throughput requirements under load. This includes load testing, stress testing, and frontend performance benchmarks.
k6 Load Testing
// load-test.js (k6)
import http from "k6/http";
import { check, sleep } from "k6";
import { Rate, Trend } from "k6/metrics";
const errorRate = new Rate("errors");
const responseTime = new Trend("response_time");
export const options = {
stages: [
{ duration: "30s", target: 20 }, // Ramp up
{ duration: "1m", target: 50 }, // Steady load
{ duration: "30s", target: 100 }, // Peak load
{ duration: "30s", target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ["p(95)<500", "p(99)<1000"],
errors: ["rate<0.05"],
http_req_failed: ["rate<0.01"],
},
};
export default function () {
// GET request
const listRes = http.get("http://localhost:3000/api/products");
check(listRes, {
"status is 200": (r) => r.status === 200,
"response time < 500ms": (r) => r.timings.duration < 500,
});
errorRate.add(listRes.status !== 200);
responseTime.add(listRes.timings.duration);
// POST request
const payload = JSON.stringify({ query: "test item" });
const searchRes = http.post(
"http://localhost:3000/api/search",
payload,
{ headers: { "Content-Type": "application/json" } }
);
check(searchRes, {
"search returns results": (r) => {
const body = JSON.parse(r.body);
return body.results && body.results.length > 0;
},
});
sleep(1);
}Lighthouse CI Frontend Performance
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: [
"http://localhost:3000",
"http://localhost:3000/products",
"http://localhost:3000/login",
],
numberOfRuns: 3,
startServerCommand: "npm run start",
},
assert: {
assertions: {
"categories:performance": ["error", { minScore: 0.9 }],
"categories:accessibility": ["warn", { minScore: 0.9 }],
"categories:seo": ["warn", { minScore: 0.9 }],
"first-contentful-paint": ["error", { maxNumericValue: 2000 }],
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
"cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
"total-blocking-time": ["error", { maxNumericValue: 300 }],
},
},
upload: {
target: "temporary-public-storage",
},
},
};12. Snapshot & Visual Regression Testing
Snapshot testing captures a "snapshot" of output and compares against it in subsequent runs. Visual regression testing performs pixel-level comparison of UI screenshots to prevent unintended visual changes.
// Inline snapshot testing (Vitest/Jest)
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { Button } from "./Button";
describe("Button", () => {
it("matches snapshot", () => {
const { container } = render(
<Button variant="primary" size="lg">Click me</Button>
);
expect(container.firstChild).toMatchSnapshot();
});
it("matches inline snapshot", () => {
const { container } = render(
<Button variant="outline">Cancel</Button>
);
expect(container.innerHTML).toMatchInlineSnapshot();
// Vitest auto-fills the snapshot on first run
});
});
// Playwright visual regression
import { test, expect } from "@playwright/test";
test("homepage visual regression", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveScreenshot("homepage.png", {
maxDiffPixels: 100,
});
});
test("responsive layout", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/");
await expect(page).toHaveScreenshot("homepage-mobile.png", {
maxDiffPixelRatio: 0.01,
});
});
test("component visual test", async ({ page }) => {
await page.goto("/storybook/button");
const button = page.getByRole("button", { name: "Primary" });
await expect(button).toHaveScreenshot("button-primary.png");
});13. Test Organization & Best Practices
Good test organization makes test suites easy to maintain and extend. Follow the AAA (Arrange-Act-Assert) pattern and FIRST (Fast-Independent-Repeatable-Self-validating-Timely) principles.
// Recommended project structure
// src/
// components/
// Button/
// Button.tsx
// Button.test.tsx ← Unit test (co-located)
// services/
// payment.ts
// payment.test.ts ← Unit test (co-located)
// __tests__/
// integration/
// checkout.test.ts ← Integration test
// user-flow.test.ts
// e2e/
// checkout.spec.ts ← E2E test
// auth.spec.ts
// mocks/
// handlers.ts ← MSW handlers
// server.ts
// Test utilities — custom render with providers
// src/test/utils.tsx
import { render, RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "./ThemeProvider";
function AllProviders({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
);
}
export function renderWithProviders(
ui: React.ReactElement,
options?: Omit<RenderOptions, "wrapper">
) {
return render(ui, { wrapper: AllProviders, ...options });
}
// Usage in tests:
import { renderWithProviders } from "../test/utils";
it("renders with providers", () => {
renderWithProviders(<MyComponent />);
// All providers (React Query, Theme, etc.) are available
});Test Naming Conventions
| Pattern | Example | Use Case |
|---|---|---|
| Behavior description | "adds item to cart" | Unit, Integration tests |
| should + action | "should redirect after login" | E2E tests |
| given-when-then | "given empty cart, when add item, then total updates" | BDD style |
| Edge case description | "handles empty string input" | Edge cases |
| Error description | "throws on invalid email" | Error handling |
14. Advanced Testing Patterns
Fixtures & Factory Pattern
// test/factories.ts — Test data factories
import { faker } from "@faker-js/faker";
interface User {
id: string;
name: string;
email: string;
role: "admin" | "user";
createdAt: Date;
}
export function createUser(overrides: Partial<User> = {}): User {
return {
id: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
role: "user",
createdAt: faker.date.past(),
...overrides,
};
}
export function createProduct(overrides = {}) {
return {
id: faker.string.uuid(),
name: faker.commerce.productName(),
price: Number(faker.commerce.price()),
inStock: true,
...overrides,
};
}
// Usage in tests
it("displays user profile", () => {
const admin = createUser({ role: "admin", name: "Alice" });
renderWithProviders(<UserProfile user={admin} />);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Admin")).toBeInTheDocument();
});Async Testing & Retries
// Polling and async assertions
import { waitFor, screen } from "@testing-library/react";
it("loads data after delay", async () => {
renderWithProviders(<AsyncDataComponent />);
// waitFor retries until assertion passes or timeout
await waitFor(
() => {
expect(screen.getByText("Data loaded")).toBeInTheDocument();
},
{ timeout: 3000, interval: 100 }
);
});
// Playwright retries with toPass
import { test, expect } from "@playwright/test";
test("waits for API to be ready", async ({ request }) => {
await expect(async () => {
const response = await request.get("/api/health");
expect(response.status()).toBe(200);
}).toPass({
intervals: [1000, 2000, 5000],
timeout: 30000,
});
});
// Vitest retry configuration
// vitest.config.ts
export default defineConfig({
test: {
retry: process.env.CI ? 2 : 0, // Retry flaky tests in CI
testTimeout: 10000,
},
});Conclusion
A mature testing strategy should cover the full pyramid from unit tests to E2E tests. TDD and BDD are not just testing methods — they are design methodologies that improve code quality and team collaboration. Choose the right tools — Jest/Vitest for unit tests, Playwright/Cypress for E2E tests, MSW for API mocking — and automate the entire workflow in CI. Remember: good testing is not about quantity, but about testing the right things at the right level.
Continuously invest in testing infrastructure — custom render functions, factory functions, MSW handlers, CI pipelines — to make your test suite maintainable. When testing becomes a natural part of the development workflow rather than a burden, you have built a true quality culture.
Frequently Asked Questions
What is the difference between unit testing, integration testing, and E2E testing?
Unit tests verify individual functions or components in isolation, running fast with mocked dependencies. Integration tests check how multiple modules work together with real dependencies like databases or APIs. End-to-end (E2E) tests simulate real user workflows through the entire application stack. The test pyramid recommends many unit tests, fewer integration tests, and minimal E2E tests for optimal cost-to-confidence ratio.
What is Test-Driven Development (TDD) and how does it work?
TDD follows a Red-Green-Refactor cycle: first write a failing test that defines desired behavior (Red), then write the minimum code to make it pass (Green), then refactor for quality while keeping tests green (Refactor). TDD ensures every line of production code is driven by a test, leading to better design, higher coverage, and fewer regressions.
When should I use Jest vs Vitest for JavaScript testing?
Vitest is recommended for Vite-based projects as it shares the same config and transform pipeline, runs significantly faster with native ESM support, and has a Jest-compatible API. Jest remains the standard for non-Vite projects, has the largest ecosystem of plugins and matchers, and is well-supported in Create React App and Next.js. Both support mocking, snapshots, code coverage, and parallel execution.
How do Playwright and Cypress compare for E2E testing?
Playwright supports Chromium, Firefox, and WebKit with native multi-tab, multi-origin, and network interception. It runs faster with auto-wait and parallel browser contexts. Cypress has an excellent developer experience with time-travel debugging, real-time reloading, and a visual test runner, but only supports Chromium-based browsers and Chrome natively. Choose Playwright for cross-browser coverage and CI speed; choose Cypress for developer experience and interactive debugging.
What is BDD and how does it differ from TDD?
Behavior-Driven Development (BDD) extends TDD by writing tests in natural language (Given-When-Then) that both developers and stakeholders can understand. While TDD focuses on technical correctness from a developer perspective, BDD focuses on business behavior and acceptance criteria. BDD tools like Cucumber and Jest-Cucumber translate Gherkin feature files into executable test steps.
What test coverage percentage should I aim for?
Aim for 80% line and branch coverage as a practical target. 100% coverage is rarely worth the effort as it leads to brittle tests on trivial code. Focus coverage on business-critical paths, complex algorithms, and error handling rather than getters/setters or boilerplate. Use coverage tools (Istanbul/c8/v8) to identify untested code paths, not as a quality metric alone — high coverage does not guarantee meaningful tests.
How do I set up testing in a CI/CD pipeline?
In CI, run unit tests first (fastest feedback), then integration tests, then E2E tests. Use parallelization to split test suites across workers. Cache node_modules and build artifacts between steps. Set coverage thresholds to fail the pipeline if coverage drops. Store test reports and artifacts (screenshots, videos) for debugging. Use GitHub Actions, GitLab CI, or CircleCI with matrix builds for cross-environment testing.
What are the best practices for mocking in tests?
Mock external dependencies (APIs, databases, file system) but avoid mocking internal implementation details. Use dependency injection to make code testable. Prefer spies over mocks when you only need to verify calls. Reset mocks between tests to prevent state leakage. Use MSW (Mock Service Worker) for API mocking in both tests and development. Keep mocks close to their real behavior to avoid false confidence.