Skip to content
intermediate Phase 15 · Testing & Quality

Integration Testing

Test API endpoints, database operations, and component interactions with supertest and testing-library.

1h 15m
0 problems
Topic Progress 0%

API Integration Testing Setup

API Integration Testing Setup

Integration tests verify that multiple parts of your application work together correctly. Unlike unit tests that isolate individual functions, integration tests exercise real HTTP requests against your Express app with a live test database.

Express App Testing with Supertest

Supertest provides a fluent API for making HTTP assertions against your Express application without actually starting a server. It intercepts requests at the middleware level, making tests fast and reliable.

import request from 'supertest';
import app from '../src/app.js';
import { db } from '../src/config/database.js';

describe('Users API', () => {
  beforeAll(async () => {
    await db.migrate.latest();
    await db.seed.run();
  });

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

  beforeEach(async () => {
    await db.raw('TRUNCATE users, posts CASCADE');
    await db.seed.run();
  });

  describe('GET /api/users', () => {
    it('should return paginated users', async () => {
      const res = await request(app)
        .get('/api/users?page=1&limit=10')
        .expect(200);

      expect(res.body).toMatchObject({
        data: expect.arrayContaining([
          expect.objectContaining({ id: expect.any(String), name: expect.any(String) }),
        ]),
        meta: expect.objectContaining({ page: 1, limit: 10, total: expect.any(Number) }),
      });
    });

    it('should require authentication', async () => {
      await request(app)
        .get('/api/users')
        .expect(401);
    });

    it('should return users with valid token', async () => {
      const token = await getAuthToken();
      const res = await request(app)
        .get('/api/users')
        .set('Authorization', `Bearer ${token}`)
        .expect(200);

      expect(res.body.data.length).toBeGreaterThan(0);
    });
  });

  describe('POST /api/users', () => {
    it('should create a user with valid data', async () => {
      const token = await getAuthToken('admin');
      const res = await request(app)
        .post('/api/users')
        .set('Authorization', `Bearer ${token}`)
        .send({ name: 'New User', email: 'new@example.com', password: 'Pass123!' })
        .expect(201);

      expect(res.body).toMatchObject({
        name: 'New User',
        email: 'new@example.com',
        role: 'user',
      });
      expect(res.body.id).toBeDefined();
      expect(res.body.password).toBeUndefined();
    });

    it('should reject duplicate email', async () => {
      const token = await getAuthToken('admin');
      await request(app)
        .post('/api/users')
        .set('Authorization', `Bearer ${token}`)
        .send({ name: 'User', email: 'existing@example.com', password: 'Pass123!' })
        .expect(409);
    });

    it('should validate required fields', async () => {
      const token = await getAuthToken('admin');
      const res = await request(app)
        .post('/api/users')
        .set('Authorization', `Bearer ${token}`)
        .send({ name: '' })
        .expect(400);

      expect(res.body.errors).toBeDefined();
    });
  });
});

Auth Token Helper

async function getAuthToken(role = 'user') {
  const res = await request(app)
    .post('/api/auth/login')
    .send({ email: `${role}@test.com`, password: 'password123' });
  return res.body.accessToken;
}

Testing-Library for API Response Validation

import { screen } from '@testing-library/react';

// For API response shape validation without UI
function validateUserResponse(body) {
  expect(body).toHaveProperty('id');
  expect(body).toHaveProperty('name');
  expect(body).toHaveProperty('email');
  expect(body).not.toHaveProperty('password');
  expect(body).not.toHaveProperty('passwordHash');
}

Database Integration Tests

Database Integration Tests

Database integration tests verify that your data layer correctly interacts with a real database. These tests catch issues that unit tests with mocks would miss, such as constraint violations, transaction behavior, and query correctness.

Repository Testing

import { UserRepository } from '../src/repositories/users.js';
import { db } from '../src/config/database.js';

describe('UserRepository', () => {
  let repo;

  beforeAll(() => {
    repo = new UserRepository(db);
  });

  beforeEach(async () => {
    await db.raw('TRUNCATE users CASCADE');
  });

  afterAll(() => {
    db.destroy();
  });

  describe('create', () => {
    it('should create a user and return it', async () => {
      const user = await repo.create({
        name: 'Alice',
        email: 'alice@test.com',
        passwordHash: 'hashed-password',
        role: 'user',
      });

      expect(user).toMatchObject({
        id: expect.any(String),
        name: 'Alice',
        email: 'alice@test.com',
        role: 'user',
        createdAt: expect.any(Date),
      });
    });

    it('should enforce unique email constraint', async () => {
      await repo.create({ name: 'A', email: 'dup@test.com', passwordHash: 'x', role: 'user' });
      await expect(
        repo.create({ name: 'B', email: 'dup@test.com', passwordHash: 'y', role: 'user' })
      ).rejects.toThrow();
    });
  });

  describe('findById', () => {
    it('should return user when found', async () => {
      const created = await repo.create({ name: 'Bob', email: 'bob@test.com', passwordHash: 'x', role: 'user' });
      const found = await repo.findById(created.id);
      expect(found.name).toBe('Bob');
    });

    it('should return null when not found', async () => {
      const found = await repo.findById('non-existent-id');
      expect(found).toBeNull();
    });
  });

  describe('list with pagination', () => {
    beforeEach(async () => {
      const users = Array.from({ length: 25 }, (_, i) => ({
        name: `User ${i}`,
        email: `user${i}@test.com`,
        passwordHash: 'x',
        role: 'user',
      }));
      await db('users').insert(users);
    });

    it('should return paginated results', async () => {
      const result = await repo.findAll({ page: 1, limit: 10 });
      expect(result.data).toHaveLength(10);
      expect(result.meta.total).toBe(25);
      expect(result.meta.totalPages).toBe(3);
    });
  });
});

Transaction Testing

describe('transferCredits', () => {
  it('should transfer credits atomically', async () => {
    await db('users').insert([
      { id: 'u1', name: 'Alice', credits: 100 },
      { id: 'u2', name: 'Bob', credits: 50 },
    ]);

    await transferCredits('u1', 'u2', 30);

    const alice = await db('users').where('id', 'u1').first();
    const bob = await db('users').where('id', 'u2').first();
    expect(alice.credits).toBe(70);
    expect(bob.credits).toBe(80);
  });

  it('should rollback on insufficient credits', async () => {
    await db('users').insert([
      { id: 'u1', name: 'Alice', credits: 10 },
      { id: 'u2', name: 'Bob', credits: 50 },
    ]);

    await expect(transferCredits('u1', 'u2', 100)).rejects.toThrow('Insufficient credits');

    const alice = await db('users').where('id', 'u1').first();
    expect(alice.credits).toBe(10);
  });
});

Test Fixtures and Factories

Test Fixtures and Factories

Test fixtures provide consistent, predictable data for integration tests. Factories generate test data programmatically, allowing customization per test while maintaining sensible defaults. This eliminates brittle tests that depend on specific database state.

Factory Pattern

// factories/userFactory.js
let userCount = 0;

export function buildUser(overrides = {}) {
  userCount++;
  return {
    name: `Test User ${userCount}`,
    email: `user${userCount}@test.com`,
    passwordHash: 'hashed-password',
    role: 'user',
    credits: 100,
    ...overrides,
  };
}

export async function createUser(overrides = {}) {
  const data = buildUser(overrides);
  const [user] = await db('users').insert(data).returning('*');
  return user;
}

export async function createPost(userId, overrides = {}) {
  const slug = `test-post-${Date.now()}`;
  const data = {
    authorId: userId,
    title: 'Test Post',
    content: 'Test content that is long enough',
    slug,
    published: false,
    ...overrides,
  };
  const [post] = await db('posts').insert(data).returning('*');
  return post;
}

Usage in Tests

describe('Posts API', () => {
  let admin, user, post;

  beforeEach(async () => {
    admin = await createUser({ role: 'admin' });
    user = await createUser({ role: 'user' });
    post = await createPost(user.id, { title: 'My Post', published: true });
  });

  it('should allow author to edit own post', async () => {
    const token = jwt.sign({ sub: user.id, role: 'user' }, SECRET);
    await request(app)
      .patch(`/api/posts/${post.id}`)
      .set('Authorization', `Bearer ${token}`)
      .send({ title: 'Updated Title' })
      .expect(200);
  });

  it('should prevent non-author from editing', async () => {
    const otherUser = await createUser();
    const token = jwt.sign({ sub: otherUser.id, role: 'user' }, SECRET);
    await request(app)
      .patch(`/api/posts/${post.id}`)
      .set('Authorization', `Bearer ${token}`)
      .send({ title: 'Hacked!' })
      .expect(403);
  });
});

Database Seed File

// seeds/test-data.js
export async function seed(knex) {
  await knex('comments').del();
  await knex('posts').del();
  await knex('users').del();

  const [admin] = await knex('users').insert({
    name: 'Admin',
    email: 'admin@test.com',
    passwordHash: await hashPassword('admin123'),
    role: 'admin',
  }).returning('*');

  const [user] = await knex('users').insert({
    name: 'User',
    email: 'user@test.com',
    passwordHash: await hashPassword('user123'),
    role: 'user',
  }).returning('*');

  await knex('posts').insert([
    { authorId: admin.id, title: 'Admin Post', slug: 'admin-post', content: 'Admin content', published: true },
    { authorId: user.id, title: 'User Post', slug: 'user-post', content: 'User content', published: false },
  ]);
}

Quiz

1. What is the primary difference between unit tests and integration tests?

Question 1 options

2. Why should integration tests truncate the database in beforeEach instead of afterAll?

Question 2 options

3. What does supertest do that makes it useful for Express API testing?

Question 3 options

Flashcards

Question

What is the purpose of test fixtures in integration testing?

Answer

Test fixtures provide consistent, predictable data for tests. Factories generate test data programmatically with sensible defaults that can be overridden per test, eliminating fragile tests that depend on specific database state.

Question

How do you test database transaction rollback behavior?

Answer

Insert seed data, trigger an operation that should fail within a transaction, then verify the original data is unchanged. Use expect().rejects.toThrow() for async failures and check the database state after the failed operation.

Question

What is the advantage of using supertest over curl commands for API testing?

Answer

Supertest provides a programmatic, assertion-rich API that integrates with test frameworks like Jest. It does not require a running server, supports chaining requests, and can validate response status, headers, and body in a single fluent call.

Revision Notes

Key Takeaways

  • 1. Integration tests verify that API routes, middleware, database queries, and authentication work together as a system
  • 2. Use supertest to make HTTP requests against Express apps without starting a real server
  • 3. Always truncate and reseed the database before each test to ensure isolation and repeatability
  • 4. Factory functions with sensible defaults and overrides keep test data flexible and maintainable

Interview Tips

  • Explain the testing pyramid: many unit tests at the base, fewer integration tests in the middle, and minimal E2E tests at the top
  • Discuss how transaction rollback testing ensures data integrity in failure scenarios
  • Describe your approach to managing test database state and why TRUNCATE is preferred over DELETE for performance
  • Talk about how test fixtures and factories improve maintainability compared to hardcoded seed data

Cheat Sheet

Integration Testing Cheat Sheet

Supertest Setup:

import request from 'supertest';
import app from '../src/app.js';
const res = await request(app).get('/api/users').expect(200);

Database Lifecycle:

  • beforeAll: run migrations, seed base data
  • beforeEach: truncate tables, reseed
  • afterAll: destroy connection

Factory Pattern:

async function createUser(overrides = {}) {
  const data = { ...defaultFields, ...overrides };
  const [user] = await db('users').insert(data).returning('*');
  return user;
}

Transaction Test:

  1. Insert seed state
  2. Trigger failing operation
  3. Assert original state unchanged

Key Libraries: supertest, jest/vitest, knex (migrations/seeds), testing-library (response validation)