DevOpsintermediate

Testing Strategies (Unit, Integration, E2E)

Master software testing: unit tests with Jest/Vitest, integration tests, E2E with Cypress/Playwright, the test pyramid, coverage metrics, and flaky test prevention.

17 min read·Published May 6, 2026
devopstestingjestcypressplaywright

Why Testing Matters

Every developer has shipped a "quick fix" that broke something else. Testing prevents that. Automated tests act as a safety net -- they catch regressions before users do, give you confidence to refactor, and serve as living documentation of how your code should behave.

The question is never "should I test?" -- it's "what should I test, and how?"

The Test Pyramid

The test pyramid is the foundational model for how to distribute your testing effort.

            /\
           /  \
          / E2E\         Few:    Slow, expensive, high confidence
         /______\
        /        \
       /Integration\     Some:   Medium speed, medium confidence
      /______________\
     /                \
    /    Unit Tests     \  Many:   Fast, cheap, focused
   /____________________\
LevelQuantitySpeedCostScopeConfidence
UnitMany (70-80%)MillisecondsLowSingle function/componentLow-Medium
IntegrationSome (15-20%)SecondsMediumMultiple modules togetherMedium-High
E2EFew (5-10%)MinutesHighFull user workflowHigh

The pyramid shape means: write many unit tests, some integration tests, and few E2E tests. This gives you the best balance of speed, cost, and confidence.

Unit Testing

Unit tests verify that individual functions, modules, or components work correctly in isolation. They're the foundation of your test suite.

Jest Setup

# Install Jest
npm install -D jest @types/jest ts-jest

# jest.config.ts
// jest.config.ts
import type { Config } from 'jest';

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/*.test.ts', '**/*.spec.ts'],
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
    '!src/**/index.ts',
  ],
  coverageThresholds: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

export default config;

Vitest Setup (Faster Alternative)

Vitest is a Vite-native testing framework that's significantly faster than Jest, especially for Vite-based projects.

# Install Vitest
npm install -D vitest
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      thresholds: {
        lines: 80,
        branches: 80,
        functions: 80,
        statements: 80,
      },
    },
  },
});

Writing Unit Tests

Testing Pure Functions

// src/utils/calculator.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 calculateDiscount(price: number, percentage: number): number {
  if (percentage < 0 || percentage > 100) {
    throw new Error('Percentage must be between 0 and 100');
  }
  return price - (price * percentage) / 100;
}
// src/utils/calculator.test.ts
import { describe, it, expect } from 'vitest'; // or from '@jest/globals'
import { add, divide, calculateDiscount } from './calculator';

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('returns decimal results', () => {
    expect(divide(7, 2)).toBe(3.5);
  });

  it('throws on division by zero', () => {
    expect(() => divide(10, 0)).toThrow('Division by zero');
  });
});

describe('calculateDiscount', () => {
  it('applies 20% discount correctly', () => {
    expect(calculateDiscount(100, 20)).toBe(80);
  });

  it('returns full price for 0% discount', () => {
    expect(calculateDiscount(50, 0)).toBe(50);
  });

  it('returns 0 for 100% discount', () => {
    expect(calculateDiscount(100, 100)).toBe(0);
  });

  it('throws for negative percentage', () => {
    expect(() => calculateDiscount(100, -5)).toThrow('Percentage must be between 0 and 100');
  });

  it('throws for percentage over 100', () => {
    expect(() => calculateDiscount(100, 150)).toThrow('Percentage must be between 0 and 100');
  });
});

Testing Async Functions

// src/services/userService.ts
type User = {
  id: string;
  name: string;
  email: string;
};

type UserRepository = {
  findById: (id: string) => Promise<User | null>;
  save: (user: User) => Promise<User>;
};

export function createUserService(repo: UserRepository) {
  return {
    async getUser(id: string): Promise<User> {
      const user = await repo.findById(id);
      if (!user) throw new Error(`User ${id} not found`);
      return user;
    },

    async updateEmail(id: string, newEmail: string): Promise<User> {
      const user = await repo.findById(id);
      if (!user) throw new Error(`User ${id} not found`);
      return repo.save({ ...user, email: newEmail });
    },
  };
}
// src/services/userService.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createUserService } from './userService';

const mockUser = { id: '1', name: 'Alice', email: '[email protected]' };

function createMockRepo() {
  return {
    findById: vi.fn(),
    save: vi.fn(),
  };
}

describe('UserService', () => {
  describe('getUser', () => {
    it('returns user when found', async () => {
      const repo = createMockRepo();
      repo.findById.mockResolvedValue(mockUser);
      const service = createUserService(repo);

      const result = await service.getUser('1');

      expect(result).toEqual(mockUser);
      expect(repo.findById).toHaveBeenCalledWith('1');
    });

    it('throws when user not found', async () => {
      const repo = createMockRepo();
      repo.findById.mockResolvedValue(null);
      const service = createUserService(repo);

      await expect(service.getUser('999')).rejects.toThrow('User 999 not found');
    });
  });

  describe('updateEmail', () => {
    it('updates and saves user email', async () => {
      const repo = createMockRepo();
      repo.findById.mockResolvedValue(mockUser);
      repo.save.mockResolvedValue({ ...mockUser, email: '[email protected]' });
      const service = createUserService(repo);

      const result = await service.updateEmail('1', '[email protected]');

      expect(repo.save).toHaveBeenCalledWith({
        ...mockUser,
        email: '[email protected]',
      });
      expect(result.email).toBe('[email protected]');
    });
  });
});

Testing React Components

// src/components/Counter.tsx
import { useState } from 'react';

type CounterProps = {
  initialCount?: number;
  onCountChange?: (count: number) => void;
};

export function Counter({ initialCount = 0, onCountChange }: CounterProps) {
  const [count, setCount] = useState(initialCount);

  const increment = () => {
    const newCount = count + 1;
    setCount(newCount);
    onCountChange?.(newCount);
  };

  const decrement = () => {
    const newCount = count - 1;
    setCount(newCount);
    onCountChange?.(newCount);
  };

  return (
    <div>
      <span data-testid="count">{count}</span>
      <button onClick={decrement}>-</button>
      <button onClick={increment}>+</button>
    </div>
  );
}
// src/components/Counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Counter } from './Counter';

describe('Counter', () => {
  it('renders initial count of 0 by default', () => {
    render(<Counter />);
    expect(screen.getByTestId('count')).toHaveTextContent('0');
  });

  it('renders custom initial count', () => {
    render(<Counter initialCount={10} />);
    expect(screen.getByTestId('count')).toHaveTextContent('10');
  });

  it('increments count on + click', () => {
    render(<Counter />);
    fireEvent.click(screen.getByText('+'));
    expect(screen.getByTestId('count')).toHaveTextContent('1');
  });

  it('decrements count on - click', () => {
    render(<Counter initialCount={5} />);
    fireEvent.click(screen.getByText('-'));
    expect(screen.getByTestId('count')).toHaveTextContent('4');
  });

  it('calls onCountChange when count changes', () => {
    const handleChange = vi.fn();
    render(<Counter onCountChange={handleChange} />);

    fireEvent.click(screen.getByText('+'));

    expect(handleChange).toHaveBeenCalledWith(1);
  });
});

Mocking Strategies

Module Mocks

// Mock an entire module
vi.mock('./api', () => ({
  fetchUsers: vi.fn().mockResolvedValue([{ id: 1, name: 'Test' }]),
}));

// Mock a specific export
vi.mock('./config', () => ({
  ...vi.importActual('./config'),
  API_URL: 'http://test-api.com',
}));

Timer Mocks

describe('debounce', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('calls function after delay', () => {
    const fn = vi.fn();
    const debounced = debounce(fn, 300);

    debounced();
    expect(fn).not.toHaveBeenCalled();

    vi.advanceTimersByTime(300);
    expect(fn).toHaveBeenCalledTimes(1);
  });
});

Spy on Methods

it('logs errors to console', () => {
  const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

  processData(null);

  expect(consoleSpy).toHaveBeenCalledWith('Invalid data: null');
  consoleSpy.mockRestore();
});

Integration Testing

Integration tests verify that multiple units work together correctly. They test the boundaries between modules -- API routes, database queries, service interactions.

API Integration Test

// src/api/users.integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createApp } from '../app';
import { setupTestDb, teardownTestDb, seedTestData } from '../test/helpers';

let app: ReturnType<typeof createApp>;

beforeAll(async () => {
  await setupTestDb();
  await seedTestData();
  app = createApp();
});

afterAll(async () => {
  await teardownTestDb();
});

describe('GET /api/users', () => {
  it('returns list of users', async () => {
    const response = await request(app)
      .get('/api/users')
      .expect(200);

    expect(response.body).toHaveLength(3);
    expect(response.body[0]).toHaveProperty('id');
    expect(response.body[0]).toHaveProperty('name');
    expect(response.body[0]).toHaveProperty('email');
  });

  it('filters users by name', async () => {
    const response = await request(app)
      .get('/api/users?name=Alice')
      .expect(200);

    expect(response.body).toHaveLength(1);
    expect(response.body[0].name).toBe('Alice');
  });
});

describe('POST /api/users', () => {
  it('creates a new user', async () => {
    const newUser = { name: 'Dave', email: '[email protected]' };

    const response = await request(app)
      .post('/api/users')
      .send(newUser)
      .expect(201);

    expect(response.body.name).toBe('Dave');
    expect(response.body.email).toBe('[email protected]');
    expect(response.body).toHaveProperty('id');
  });

  it('returns 400 for invalid email', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ name: 'Test', email: 'not-an-email' })
      .expect(400);

    expect(response.body.error).toContain('email');
  });

  it('returns 409 for duplicate email', async () => {
    await request(app)
      .post('/api/users')
      .send({ name: 'Dup', email: '[email protected]' })
      .expect(409);
  });
});

describe('PUT /api/users/:id', () => {
  it('updates an existing user', async () => {
    const response = await request(app)
      .put('/api/users/1')
      .send({ name: 'Alice Updated' })
      .expect(200);

    expect(response.body.name).toBe('Alice Updated');
  });

  it('returns 404 for non-existent user', async () => {
    await request(app)
      .put('/api/users/9999')
      .send({ name: 'Nobody' })
      .expect(404);
  });
});

Database Integration Test

// src/repositories/userRepo.integration.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { db } from '../database';
import { UserRepository } from './userRepo';

const repo = new UserRepository(db);

beforeEach(async () => {
  // Reset the users table before each test
  await db.query('DELETE FROM users');
  await db.query(`
    INSERT INTO users (id, name, email) VALUES
    (1, 'Alice', '[email protected]'),
    (2, 'Bob', '[email protected]')
  `);
});

describe('UserRepository', () => {
  it('finds user by id', async () => {
    const user = await repo.findById(1);
    expect(user).toEqual({ id: 1, name: 'Alice', email: '[email protected]' });
  });

  it('returns null for non-existent user', async () => {
    const user = await repo.findById(999);
    expect(user).toBeNull();
  });

  it('finds users by email domain', async () => {
    const users = await repo.findByEmailDomain('test.com');
    expect(users).toHaveLength(2);
  });

  it('creates a new user with auto-generated id', async () => {
    const user = await repo.create({ name: 'Charlie', email: '[email protected]' });
    expect(user.id).toBeDefined();
    expect(user.name).toBe('Charlie');
  });
});

End-to-End (E2E) Testing

E2E tests simulate real user interactions with the full application -- browser, API, database, everything. They provide the highest confidence but are the slowest and most expensive to maintain.

Playwright Setup

Playwright is a modern E2E framework from Microsoft. It supports Chromium, Firefox, and WebKit.

# Install Playwright
npm install -D @playwright/test
npx playwright install
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  timeout: 30_000,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [['html'], ['list']],
  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: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
  ],
  webServer: {
    command: 'npm run dev',
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
});

Playwright E2E Tests

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

test.describe('Authentication', () => {
  test('user can sign up', async ({ page }) => {
    await page.goto('/signup');

    await page.fill('[name="name"]', 'Test User');
    await page.fill('[name="email"]', '[email protected]');
    await page.fill('[name="password"]', 'SecurePass123!');
    await page.click('button[type="submit"]');

    // Should redirect to dashboard
    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('h1')).toContainText('Welcome, Test User');
  });

  test('user can log in', async ({ page }) => {
    await page.goto('/login');

    await page.fill('[name="email"]', '[email protected]');
    await page.fill('[name="password"]', 'SecurePass123!');
    await page.click('button[type="submit"]');

    await expect(page).toHaveURL('/dashboard');
  });

  test('shows error for invalid credentials', async ({ page }) => {
    await page.goto('/login');

    await page.fill('[name="email"]', '[email protected]');
    await page.fill('[name="password"]', 'wrongpass');
    await page.click('button[type="submit"]');

    await expect(page.locator('.error-message')).toContainText('Invalid credentials');
    await expect(page).toHaveURL('/login');
  });

  test('user can log out', async ({ page }) => {
    // Login first
    await page.goto('/login');
    await page.fill('[name="email"]', '[email protected]');
    await page.fill('[name="password"]', 'SecurePass123!');
    await page.click('button[type="submit"]');
    await expect(page).toHaveURL('/dashboard');

    // Log out
    await page.click('[data-testid="logout-button"]');
    await expect(page).toHaveURL('/login');
  });
});
// e2e/shopping-cart.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Shopping Cart', () => {
  test.beforeEach(async ({ page }) => {
    // Login before each test
    await page.goto('/login');
    await page.fill('[name="email"]', '[email protected]');
    await page.fill('[name="password"]', 'TestPass123!');
    await page.click('button[type="submit"]');
    await expect(page).toHaveURL('/dashboard');
  });

  test('add item to cart and checkout', async ({ page }) => {
    // Browse products
    await page.goto('/products');
    await page.click('[data-testid="product-card"]:first-child button');

    // Verify cart badge
    const cartBadge = page.locator('[data-testid="cart-badge"]');
    await expect(cartBadge).toHaveText('1');

    // Go to cart
    await page.click('[data-testid="cart-icon"]');
    await expect(page).toHaveURL('/cart');

    // Verify item in cart
    const cartItem = page.locator('[data-testid="cart-item"]');
    await expect(cartItem).toHaveCount(1);

    // Proceed to checkout
    await page.click('[data-testid="checkout-button"]');
    await expect(page).toHaveURL('/checkout');
  });

  test('remove item from cart', async ({ page }) => {
    // Add item
    await page.goto('/products');
    await page.click('[data-testid="product-card"]:first-child button');

    // Go to cart and remove
    await page.goto('/cart');
    await page.click('[data-testid="remove-item"]');

    // Verify empty cart
    await expect(page.locator('[data-testid="empty-cart-message"]')).toBeVisible();
  });
});

Cypress Setup and Tests

Cypress is another popular E2E framework known for its developer experience.

# Install Cypress
npm install -D cypress
// cypress.config.js
const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    viewportWidth: 1280,
    viewportHeight: 720,
    video: false,
    screenshotOnRunFailure: true,
    retries: {
      runMode: 2,
      openMode: 0,
    },
  },
});
// cypress/e2e/search.cy.js
describe('Product Search', () => {
  beforeEach(() => {
    cy.visit('/products');
  });

  it('shows search results for valid query', () => {
    cy.get('[data-testid="search-input"]').type('laptop');
    cy.get('[data-testid="search-button"]').click();

    cy.get('[data-testid="product-card"]').should('have.length.greaterThan', 0);
    cy.get('[data-testid="product-card"]').first().should('contain', 'Laptop');
  });

  it('shows no results message for gibberish query', () => {
    cy.get('[data-testid="search-input"]').type('xyzabc123nonsense');
    cy.get('[data-testid="search-button"]').click();

    cy.get('[data-testid="no-results"]').should('be.visible');
    cy.get('[data-testid="product-card"]').should('have.length', 0);
  });

  it('filters by category', () => {
    cy.get('[data-testid="category-filter"]').select('Electronics');

    cy.get('[data-testid="product-card"]').each(($card) => {
      cy.wrap($card).find('.category-tag').should('contain', 'Electronics');
    });
  });
});

Playwright vs Cypress Comparison

FeaturePlaywrightCypress
LanguageTypeScript/JavaScriptJavaScript
Multi-BrowserChromium, Firefox, WebKitChromium, Firefox, WebKit
Multi-Tab SupportYesLimited
Parallel ExecutionBuilt-inVia CI parallelization
Auto-WaitYesYes
Network InterceptionYesYes
Mobile EmulationYesViewport only
iframesFull supportLimited
SpeedFasterSlightly slower
Developer ExperienceGoodExcellent
CommunityGrowing fastLarge, established

Test Coverage

Coverage measures how much of your code is exercised by tests. It's a useful metric but not a guarantee of quality.

Coverage Metrics

MetricWhat It Measures
Line CoveragePercentage of code lines executed
Branch CoveragePercentage of if/else branches taken
Function CoveragePercentage of functions called
Statement CoveragePercentage of statements executed

Generating Coverage Reports

# Jest
npx jest --coverage

# Vitest
npx vitest --coverage

# Output example:
# ----------|---------|----------|---------|---------|
# File      | % Stmts | % Branch | % Funcs | % Lines |
# ----------|---------|----------|---------|---------|
# All files |   87.5  |   75.0   |   90.0  |   87.5  |
#  calc.ts  |  100.0  |  100.0   |  100.0  |  100.0  |
#  user.ts  |   75.0  |   50.0   |   80.0  |   75.0  |
# ----------|---------|----------|---------|---------|

Coverage Guidelines

  • 80% line coverage is a reasonable target for most projects
  • 100% coverage does not mean bug-free -- you can hit every line without testing edge cases
  • Branch coverage matters more than line coverage -- uncovered branches hide bugs
  • Don't game the metric -- writing tests just to hit a number produces low-value tests
// 100% line coverage but misses the bug
function isAdult(age: number): boolean {
  return age >= 18;
}

// Test covers all lines but doesn't test boundary
it('returns true for adults', () => {
  expect(isAdult(25)).toBe(true);
});

// Better: test the boundary
it('returns true for exactly 18', () => {
  expect(isAdult(18)).toBe(true);
});

it('returns false for 17', () => {
  expect(isAdult(17)).toBe(false);
});

When to Test What

Knowing what to test at each level is more important than testing everything everywhere.

Test at the Unit Level

  • Pure functions (calculations, transformations, validations)
  • State management logic (reducers, store actions)
  • Utility functions
  • Custom hooks
  • Business rules

Test at the Integration Level

  • API endpoints (request/response)
  • Database queries (CRUD operations)
  • Service layer (multiple modules interacting)
  • Authentication/authorization flows
  • Third-party API integrations (with mocked external calls)

Test at the E2E Level

  • Critical user journeys (signup, login, checkout, payment)
  • Cross-page navigation flows
  • Form submissions with validation
  • Features involving multiple services
  • Accessibility (screen reader, keyboard navigation)

What NOT to Test

  • Third-party library internals (React, Lodash -- they have their own tests)
  • Trivial code (getters, setters with no logic)
  • Implementation details (internal state, private methods)
  • Generated code (GraphQL types, Prisma client)

Flaky Test Prevention

Flaky tests -- tests that pass sometimes and fail randomly -- destroy trust in the test suite. When tests are flaky, teams start ignoring failures, which defeats the purpose.

Common Causes and Fixes

1. Timing Issues

// Flaky: depends on timing
it('shows loading then data', async () => {
  render(<UserList />);
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  expect(screen.getByText('Alice')).toBeInTheDocument(); // might not be rendered yet
});

// Fixed: wait for the element
it('shows loading then data', async () => {
  render(<UserList />);
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeInTheDocument();
  });
});

2. Shared State Between Tests

// Flaky: tests share state
let counter = 0;

it('increments counter', () => {
  counter++;
  expect(counter).toBe(1);
});

it('increments counter again', () => {
  counter++; // counter might be 1 or 2 depending on test order
  expect(counter).toBe(2);
});

// Fixed: reset state before each test
beforeEach(() => {
  counter = 0;
});

3. Network Requests in Unit Tests

// Flaky: depends on real API
it('fetches users', async () => {
  const users = await fetchUsers(); // real HTTP call
  expect(users.length).toBeGreaterThan(0);
});

// Fixed: mock the API
it('fetches users', async () => {
  vi.spyOn(global, 'fetch').mockResolvedValue({
    ok: true,
    json: () => Promise.resolve([{ id: 1, name: 'Alice' }]),
  } as Response);

  const users = await fetchUsers();
  expect(users).toHaveLength(1);
});

4. Date/Time Dependence

// Flaky: depends on current time
it('shows greeting based on time', () => {
  const greeting = getGreeting();
  expect(greeting).toBe('Good morning'); // only passes before noon
});

// Fixed: inject the time
it('shows morning greeting before noon', () => {
  const morning = new Date('2026-01-15T09:00:00');
  const greeting = getGreeting(morning);
  expect(greeting).toBe('Good morning');
});

5. Random Data Without Seeds

// Flaky: random behavior
it('shuffles array', () => {
  const result = shuffle([1, 2, 3, 4, 5]);
  expect(result[0]).toBe(3); // might be any value
});

// Fixed: use a seeded random or test the invariant
it('shuffles array without losing elements', () => {
  const input = [1, 2, 3, 4, 5];
  const result = shuffle([...input]);
  expect(result).toHaveLength(5);
  expect(result.sort()).toEqual(input.sort());
});

Flaky Test Checklist

[ ] No real network calls in unit/integration tests
[ ] No shared mutable state between tests
[ ] No hard-coded delays (use waitFor, retries)
[ ] No date/time dependence without mocking
[ ] No test order dependence (each test sets up its own state)
[ ] No race conditions (proper async/await usage)
[ ] beforeEach/afterEach properly clean up resources
[ ] Database seeded fresh for each integration test

Test Organization

File Structure

src/
  components/
    Counter.tsx
    Counter.test.tsx        # Co-located unit test
  services/
    userService.ts
    userService.test.ts     # Co-located unit test
  utils/
    calculator.ts
    calculator.test.ts      # Co-located unit test

e2e/                        # E2E tests in separate directory
  auth.spec.ts
  shopping-cart.spec.ts
  search.spec.ts

test/                       # Shared test utilities
  helpers.ts
  fixtures/
    users.json
  mocks/
    api.ts

Naming Conventions

// Describe what the module does
describe('UserService', () => {
  // Describe the method
  describe('getUser', () => {
    // Describe the scenario with "it" or "test"
    it('returns user when found', () => { ... });
    it('throws when user not found', () => { ... });
    it('handles concurrent requests', () => { ... });
  });
});

Key Takeaways

  • Follow the test pyramid: many unit tests, some integration, few E2E
  • Unit tests are fast, cheap, and test logic in isolation -- they're your foundation
  • Integration tests verify modules work together -- API routes, database queries, service layers
  • E2E tests simulate real user flows -- reserve them for critical paths only
  • Coverage is a guide, not a goal -- 80% is good, but branch coverage matters more than line coverage
  • Flaky tests are worse than no tests -- fix them immediately or delete them
  • Mock external dependencies in unit tests, use real services in integration tests
  • Use Playwright for new projects (faster, multi-browser), Cypress if you value DX and have an existing setup
  • Co-locate test files with source code, separate E2E tests in their own directory
  • Test behavior, not implementation -- your tests should survive refactors

Found this helpful?

Support devsofus — help us keep creating free dev guides.

Related Articles