๐Ÿงช

Professional Testing

๐Ÿ‘จโ€๐Ÿณ Chef

Your safety net for changes

Imagine youre a surgeon and you have to operate without anesthesia or monitors. Every cut is a risk, and you dont know if something went wrong until its too late. Programming without tests is exactly like that.

Tests are not for finding bugs. They are for giving you the confidence to make changes without fear of breaking everything.


The cost of bugs

Stage where bug is foundRelative cost
During development1x
During code review2x
During QA10x
In production100x
After losing customers1000x

The testing pyramid

         /\
        /E2E\           Few, slow, fragile
       /โ”€โ”€โ”€โ”€โ”€โ”€\         (5% of your tests)
      /Integration\     Balance of cost/value
     /โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\   (15% of your tests)
    /   Unit Tests   \  Many, fast, stable
   /โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\ (80% of your tests)
TypeWhat it testsSpeedFragility
UnitA single isolated functionmsVery stable
IntegrationMultiple modules togethersecondsModerate
E2EComplete flow as a userminutesFragile

Unit Testing with Vitest

Basic setup

npm install -D vitest @vitest/coverage-v8
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
    },
  },
});

AAA Pattern (Arrange, Act, Assert)

// src/utils/price.ts
export function calculateTotal(items: Item[], taxRate: number): number {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  return subtotal * (1 + taxRate);
}

// src/utils/price.test.ts
import { describe, it, expect } from 'vitest';
import { calculateTotal } from './price';

describe('calculateTotal', () => {
  it('calculates total with tax', () => {
    // Arrange - Prepare data
    const items = [
      { price: 100, quantity: 2 },
      { price: 50, quantity: 1 },
    ];
    const taxRate = 0.16;

    // Act - Execute function
    const result = calculateTotal(items, taxRate);

    // Assert - Verify result
    expect(result).toBe(290); // (200 + 50) * 1.16 = 290
  });

  it('returns 0 for empty cart', () => {
    expect(calculateTotal([], 0.16)).toBe(0);
  });

  it('handles 0 tax rate', () => {
    const items = [{ price: 100, quantity: 1 }];
    expect(calculateTotal(items, 0)).toBe(100);
  });
});

Mocking: When and how

// src/services/user.ts
import { db } from './database';
import { sendEmail } from './email';

export async function createUser(email: string, name: string) {
  const user = await db.user.create({ data: { email, name } });
  await sendEmail(email, 'Welcome!', `Hello ${name}`);
  return user;
}

// src/services/user.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createUser } from './user';
import { db } from './database';
import { sendEmail } from './email';

// Mock external modules
vi.mock('./database', () => ({
  db: {
    user: {
      create: vi.fn(),
    },
  },
}));

vi.mock('./email', () => ({
  sendEmail: vi.fn(),
}));

describe('createUser', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('creates user and sends email', async () => {
    // Arrange
    const mockUser = { id: 1, email: 'test@example.com', name: 'Test' };
    vi.mocked(db.user.create).mockResolvedValue(mockUser);
    vi.mocked(sendEmail).mockResolvedValue(undefined);

    // Act
    const result = await createUser('test@example.com', 'Test');

    // Assert
    expect(db.user.create).toHaveBeenCalledWith({
      data: { email: 'test@example.com', name: 'Test' },
    });
    expect(sendEmail).toHaveBeenCalledWith(
      'test@example.com',
      'Welcome!',
      'Hello Test'
    );
    expect(result).toEqual(mockUser);
  });
});

Integration Testing

API Testing with Supertest

// src/app.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from './app';
import { db } from './database';

describe('API /users', () => {
  beforeAll(async () => {
    // Setup: clean and populate test DB
    await db.user.deleteMany();
    await db.user.create({
      data: { email: 'existing@test.com', name: 'Existing' },
    });
  });

  afterAll(async () => {
    await db.$disconnect();
  });

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

    expect(response.body).toHaveLength(1);
    expect(response.body[0].email).toBe('existing@test.com');
  });

  it('POST /users creates new user', async () => {
    const response = await request(app)
      .post('/users')
      .send({ email: 'new@test.com', name: 'New User' })
      .expect(201);

    expect(response.body.email).toBe('new@test.com');
  });

  it('POST /users with duplicate email returns 400', async () => {
    const response = await request(app)
      .post('/users')
      .send({ email: 'existing@test.com', name: 'Duplicate' })
      .expect(400);

    expect(response.body.error).toContain('already exists');
  });
});

Testcontainers: Real databases

// src/db.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { PrismaClient } from '@prisma/client';

describe('Database Integration', () => {
  let container;
  let prisma;

  beforeAll(async () => {
    // Start real PostgreSQL container
    container = await new PostgreSqlContainer()
      .withDatabase('testdb')
      .start();

    // Connect Prisma to the container
    prisma = new PrismaClient({
      datasources: {
        db: { url: container.getConnectionUri() },
      },
    });

    // Run migrations
    await prisma.$executeRaw`CREATE TABLE users (id SERIAL, email TEXT, name TEXT)`;
  }, 60000); // Long timeout to start container

  afterAll(async () => {
    await prisma.$disconnect();
    await container.stop();
  });

  it('inserts and reads user from real PostgreSQL', async () => {
    await prisma.$executeRaw`INSERT INTO users (email, name) VALUES ('test@db.com', 'DB Test')`;

    const users = await prisma.$queryRaw`SELECT * FROM users WHERE email = 'test@db.com'`;

    expect(users).toHaveLength(1);
    expect(users[0].name).toBe('DB Test');
  });
});

E2E Testing with Playwright

Setup

npm init playwright@latest

Basic E2E test

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

test.describe('Login Flow', () => {
  test('user can log in', async ({ page }) => {
    // Navigate to the page
    await page.goto('/login');

    // Fill form
    await page.fill('[data-testid="email"]', 'user@example.com');
    await page.fill('[data-testid="password"]', 'password123');

    // Click submit
    await page.click('[data-testid="submit"]');

    // Verify redirect to dashboard
    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('h1')).toContainText('Welcome');
  });

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

    await page.fill('[data-testid="email"]', 'wrong@example.com');
    await page.fill('[data-testid="password"]', 'wrongpass');
    await page.click('[data-testid="submit"]');

    // Verify error message
    await expect(page.locator('[data-testid="error"]')).toBeVisible();
    await expect(page.locator('[data-testid="error"]')).toContainText('Invalid credentials');
  });
});

Page Object Pattern

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

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.locator('[data-testid="email"]');
    this.passwordInput = page.locator('[data-testid="password"]');
    this.submitButton = page.locator('[data-testid="submit"]');
    this.errorMessage = page.locator('[data-testid="error"]');
  }

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

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

// e2e/login.spec.ts (using Page Object)
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test('successful login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');

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

TDD: Test Driven Development

The Red-Green-Refactor cycle

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   RED    โ”‚ โ”€โ”€โ†’ โ”‚  GREEN   โ”‚ โ”€โ”€โ†’ โ”‚ REFACTOR โ”‚ โ”€โ”€โ”
โ”‚          โ”‚     โ”‚          โ”‚     โ”‚          โ”‚   โ”‚
โ”‚ Write    โ”‚     โ”‚ Write    โ”‚     โ”‚ Improve  โ”‚   โ”‚
โ”‚ a test   โ”‚     โ”‚ minimum  โ”‚     โ”‚ code     โ”‚   โ”‚
โ”‚ that     โ”‚     โ”‚ code to  โ”‚     โ”‚ without  โ”‚   โ”‚
โ”‚ fails    โ”‚     โ”‚ pass     โ”‚     โ”‚ breaking โ”‚   โ”‚
โ”‚          โ”‚     โ”‚          โ”‚     โ”‚ tests    โ”‚   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ”‚
     โ†‘                                           โ”‚
     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

TDD Example: Password validator

// Step 1: RED - Failing test
// password.test.ts
describe('validatePassword', () => {
  it('rejects password shorter than 8 characters', () => {
    expect(validatePassword('short')).toBe(false);
  });
});

// validatePassword doesnt exist yet โ†’ test fails โœ“

// Step 2: GREEN - Minimum code
// password.ts
export function validatePassword(password: string): boolean {
  return password.length >= 8;
}

// Test passes โœ“

// Step 3: RED - New requirement
it('rejects password without uppercase letters', () => {
  expect(validatePassword('lowercase123')).toBe(false);
});

// Test fails โœ“

// Step 4: GREEN
export function validatePassword(password: string): boolean {
  if (password.length < 8) return false;
  if (!/[A-Z]/.test(password)) return false;
  return true;
}

// Step 5: REFACTOR
export function validatePassword(password: string): boolean {
  const rules = [
    (p: string) => p.length >= 8,
    (p: string) => /[A-Z]/.test(p),
  ];
  return rules.every(rule => rule(password));
}

// Tests still pass โœ“

Coverage: Dont obsess

100% coverage is a trap

// This code has 100% coverage...
function add(a, b) {
  return a + b;
}

// With this test...
test('adds numbers', () => {
  expect(add(1, 2)).toBe(3);
});

// But what about add('1', '2')? โ†’ '12' (bug!)
// Coverage doesnt guarantee quality

Useful coverage vs vanity

Code typeRecommended coverage
Business logic80-90%
Utilities90%+
UI components60-70%
Glue code30-50%

Testing in CI/CD

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Run unit tests
        run: npm run test:unit

      - name: Run integration tests
        run: npm run test:integration
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/test

      - name: Run E2E tests
        run: npx playwright test

      - name: Upload coverage
        uses: codecov/codecov-action@v3

Testing checklist

  • Unit tests for business logic (80%+ coverage)
  • Integration tests for APIs and DB
  • E2E tests for critical user flows
  • Tests run in CI before merge
  • Mocks only for external dependencies
  • Tests are independent (dont depend on order)
  • Tests are deterministic (not flaky)

Practice

-> Fullstack Testing with Vitest + Playwright -> API Testing with Testcontainers


Resources