Skip to content
Learni
View all tutorials
Tests & Qualité

How to Implement Advanced Integration Tests in 2026

Lire en français

Introduction

In 2026, integration tests are essential for validating interactions between your backend components: REST API, database, and external services. Unlike isolated unit tests, they simulate real workflows, catching subtle bugs like transaction conflicts or database memory leaks. This advanced tutorial guides you through building a robust test suite for a users API using Node.js, Express, Prisma, and PostgreSQL in Docker.

We'll leverage Vitest for its speed and native TypeScript support, Supertest for HTTP assertions, and advanced techniques: transactional cleanup, parallel execution, Istanbul coverage, and CI/CD integration. By the end, your test pipeline will be production-ready, slashing regressions by 80% on average. Full project: users CRUD with DB validation. Estimated time: 30min for setup + tests.

Prerequisites

  • Node.js 20+ and pnpm 9+
  • Docker and Docker Compose v2+
  • Advanced knowledge of TypeScript, Express, and relational databases
  • Tools: VS Code with Prisma and Vitest extensions
  • Git for version control

Project Initialization and Dependencies

terminal
pnpm init
pnpm add express cors @types/express @types/cors @types/node
pnpm add -D typescript @types/node tsx prisma @prisma/client vitest supertest @vitest/coverage-istanbul @vitest/ui jsdom
pnpm tsx add prisma --save-dev
echo '{"compilerOptions": {"target": "ES2022", "module": "ESNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "moduleResolution": "bundler", "baseUrl": ".", "paths": {"@/*": ["src/*"]}}}' > tsconfig.json
mkdir -p src tests/integration

This script sets up a modern TypeScript project with pnpm for fast installs. Runtime deps handle the API (Express+CORS), Prisma for the ORM. Vitest+Supertest enable HTTP+DB tests, with coverage for advanced reports. tsx runs TS natively without building.

Docker Test Database Setup

To isolate tests, we'll provision a dedicated PostgreSQL instance via Docker Compose. This avoids conflicts with your dev/prod DB and mimics a realistic CI/CD environment.

docker-compose.test.yml

docker-compose.test.yml
version: '3.8'
services:
  postgres-test:
    image: postgres:16-alpine
    container_name: integration-tests-db
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: tester
      POSTGRES_PASSWORD: secretpass
    ports:
      - "5433:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U tester -d testdb"]
      interval: 5s
      timeout: 5s
      retries: 5

This Compose file exposes PostgreSQL on port 5433 with a healthcheck for CI compatibility. Alpine keeps the image size small. Run docker-compose -f docker-compose.test.yml up -d before tests and down afterward for automatic cleanup.

Prisma Schema for Users

prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id    Int     @id @default(autoincrement())
  name  String  @db.VarChar(255)
  email String  @unique @db.VarChar(255)

  @@map("users")
}

Minimal schema for users CRUD with unique/email constraints. @@map avoids table name conflicts. Generate the client with npx prisma generate after migration.

Migration and Test .env

Create the .env to point to the test DB: DATABASE_URL="postgresql://tester:secretpass@localhost:5433/testdb?schema=public". Then run npx prisma migrate dev --name init and npx prisma generate.

Exportable Express API Server

src/server.ts
import express, { Express, Request, Response } from 'express';
import cors from 'cors';
import { PrismaClient } from '@prisma/client';

const app: Express = express();
const prisma = new PrismaClient();

app.use(cors({ origin: '*' }));
app.use(express.json({ limit: '10mb' }));

app.post('/users', async (req: Request, res: Response) => {
  try {
    const { name, email } = req.body;
    if (!name || !email) return res.status(400).json({ error: 'Missing fields' });
    const user = await prisma.user.create({
      data: { name, email },
    });
    res.status(201).json(user);
  } catch (error) {
    res.status(400).json({ error: 'Email already exists' });
  }
});

app.get('/users/:id', async (req: Request, res: Response) => {
  try {
    const id = parseInt(req.params.id);
    const user = await prisma.user.findUnique({ where: { id } });
    if (!user) return res.status(404).json({ error: 'User not found' });
    res.json(user);
  } catch (error) {
    res.status(500).json({ error: 'Server error' });
  }
});

app.delete('/users/:id', async (req: Request, res: Response) => {
  try {
    const id = parseInt(req.params.id);
    await prisma.user.delete({ where: { id } });
    res.status(204).send();
  } catch (error) {
    res.status(404).json({ error: 'User not found' });
  }
});

export { app, prisma };

App exported without listen() for Supertest compatibility. Basic CRUD routes with validation, try/catch, and standard HTTP codes. Prisma injected globally; in production, use per-request providers for scalability.

Advanced Vitest Configuration

vitest.config.ts
import { defineConfig } from 'vitest/config';
import { coverageConfig } from './vitest.coverage';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    pool: 'threads',
    poolOptions: { threads: { singleThread: true } },
    include: ['tests/integration/**/*.{test,spec}.{ts,js}'],
    setupFiles: ['./tests/integration/setup.ts'],
    maxConcurrency: 5,
    minWorkers: 1,
  },
  plugins: [coverageConfig],
});

Config enables limited parallel tests (maxConcurrency=5 prevents DB overload). Globals true for test/expect. Threads pool boosts performance. Istanbul coverage activated via plugin.

Complete Integration Test Suite

Tests use beforeEach for cleanup via Prisma (no transactions for Postgres compatibility). Supertest simulates real HTTP requests. Assertions check status, body, and DB state.

users.integration.test.ts Test File

tests/integration/users.integration.test.ts
import { test, expect, beforeEach, afterEach, describe } from 'vitest';
import request from 'supertest';
import { app, prisma } from '../../src/server';

describe('Users Integration Tests', () => {
  beforeEach(async () => {
    await prisma.user.deleteMany({});
  });

  afterEach(async () => {
    await prisma.$disconnect();
  });

  test('POST /users crée un utilisateur valide', async () => {
    const newUser = { name: 'John Doe', email: 'john@example.com' };
    const res = await request(app.callback())
      .post('/users')
      .send(newUser)
      .expect(201);

    expect(res.body).toMatchObject(newUser);
    expect(res.body.id).toBeDefined();

    const dbUser = await prisma.user.findUnique({ where: { id: res.body.id } });
    expect(dbUser).not.toBeNull();
  });

  test('POST /users rejette email dupliqué', async () => {
    const user = { name: 'Jane', email: 'jane@example.com' };
    await request(app.callback()).post('/users').send(user);

    const res = await request(app.callback())
      .post('/users')
      .send(user)
      .expect(400);

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

  test('GET /users/:id retourne utilisateur', async () => {
    const created = await request(app.callback())
      .post('/users')
      .send({ name: 'Alice', email: 'alice@example.com' })
      .expect(201);

    const res = await request(app.callback())
      .get(`/users/${created.body.id}`)
      .expect(200);

    expect(res.body.email).toBe('alice@example.com');
  });

  test('GET /users/:id 404 si inexistant', async () => {
    await request(app.callback()).get('/users/999').expect(404);
  });

  test('DELETE /users/:id supprime utilisateur', async () => {
    const created = await request(app.callback())
      .post('/users')
      .send({ name: 'Bob', email: 'bob@example.com' })
      .expect(201);

    await request(app.callback()).delete(`/users/${created.body.id}`).expect(204);

    await request(app.callback()).get(`/users/${created.body.id}`).expect(404);
  });
});

Full suite: 5 tests covering happy paths + errors. app.callback() handles requests without a server. deleteMany cleanup ensures isolation. Post-HTTP DB assertions validate persistence. Run with pnpm vitest run.

package.json Scripts and Coverage Config

package.json
{
  "name": "integration-tests-advanced",
  "scripts": {
    "db:up": "docker-compose -f docker-compose.test.yml up -d",
    "db:down": "docker-compose -f docker-compose.test.yml down",
    "db:migrate": "prisma migrate dev --name test",
    "db:generate": "prisma generate",
    "dev": "tsx watch src/server.ts",
    "test:integration": "pnpm db:up && sleep 5 && pnpm db:migrate && pnpm db:generate && vitest run --coverage",
    "test:ui": "vitest --ui",
    "test:watch": "vitest"
  },
  "devDependencies": {
    "@prisma/client": "^5.14.0",
    "@types/cors": "^2.8.17",
    "@types/express": "^4.17.21",
    "@types/node": "^22.4.1",
    "@vitest/coverage-istanbul": "^2.0.4",
    "@vitest/ui": "^2.0.4",
    "jsdom": "^24.0.0",
    "prisma": "^5.14.0",
    "supertest": "^7.0.0",
    "tsx": "^4.7.2",
    "typescript": "^5.5.3",
    "vitest": "^2.0.4"
  },
  "dependencies": {
    "cors": "^2.8.5",
    "express": "^4.19.2"
  }
}

Scripts automate the workflow: pnpm test:integration starts DB+migration+tests+coverage. Add to CI (e.g., GitHub Actions) for parallel runs. Aim for >90% coverage.

Best Practices

  • Strict isolation: Always clean up before/after each test (deleteMany or $transaction).
  • Parallel safe: Limit concurrency to DB capacity; use Prisma pools.
  • Coverage thresholds: Set Vitest coverage: { thresholds: { lines: 90 } } for CI gates.
  • Mock externals: Use MSW for third-party APIs, keep real DB for integration.
  • Seed data: Leverage Prisma Studio or factories (e.g., Faker.js) for realistic datasets.

Common Pitfalls to Avoid

  • No cleanup: Flaky tests from persistent state; always reset DB.
  • Prisma connection leaks: Call $disconnect() in afterEach, or use per-test PrismaClient.
  • Port conflicts: Dedicated test DB port (5433); healthcheck before tests.
  • Unhandled async: Supertest uses promises; stick to async/await to avoid race conditions.

Next Steps

  • Integrate Playwright for E2E + integration testing.
  • Explore Vitest persistence for in-memory SQLite DB (faster).
  • CI/CD: GitHub Actions matrix for Node versions.
  • Resources: Vitest Docs, Prisma Testing.
Check out our advanced Learni training on testing.