๐Ÿงช

Fullstack Testing

๐Ÿ‘จโ€๐Ÿณ Chefโฑ๏ธ 60 minutes

๐Ÿ“‹ Suggested prerequisites

  • โ€ขNode.js
  • โ€ขBasic React
  • โ€ขUnderstanding of REST APIs

What you'll build

A fully tested fullstack application: a REST API with Express and a React frontend, with tests at every level of the testing pyramid. You'll write unit tests with Jest for business logic, integration tests with Supertest for endpoints, database tests with Testcontainers, React component tests with Testing Library, and end-to-end tests with Playwright. When finished, you'll have a complete test suite with coverage reporting that gives you confidence to make changes without breaking anything.


Step 1: Create the project

mkdir fullstack-testing && cd fullstack-testing
npm init -y

Install backend dependencies:

npm install express cors
npm install -D typescript @types/node @types/express ts-node nodemon

Step 2: Configure TypeScript

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "**/*.test.ts"]
}

Step 3: Create business logic

// src/services/userService.ts
export interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

export interface CreateUserDTO {
  name: string;
  email: string;
}

// Simulate an in-memory database
const users: Map<string, User> = new Map();

export function validateEmail(email: string): boolean {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
}

export function validateName(name: string): boolean {
  return name.length >= 2 && name.length <= 50;
}

export function createUser(data: CreateUserDTO): User {
  if (!validateEmail(data.email)) {
    throw new Error('Invalid email');
  }
  if (!validateName(data.name)) {
    throw new Error('Name must be between 2 and 50 characters');
  }

  // Check unique email
  for (const user of users.values()) {
    if (user.email === data.email) {
      throw new Error('Email already exists');
    }
  }

  const user: User = {
    id: crypto.randomUUID(),
    name: data.name.trim(),
    email: data.email.toLowerCase(),
    createdAt: new Date()
  };

  users.set(user.id, user);
  return user;
}

export function getUser(id: string): User | undefined {
  return users.get(id);
}

export function getAllUsers(): User[] {
  return Array.from(users.values());
}

export function deleteUser(id: string): boolean {
  return users.delete(id);
}

export function clearUsers(): void {
  users.clear();
}

Step 4: Create the REST API

// src/app.ts
import express from 'express';
import cors from 'cors';
import * as userService from './services/userService';

const app = express();

app.use(cors());
app.use(express.json());

// GET /api/users - List users
app.get('/api/users', (req, res) => {
  const users = userService.getAllUsers();
  res.json(users);
});

// GET /api/users/:id - Get user
app.get('/api/users/:id', (req, res) => {
  const user = userService.getUser(req.params.id);
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  res.json(user);
});

// POST /api/users - Create user
app.post('/api/users', (req, res) => {
  try {
    const user = userService.createUser(req.body);
    res.status(201).json(user);
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    res.status(400).json({ error: message });
  }
});

// DELETE /api/users/:id - Delete user
app.delete('/api/users/:id', (req, res) => {
  const deleted = userService.deleteUser(req.params.id);
  if (!deleted) {
    return res.status(404).json({ error: 'User not found' });
  }
  res.status(204).send();
});

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

export { app };
// src/server.ts
import { app } from './app';

const PORT = process.env.PORT || 3001;

app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});

Step 5: Unit tests with Jest

npm install -D jest @types/jest ts-jest
// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/*.test.ts'],
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/server.ts'
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  }
};
// src/services/userService.test.ts
import {
  validateEmail,
  validateName,
  createUser,
  getUser,
  getAllUsers,
  deleteUser,
  clearUsers
} from './userService';

describe('UserService', () => {
  beforeEach(() => {
    clearUsers();
  });

  describe('validateEmail', () => {
    it('accepts valid emails', () => {
      expect(validateEmail('test@example.com')).toBe(true);
      expect(validateEmail('user.name@domain.org')).toBe(true);
    });

    it('rejects invalid emails', () => {
      expect(validateEmail('invalid')).toBe(false);
      expect(validateEmail('no@domain')).toBe(false);
      expect(validateEmail('@nodomain.com')).toBe(false);
      expect(validateEmail('')).toBe(false);
    });
  });

  describe('validateName', () => {
    it('accepts valid names', () => {
      expect(validateName('John')).toBe(true);
      expect(validateName('Jane Mary Smith')).toBe(true);
    });

    it('rejects names too short', () => {
      expect(validateName('A')).toBe(false);
    });

    it('rejects names too long', () => {
      expect(validateName('A'.repeat(51))).toBe(false);
    });
  });

  describe('createUser', () => {
    it('creates user with valid data', () => {
      const user = createUser({
        name: 'John Doe',
        email: 'john@example.com'
      });

      expect(user.id).toBeDefined();
      expect(user.name).toBe('John Doe');
      expect(user.email).toBe('john@example.com');
      expect(user.createdAt).toBeInstanceOf(Date);
    });

    it('normalizes email to lowercase', () => {
      const user = createUser({
        name: 'Test User',
        email: 'TEST@EXAMPLE.COM'
      });

      expect(user.email).toBe('test@example.com');
    });

    it('throws error with invalid email', () => {
      expect(() => createUser({
        name: 'Test',
        email: 'invalid'
      })).toThrow('Invalid email');
    });

    it('throws error with invalid name', () => {
      expect(() => createUser({
        name: 'A',
        email: 'test@example.com'
      })).toThrow('Name must be between 2 and 50 characters');
    });

    it('throws error if email already exists', () => {
      createUser({ name: 'User 1', email: 'test@example.com' });

      expect(() => createUser({
        name: 'User 2',
        email: 'test@example.com'
      })).toThrow('Email already exists');
    });
  });

  describe('getUser', () => {
    it('returns existing user', () => {
      const created = createUser({
        name: 'Test',
        email: 'test@example.com'
      });

      const found = getUser(created.id);
      expect(found).toEqual(created);
    });

    it('returns undefined for non-existent user', () => {
      expect(getUser('nonexistent-id')).toBeUndefined();
    });
  });

  describe('getAllUsers', () => {
    it('returns empty array without users', () => {
      expect(getAllUsers()).toEqual([]);
    });

    it('returns all users', () => {
      createUser({ name: 'User 1', email: 'user1@example.com' });
      createUser({ name: 'User 2', email: 'user2@example.com' });

      const users = getAllUsers();
      expect(users).toHaveLength(2);
    });
  });

  describe('deleteUser', () => {
    it('deletes existing user', () => {
      const user = createUser({
        name: 'Test',
        email: 'test@example.com'
      });

      expect(deleteUser(user.id)).toBe(true);
      expect(getUser(user.id)).toBeUndefined();
    });

    it('returns false for non-existent user', () => {
      expect(deleteUser('nonexistent-id')).toBe(false);
    });
  });
});

Step 6: Integration tests with Supertest

npm install -D supertest @types/supertest
// src/app.test.ts
import request from 'supertest';
import { app } from './app';
import { clearUsers } from './services/userService';

describe('Users API', () => {
  beforeEach(() => {
    clearUsers();
  });

  describe('GET /health', () => {
    it('returns ok status', async () => {
      const res = await request(app).get('/health');

      expect(res.status).toBe(200);
      expect(res.body).toEqual({ status: 'ok' });
    });
  });

  describe('POST /api/users', () => {
    it('creates user with valid data', async () => {
      const res = await request(app)
        .post('/api/users')
        .send({ name: 'John Doe', email: 'john@example.com' });

      expect(res.status).toBe(201);
      expect(res.body).toMatchObject({
        name: 'John Doe',
        email: 'john@example.com'
      });
      expect(res.body.id).toBeDefined();
    });

    it('returns 400 with invalid email', async () => {
      const res = await request(app)
        .post('/api/users')
        .send({ name: 'Test', email: 'invalid' });

      expect(res.status).toBe(400);
      expect(res.body.error).toBe('Invalid email');
    });

    it('returns 400 for duplicate email', async () => {
      await request(app)
        .post('/api/users')
        .send({ name: 'User 1', email: 'test@example.com' });

      const res = await request(app)
        .post('/api/users')
        .send({ name: 'User 2', email: 'test@example.com' });

      expect(res.status).toBe(400);
      expect(res.body.error).toBe('Email already exists');
    });
  });

  describe('GET /api/users', () => {
    it('returns empty list initially', async () => {
      const res = await request(app).get('/api/users');

      expect(res.status).toBe(200);
      expect(res.body).toEqual([]);
    });

    it('returns created users', async () => {
      await request(app)
        .post('/api/users')
        .send({ name: 'User 1', email: 'user1@example.com' });
      await request(app)
        .post('/api/users')
        .send({ name: 'User 2', email: 'user2@example.com' });

      const res = await request(app).get('/api/users');

      expect(res.status).toBe(200);
      expect(res.body).toHaveLength(2);
    });
  });

  describe('GET /api/users/:id', () => {
    it('returns existing user', async () => {
      const createRes = await request(app)
        .post('/api/users')
        .send({ name: 'Test User', email: 'test@example.com' });

      const res = await request(app)
        .get(`/api/users/${createRes.body.id}`);

      expect(res.status).toBe(200);
      expect(res.body.name).toBe('Test User');
    });

    it('returns 404 for non-existent user', async () => {
      const res = await request(app)
        .get('/api/users/nonexistent-id');

      expect(res.status).toBe(404);
      expect(res.body.error).toBe('User not found');
    });
  });

  describe('DELETE /api/users/:id', () => {
    it('deletes existing user', async () => {
      const createRes = await request(app)
        .post('/api/users')
        .send({ name: 'Test', email: 'test@example.com' });

      const res = await request(app)
        .delete(`/api/users/${createRes.body.id}`);

      expect(res.status).toBe(204);

      // Verify it was deleted
      const getRes = await request(app)
        .get(`/api/users/${createRes.body.id}`);
      expect(getRes.status).toBe(404);
    });

    it('returns 404 for non-existent user', async () => {
      const res = await request(app)
        .delete('/api/users/nonexistent-id');

      expect(res.status).toBe(404);
    });
  });
});

Step 7: Database tests with Testcontainers

npm install -D testcontainers @testcontainers/postgresql pg @types/pg
// src/database/db.integration.test.ts
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';

describe('Database Integration', () => {
  let container: StartedPostgreSqlContainer;
  let pool: Pool;

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

    pool = new Pool({
      connectionString: container.getConnectionUri()
    });

    // Create table
    await pool.query(`
      CREATE TABLE users (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        name VARCHAR(50) NOT NULL,
        email VARCHAR(255) UNIQUE NOT NULL,
        created_at TIMESTAMP DEFAULT NOW()
      )
    `);
  }, 60000); // 60s timeout to download image

  afterAll(async () => {
    await pool.end();
    await container.stop();
  });

  beforeEach(async () => {
    await pool.query('DELETE FROM users');
  });

  it('inserts and retrieves user', async () => {
    const insertResult = await pool.query(
      'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
      ['John Doe', 'john@example.com']
    );

    expect(insertResult.rows[0].name).toBe('John Doe');
    expect(insertResult.rows[0].email).toBe('john@example.com');

    const selectResult = await pool.query(
      'SELECT * FROM users WHERE email = $1',
      ['john@example.com']
    );

    expect(selectResult.rows).toHaveLength(1);
  });

  it('rejects duplicate emails', async () => {
    await pool.query(
      'INSERT INTO users (name, email) VALUES ($1, $2)',
      ['User 1', 'test@example.com']
    );

    await expect(
      pool.query(
        'INSERT INTO users (name, email) VALUES ($1, $2)',
        ['User 2', 'test@example.com']
      )
    ).rejects.toThrow(/unique constraint/i);
  });

  it('generates UUID automatically', async () => {
    const result = await pool.query(
      'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id',
      ['Test', 'test@example.com']
    );

    expect(result.rows[0].id).toMatch(
      /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
    );
  });
});

Step 8: Create React component

Create the frontend in a separate folder:

mkdir -p client/src
cd client
npm init -y
npm install react react-dom
npm install -D vite @vitejs/plugin-react typescript @types/react @types/react-dom
// client/src/components/UserForm.tsx
import { useState } from 'react';

interface User {
  id: string;
  name: string;
  email: string;
}

interface Props {
  onUserCreated: (user: User) => void;
}

export function UserForm({ onUserCreated }: Props) {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError('');
    setLoading(true);

    try {
      const res = await fetch('http://localhost:3001/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name, email })
      });

      const data = await res.json();

      if (!res.ok) {
        throw new Error(data.error || 'Error creating user');
      }

      onUserCreated(data);
      setName('');
      setEmail('');
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error');
    } finally {
      setLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="user-form">
      <div>
        <label htmlFor="name">Name:</label>
        <input
          id="name"
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder="Your name"
          required
        />
      </div>
      <div>
        <label htmlFor="email">Email:</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder="you@email.com"
          required
        />
      </div>
      {error && <p className="error" role="alert">{error}</p>}
      <button type="submit" disabled={loading}>
        {loading ? 'Creating...' : 'Create User'}
      </button>
    </form>
  );
}

Step 9: Component tests with Testing Library

cd client
npm install -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
// client/vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
    globals: true
  }
});
// client/src/test/setup.ts
import '@testing-library/jest-dom';
// client/src/components/UserForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserForm } from './UserForm';

describe('UserForm', () => {
  const mockOnUserCreated = vi.fn();

  beforeEach(() => {
    vi.resetAllMocks();
    global.fetch = vi.fn();
  });

  it('renders the form correctly', () => {
    render(<UserForm onUserCreated={mockOnUserCreated} />);

    expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /create user/i })).toBeInTheDocument();
  });

  it('allows typing in fields', async () => {
    const user = userEvent.setup();
    render(<UserForm onUserCreated={mockOnUserCreated} />);

    await user.type(screen.getByLabelText(/name/i), 'John Doe');
    await user.type(screen.getByLabelText(/email/i), 'john@example.com');

    expect(screen.getByLabelText(/name/i)).toHaveValue('John Doe');
    expect(screen.getByLabelText(/email/i)).toHaveValue('john@example.com');
  });

  it('submits form and calls callback with created user', async () => {
    const user = userEvent.setup();
    const mockUser = { id: '123', name: 'John', email: 'john@example.com' };

    (global.fetch as any).mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser
    });

    render(<UserForm onUserCreated={mockOnUserCreated} />);

    await user.type(screen.getByLabelText(/name/i), 'John');
    await user.type(screen.getByLabelText(/email/i), 'john@example.com');
    await user.click(screen.getByRole('button', { name: /create user/i }));

    await waitFor(() => {
      expect(mockOnUserCreated).toHaveBeenCalledWith(mockUser);
    });

    // Verify fields were cleared
    expect(screen.getByLabelText(/name/i)).toHaveValue('');
    expect(screen.getByLabelText(/email/i)).toHaveValue('');
  });

  it('shows server error', async () => {
    const user = userEvent.setup();

    (global.fetch as any).mockResolvedValueOnce({
      ok: false,
      json: async () => ({ error: 'Email already exists' })
    });

    render(<UserForm onUserCreated={mockOnUserCreated} />);

    await user.type(screen.getByLabelText(/name/i), 'Test');
    await user.type(screen.getByLabelText(/email/i), 'test@example.com');
    await user.click(screen.getByRole('button'));

    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('Email already exists');
    });

    expect(mockOnUserCreated).not.toHaveBeenCalled();
  });

  it('disables button while loading', async () => {
    const user = userEvent.setup();

    let resolvePromise: any;
    (global.fetch as any).mockImplementationOnce(
      () => new Promise((resolve) => { resolvePromise = resolve; })
    );

    render(<UserForm onUserCreated={mockOnUserCreated} />);

    await user.type(screen.getByLabelText(/name/i), 'Test');
    await user.type(screen.getByLabelText(/email/i), 'test@example.com');
    await user.click(screen.getByRole('button'));

    expect(screen.getByRole('button')).toBeDisabled();
    expect(screen.getByRole('button')).toHaveTextContent('Creating...');

    // Resolve promise to cleanup
    resolvePromise({ ok: true, json: async () => ({ id: '1' }) });
  });
});

Step 10: E2E tests with Playwright

npm install -D @playwright/test
npx playwright install
// e2e/users.spec.ts
import { test, expect } from '@playwright/test';

test.describe('User Management', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:5173');
  });

  test('can create a new user', async ({ page }) => {
    // Fill form
    await page.getByLabel(/name/i).fill('John Doe');
    await page.getByLabel(/email/i).fill('john@example.com');

    // Submit
    await page.getByRole('button', { name: /create user/i }).click();

    // Verify it appears in the list
    await expect(page.getByText('John Doe')).toBeVisible();
    await expect(page.getByText('john@example.com')).toBeVisible();
  });

  test('shows error with duplicate email', async ({ page }) => {
    // Create first user
    await page.getByLabel(/name/i).fill('User 1');
    await page.getByLabel(/email/i).fill('duplicate@example.com');
    await page.getByRole('button', { name: /create user/i }).click();

    // Wait for creation
    await expect(page.getByText('User 1')).toBeVisible();

    // Try to create with same email
    await page.getByLabel(/name/i).fill('User 2');
    await page.getByLabel(/email/i).fill('duplicate@example.com');
    await page.getByRole('button', { name: /create user/i }).click();

    // Verify error
    await expect(page.getByRole('alert')).toContainText(/already exists/i);
  });

  test('validates required fields', async ({ page }) => {
    // Try to submit without data
    await page.getByRole('button', { name: /create user/i }).click();

    // Verify HTML5 validation
    const nameInput = page.getByLabel(/name/i);
    await expect(nameInput).toHaveAttribute('required');
  });

  test('can delete user', async ({ page }) => {
    // Create user
    await page.getByLabel(/name/i).fill('Delete Me');
    await page.getByLabel(/email/i).fill('delete@example.com');
    await page.getByRole('button', { name: /create user/i }).click();

    await expect(page.getByText('Delete Me')).toBeVisible();

    // Delete
    await page.getByRole('button', { name: /delete/i }).first().click();

    // Verify it disappeared
    await expect(page.getByText('Delete Me')).not.toBeVisible();
  });
});
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  retries: 2,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
  },
  webServer: [
    {
      command: 'npm run dev:server',
      port: 3001,
      reuseExistingServer: !process.env.CI,
    },
    {
      command: 'npm run dev:client',
      port: 5173,
      reuseExistingServer: !process.env.CI,
    },
  ],
});

Step 11: Configure test scripts

// package.json
{
  "scripts": {
    "dev:server": "nodemon --exec ts-node src/server.ts",
    "dev:client": "cd client && npm run dev",
    "build": "tsc",

    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",

    "test:integration": "jest --testPathPattern=integration",

    "test:client": "cd client && npm run test",

    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",

    "test:all": "npm run test:coverage && npm run test:client && npm run test:e2e"
  }
}

Step 12: Run and view coverage

# Unit + integration tests with coverage
npm run test:coverage

You'll see a report like this:

--------------------|---------|----------|---------|---------|
File                | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
All files           |   92.45 |    85.71 |   91.67 |   92.16 |
 services           |         |          |         |         |
  userService.ts    |   95.24 |    90.00 |   100.0 |   95.00 |
 app.ts             |   89.47 |    80.00 |   83.33 |   89.19 |
--------------------|---------|----------|---------|---------|

Test Suites: 3 passed, 3 total
Tests:       24 passed, 24 total
# E2E tests
npm run test:e2e
Running 4 tests using 2 workers
  4 passed (12.3s)

To open last HTML report run:
  npx playwright show-report

Testing pyramid

LevelToolWhat it testsSpeed
UnitJestIsolated logicFast
IntegrationSupertestHTTP endpointsMedium
DatabaseTestcontainersReal queriesSlow
ComponentTesting LibraryIsolated UIFast
E2EPlaywrightComplete flowsSlow

Next step

-> CI/CD with GitHub Actions to run your tests on every push