DevToolBoxGRATIS
Blogg

Software Testing Strategies Guide: Unit, Integration, E2E, TDD & BDD

25 min readby DevToolBox Team

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.

TL;DR — Software Testing Strategies in 60 Seconds
  • 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
Key Takeaways
  • 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.

LayerSpeedCostConfidenceProportion
Unit TestsVery Fast (<10ms)LowMedium70%
Integration TestsMedium (100ms-5s)MediumHigh20%
E2E TestsSlow (5s-60s)HighHighest10%

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

FeatureJestVitest
SpeedFast (worker parallel)Very fast (native ESM + Vite)
ESM SupportExperimentalNative
TypeScriptRequires ts-jest / babelBuilt-in
Configjest.config.tsShares vite.config.ts
CoverageIstanbul / c8v8 / Istanbul
EcosystemLargest (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

FeaturePlaywrightCypress
Browser SupportChromium, Firefox, WebKitChrome, Edge, Firefox
Multi-tab/Multi-originNativeLimited
Wait StrategyAuto-wait + Web-first assertionsBuilt-in retry
DebuggingTrace Viewer, InspectorTime-travel debugger
Network InterceptionPowerful (route, fulfill, abort)Powerful (intercept)
CI SpeedFaster (headless + parallel contexts)Slower (requires Xvfb)
LanguageTS, JS, Python, C#, JavaTS, 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

PracticeDescription
Set reasonable thresholdsGlobal 80% line coverage, 95%+ for critical modules
Focus on branch coverageBranch coverage reveals missing edge cases better than line coverage
Do not chase 100%Testing getters/setters and boilerplate code adds no value
Enforce in CIFail CI when coverage drops, preventing regressions
Report to PRsUse 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

PatternExampleUse 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.

𝕏 Twitterin LinkedIn
Var detta hjälpsamt?

Håll dig uppdaterad

Få veckovisa dev-tips och nya verktyg.

Ingen spam. Avsluta när som helst.

Try These Related Tools

{ }JSON Formatter.*Regex TesterB→Base64 Encoder

Related Articles

React Testing Guide: React Testing Library, Jest, Vitest, MSW, Playwright, and Code Coverage

Master React testing from unit to e2e. Covers React Testing Library queries, userEvent, renderHook, jest.mock(), Mock Service Worker (MSW), Vitest, async testing, snapshot tests, Redux/Zustand testing, Playwright vs Cypress, and code coverage with Istanbul.

CI/CD Pipeline Best Practices: GitHub Actions, Testning och Deploy

Bygg robusta CI/CD-pipelines med GitHub Actions — teststrategier och deploy-mönster.

Advanced TypeScript Guide: Generics, Conditional Types, Mapped Types, Decorators, and Type Narrowing

Master advanced TypeScript patterns. Covers generic constraints, conditional types with infer, mapped types (Partial/Pick/Omit), template literal types, discriminated unions, utility types deep dive, decorators, module augmentation, type narrowing, covariance/contravariance, and satisfies operator.