Skip to content
Learni
View all tutorials
Tests logiciels

How to Implement Advanced Integration Tests in 2026

Lire en français

Introduction

Integration tests verify that your components (API, database, services) interact correctly, unlike isolated unit tests. In 2026, with microservices architectures and hybrid NoSQL/ProSQL databases, they're essential for catching subtle bugs like transaction leaks or race conditions. This advanced tutorial focuses on an Express API with Prisma (using SQLite for simplicity), tested via Jest and Supertest. You'll learn to use Prisma transactions for perfect test isolation, partial mocks for external dependencies, and assertions on headers/JSON. By the end, your tests will run in <500ms per suite with 100% critical coverage. Perfect for teams scaling to advanced CI/CD like GitHub Actions with parallel matrices.

Prerequisites

  • Node.js 20+ installed
  • Advanced knowledge of TypeScript, Express, and Prisma
  • Familiarity with Jest and async/await patterns
  • SQLite enabled (no external config required)

Project Initialization and Setup

setup.sh
mkdir api-tests-integration
cd api-tests-integration
npm init -y
npm install express prisma @prisma/client typescript ts-node @types/express @types/node
npm install -D jest supertest @types/supertest ts-jest @types/jest prisma
npx prisma init --datasource-provider sqlite
tsc --init
npm pkg set type=module

This script sets up a modern Node.js project with TypeScript, Express for the API, Prisma as the ORM, and Jest/Supertest for testing. The --datasource-provider sqlite flag configures Prisma for in-memory SQLite, perfect for fast tests without a persistent DB. Avoid npm install -g for CI reproducibility.

Prisma Schema with User Model

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

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

model User {
  id        Int      @id @default(autoincrement())
  name      String
  email     String   @unique
  createdAt DateTime @default(now())
}

model Account {
  id     String @id @default(cuid())
  userId Int
  user   User   @relation(fields: [userId], references: [id])
}

This schema defines a simple User model with a relation to Account to simulate complexity. The DB URL will be overridden in tests for :memory:. Use @unique on email to test real DB constraints—a common pitfall is forgetting cascade relations on delete.

Express Server with CRUD Routes

src/server.ts
import express from 'express';
import { PrismaClient } from '@prisma/client';

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

app.use(express.json());

app.get('/users', async (req, res) => {
  const users = await prisma.user.findMany({ include: { accounts: true } });
  res.json(users);
});

app.post('/users', async (req, res) => {
  try {
    const { name, email } = req.body;
    const user = await prisma.user.create({
      data: { name, email },
      include: { accounts: true }
    });
    res.status(201).json(user);
  } catch (error: any) {
    res.status(400).json({ error: error.message });
  }
});

app.delete('/users/:id', async (req, res) => {
  const { id } = req.params;
  await prisma.user.delete({ where: { id: parseInt(id) } });
  res.status(204).send();
});

const PORT = 3001;
app.listen(PORT, () => console.log(`Server on port ${PORT}`));

This server exposes basic CRUD routes with include to test Prisma relations. Errors are handled to simulate DB constraints. Pitfall: without try/catch, Prisma errors crash the process; always log in production but omit in tests to focus on isolation.

Jest Configuration for TypeScript Tests

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

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/tests/'],
  testMatch: ['**/*.test.ts'],
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 90,
      functions: 90,
      lines: 90,
      statements: 90
    }
  },
  setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
  maxWorkers: '50%'
};

export default config;

This config optimizes for TypeScript with strict coverage (90% min), parallel workers for speed, and global setup. setupFilesAfterEnv will handle Prisma. Pitfall: without roots, Jest scans everything; limit it for <1s cold starts.

Global Setup for Prisma in Tests

tests/setup.ts
import { PrismaClient } from '@prisma/client';

const globalSetups: Array<() => Promise<unknown>> = [];

beforeAll(async () => {
  globalSetups.forEach(setup => setup());
});

afterAll(async () => {
  await new Promise<void>((resolve) => setImmediate(resolve));
});

// Global Prisma pour transactions
globalThis.prisma = globalThis.prisma || new PrismaClient();

This setup prepares a global PrismaClient for shared transactions if needed, with setImmediate for async flushing. Use globalThis for cross-test access. Pitfall: without afterAll, connections leak; always close in CI.

GET /users Test with Transactional Isolation

tests/integration/users-get.test.ts
import request from 'supertest';
import { prisma } from '../../src/server';
import type { User } from '@prisma/client';

const app = (await import('../../src/server')).default;

describe('GET /users', () => {
  let txClient: any;

  beforeEach(async () => {
    txClient = await prisma.$transaction(async (tx) => {
      await tx.user.deleteMany({});
      const user1: User = await tx.user.create({ data: { name: 'Alice', email: 'alice@test.com' } });
      const user2: User = await tx.user.create({ data: { name: 'Bob', email: 'bob@test.com' } });
      return { tx, user1, user2 };
    });
  });

  afterEach(async () => {
    await txClient.tx.$rollback();
  });

  it('devrait retourner la liste des users avec relations', async () => {
    const res = await request(app).get('/users');
    expect(res.status).toBe(200);
    expect(res.body).toHaveLength(2);
    expect(res.body[0].name).toBe('Alice');
    expect(res.body[0].accounts).toEqual([]);
  });

  it('devrait avoir les bons headers', async () => {
    const res = await request(app).get('/users');
    expect(res.headers['content-type']).toMatch(/json/);
  });
});

This test uses $transaction + $rollback for perfect isolation: seed before, reset after, no pollution. Assertions on body, length, and headers. Pitfall: without explicit rollback, data persists; test with prisma.$disconnect() in afterAll for production-like behavior.

POST /users Test with Validation and Errors

tests/integration/users-post.test.ts
import request from 'supertest';
import { prisma } from '../../src/server';
import type { User } from '@prisma/client';

const app = (await import('../../src/server')).default;

describe('POST /users', () => {
  beforeAll(async () => {
    await prisma.user.deleteMany({});
  });

  it('devrait créer un user valide', async () => {
    const newUser = { name: 'Charlie', email: 'charlie@test.com' };
    const res = await request(app)
      .post('/users')
      .send(newUser)
      .expect(201);

    expect(res.body.email).toBe('charlie@test.com');
    expect(res.body.createdAt).toBeDefined();
  });

  it('devrait rejeter un email dupliqué', async () => {
    await request(app)
      .post('/users')
      .send({ name: 'Dup', email: 'charlie@test.com' })
      .expect(400);
  });
});

Tests creation + DB errors (unique constraint) with .expect(status). beforeAll cleans globally. Advanced: chain .send() + post-response assertions. Pitfall: order-dependent; use per-test transactions for independence.

DELETE Test with Partial External Mock

tests/integration/users-delete.test.ts
import request from 'supertest';
import { prisma } from '../../src/server';
const app = (await import('../../src/server')).default;

jest.mock('../../src/server', () => ({
  default: app
}));

describe('DELETE /users/:id', () => {
  let userId: number;

  beforeEach(async () => {
    const tx = await prisma.$transaction(async (tx) => {
      const user = await tx.user.create({ data: { name: 'DeleteMe', email: `del${Date.now()}@test.com` } });
      return user.id;
    });
    userId = tx;
  });

  afterEach(async () => {
    await prisma.$transaction(async (tx) => tx.user.deleteMany({}));
  });

  it('devrait supprimer un user existant', async () => {
    const res = await request(app).delete(`/users/${userId}`).expect(204);
    const users = await prisma.user.findMany();
    expect(users).toHaveLength(0);
  });
});

Partial mock of the server for dynamic import, tests delete with post-DB verification. Uses dynamic ID for uniqueness. Pitfall: without transactional afterEach, state persists; only mock if external (e.g., logger), not the SUT.

Best Practices

  • Transactional Isolation: Always use prisma.$transaction + rollback for zero shared state.
  • Targeted Coverage: Aim for 90% on critical branches (errors, edges), not 100% trivia.
  • Parallelism: maxWorkers: '50%' + DB :memory: for <100ms/test in CI.
  • Precise Assertions: Check headers, status, JSON schema with toMatchObject.
  • Global Cleanup: prisma.$disconnect() in afterAll to avoid connection leaks.

Common Errors to Avoid

  • Forgetting rollback: Tests mutate the DB globally, leading to flaky parallel runs.
  • Unhandled dynamic ports: Fix PORT=3001, mock process.env if needed.
  • Loose assertions: expect(res.body).toBeDefined() instead of toMatchObject({name: 'exact'}).
  • No transactional seeding: Manual creations pollute subsequent suites.

Next Steps

  • Integrate Testcontainers for real Postgres: Docker Docs.
  • Switch to Vitest for Vite-speed: Migration Guide.
  • CI/CD with coverage badges: GitHub Actions matrix.
Check out our Learni advanced testing courses.