{"schemaVersion":"1.0","type":"Article","types":["Article"],"slug":"integration-testing-your-node-js-api-the-setup-that-actually-catches-bugs-v5h5g","url":"https://zyvop.com/integration-testing-your-node-js-api-the-setup-that-actually-catches-bugs-v5h5g","title":"Integration Testing Your Node.js API: The Setup That Actually Catches Bugs","subtitle":"Vitest, Supertest, transaction-based test isolation, and the factory pattern that makes test data effortless","tldr":"A production-ready Node.js integration testing setup using Vitest and Supertest with real endpoint testing, transaction-based database isolation, typed test factories, authenticated request helpers, consistent error validation, and CI pipelines powered by a real Postgres container.","keywords":["Express integration tests","Vitest Supertest","integration test factories","Jest Supertest TypeScript","test database rollback","Node.js integration testing","Node.js API testing 2026"],"entities":["ZyVOP","Express integration tests","Vitest Supertest","integration test factories","Jest Supertest TypeScript","test database rollback","Node.js integration testing","Node.js API testing 2026"],"keyTakeaways":["Unit tests are fast and easy to write.","They are also very good at telling you your functions work in isolation while your API is broken in production.","The test that would have caught the bug is usually an integration test — one that hits a real endpoint, talks to a real database, and verifies the entire request/response cycle."],"headings":["Why Vitest Over Jest in 2026","Setup","The Test Database Setup","Building the Test App","Writing Integration Tests","Authentication Routes","Protected Routes","Test Factories","Running Tests in CI","What to Test and What Not To"],"outboundLinks":[],"contentText":"Unit tests are fast and easy to write. They are also very good at telling you your functions work in isolation while your API is broken in production. The test that would have caught the bug is usually an integration test — one that hits a real endpoint, talks to a real database, and verifies the entire request/response cycle. These are the tests that validate what unit tests cannot: routing logic, middleware execution order, request validation, authentication checks, error response shapes, and database interactions that only fail under real data constraints. This guide builds a production-grade integration test setup using Vitest and Supertest, with a real Postgres database, proper test isolation, and patterns that scale to a large test suite without becoming slow or brittle. Why Vitest Over Jest in 2026 Integration tests verify that multiple modules or services work together correctly. For most Node.js backends in 2026, Vitest is the better testing framework choice for new projects: Native ESM support without configuration gymnastics TypeScript support out of the box — no ts-jest or babel-jest Faster than Jest on cold starts due to Vite's dependency pre-bundling Jest-compatible API — describe, it, expect, vi.mock — minimal migration friction Watch mode that re-runs only affected tests If your project already uses Jest, the patterns here apply equally — swap vi for jest and vitest imports for @jest/globals. Setup npm install -D vitest supertest @types/supertest// vitest.config.ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, // No need to import describe/it/expect environment: 'node', setupFiles: ['./src/tests/setup.ts'], testTimeout: 30_000, // Integration tests can be slow pool: 'forks', // Separate process per test file — true isolation poolOptions: { forks: { singleFork: true }, // One process for all tests — shared DB connection }, coverage: { provider: 'v8', reporter: ['text', 'lcov'], exclude: ['**/node_modules/**', '**/tests/**'], }, }, });The Test Database Setup The most important decision in integration testing is how you manage test data. The wrong approach (sharing state between tests) produces flaky tests that pass in isolation and fail in a suite. The right approach: each test runs in a transaction that is rolled back at the end. No cleanup code, no seed/teardown overhead, complete isolation. // src/tests/setup.ts import { Pool } from 'pg'; import { beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; // Separate test database — never run tests against production const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL || 'postgresql://testuser:testpass@localhost:5432/testdb'; export const testPool = new Pool({ connectionString: TEST_DATABASE_URL }); // Shared client — holds the transaction open across the test let testClient: any; beforeAll(async () =&gt; { // Run migrations on the test database before any tests // Use your migration tool of choice await runMigrations(TEST_DATABASE_URL); }); beforeEach(async () =&gt; { testClient = await testPool.connect(); // Start a transaction — all queries in this test run inside it await testClient.query('BEGIN'); }); afterEach(async () =&gt; { // Rollback — undoes every INSERT/UPDATE/DELETE from the test await testClient.query('ROLLBACK'); testClient.release(); }); afterAll(async () =&gt; { await testPool.end(); }); // Export so tests can use the same transactional client export { testClient };For this to work, your app needs to use the test client during tests. Inject the database via a module or environment variable: // src/lib/db.ts import { Pool } from 'pg'; let _pool: Pool | null = null; export function getPool(): Pool { if (!_pool) { _pool = new Pool({ connectionString: process.env.DATABASE_URL }); } return _pool; } // Allow tests to inject a transactional client export function setPool(pool: Pool) { _pool = pool; }Building the Test App Supertest needs your Express app instance — not a running server. Separate your app definition from the server startup: // src/app.ts — just the Express app, no listen() import express from 'express'; import { requestLogger } from './middleware/requestLogger'; import { errorHandler } from './middleware/errorHandler'; import router from './routes'; const app = express(); app.use(express.json()); app.use(requestLogger); app.use('/api', router); app.use(errorHandler); export default app; // src/server.ts — starts the actual server import app from './app'; app.listen(3000);// src/tests/helpers/testApp.ts import supertest from 'supertest'; import app from '../../app'; import { setPool } from '../../lib/db'; import { testPool, testClient } from '../setup'; export function createTestApp() { // Inject the transactional test client setPool(testClient); return supertest(app); }Writing Integration Tests Authentication Routes // src/tests/integration/auth.test.ts import { describe, it, expect, beforeEach } from 'vitest'; import { createTestApp } from '../helpers/testApp'; import { createTestUser, createTestTenant } from '../helpers/factories'; describe('POST /api/auth/login', () =&gt; { let request: ReturnType&lt;typeof createTestApp&gt;; beforeEach(() =&gt; { request = createTestApp(); }); it('returns 200 and tokens with valid credentials', async () =&gt; { const { user, tenant } = await createTestUser({ email: 'test@example.com', password: 'ValidPassword123!', }); const response = await request .post('/api/auth/login') .send({ email: 'test@example.com', password: 'ValidPassword123!' }); expect(response.status).toBe(200); expect(response.body).toMatchObject({ accessToken: expect.any(String), user: { id: user.id, email: 'test@example.com', }, }); // Refresh token should be in httpOnly cookie, not body expect(response.body.refreshToken).toBeUndefined(); expect(response.headers['set-cookie']).toBeDefined(); }); it('returns 401 with wrong password', async () =&gt; { await createTestUser({ email: 'test@example.com', password: 'correct' }); const response = await request .post('/api/auth/login') .send({ email: 'test@example.com', password: 'wrong' }); expect(response.status).toBe(401); expect(response.body.error.code).toBe('AUTHENTICATION_ERROR'); }); it('returns 401 for non-existent email', async () =&gt; { const response = await request .post('/api/auth/login') .send({ email: 'nobody@example.com', password: 'anything' }); expect(response.status).toBe(401); // Same error as wrong password — do not reveal whether email exists expect(response.body.error.code).toBe('AUTHENTICATION_ERROR'); }); it('returns 400 when email is missing', async () =&gt; { const response = await request .post('/api/auth/login') .send({ password: 'ValidPassword123!' }); expect(response.status).toBe(400); expect(response.body.error.code).toBe('VALIDATION_ERROR'); }); it('rate limits after 5 failed attempts', async () =&gt; { await createTestUser({ email: 'test@example.com', password: 'correct' }); // Make 5 failed attempts for (let i = 0; i &lt; 5; i++) { await request .post('/api/auth/login') .send({ email: 'test@example.com', password: 'wrong' }); } // 6th attempt should be rate limited const response = await request .post('/api/auth/login') .send({ email: 'test@example.com', password: 'wrong' }); expect(response.status).toBe(429); }); });Protected Routes // src/tests/helpers/auth.ts import supertest from 'supertest'; import { createTestUser } from './factories'; import app from '../../app'; export async function getAuthenticatedRequest(overrides = {}) { const request = supertest(app); const { user, tenant } = await createTestUser(overrides); const loginResponse = await request .post('/api/auth/login') .send({ email: user.email, password: 'TestPassword123!' }); const { accessToken } = loginResponse.body; // Return a request builder pre-configured with auth header return { request, user, tenant, authHeader: `Bearer ${accessToken}`, get: (url: string) =&gt; request.get(url).set('Authorization', `Bearer ${accessToken}`), post: (url: string) =&gt; request.post(url).set('Authorization', `Bearer ${accessToken}`), put: (url: string) =&gt; request.put(url).set('Authorization', `Bearer ${accessToken}`), delete: (url: string) =&gt; request.delete(url).set('Authorization', `Bearer ${accessToken}`), }; }// src/tests/integration/orders.test.ts import { describe, it, expect } from 'vitest'; import { getAuthenticatedRequest } from '../helpers/auth'; import { createTestProduct } from '../helpers/factories'; describe('POST /api/orders', () =&gt; { it('creates an order and returns 201', async () =&gt; { const { post, user, tenant } = await getAuthenticatedRequest(); const product = await createTestProduct({ tenantId: tenant.id, stock: 10, price: 29.99 }); const response = await post('/api/orders').send({ items: [{ productId: product.id, quantity: 2 }], shippingAddress: { line1: '123 Main St', city: 'San Francisco', state: 'CA', postcode: '94105', country: 'US', }, }); expect(response.status).toBe(201); expect(response.body).toMatchObject({ id: expect.any(String), status: 'pending', total: '59.98', }); }); it('returns 422 when product is out of stock', async () =&gt; { const { post, tenant } = await getAuthenticatedRequest(); const product = await createTestProduct({ tenantId: tenant.id, stock: 0 }); const response = await post('/api/orders').send({ items: [{ productId: product.id, quantity: 1 }], }); expect(response.status).toBe(422); expect(response.body.error.code).toBe('UNPROCESSABLE'); }); it('returns 401 without auth token', async () =&gt; { const request = supertest(app); const response = await request.post('/api/orders').send({}); expect(response.status).toBe(401); }); });Test Factories // src/tests/helpers/factories.ts import bcrypt from 'bcryptjs'; import { testClient } from '../setup'; import { randomUUID } from 'crypto'; export async function createTestTenant(overrides = {}) { const tenant = { id: randomUUID(), slug: `test-${randomUUID().slice(0, 8)}`, name: 'Test Tenant', plan: 'pro', ...overrides, }; await testClient.query( `INSERT INTO tenants (id, slug, name, plan) VALUES ($1, $2, $3, $4)`, [tenant.id, tenant.slug, tenant.name, tenant.plan] ); return tenant; } export async function createTestUser(overrides: any = {}) { const tenant = overrides.tenant || await createTestTenant(); const passwordHash = await bcrypt.hash(overrides.password || 'TestPassword123!', 10); const user = { id: randomUUID(), tenantId: tenant.id, email: overrides.email || `user-${randomUUID().slice(0, 8)}@test.com`, fullName: overrides.fullName || 'Test User', role: overrides.role || 'member', }; await testClient.query( `INSERT INTO users (id, tenant_id, email, full_name, role, password_hash) VALUES ($1, $2, $3, $4, $5, $6)`, [user.id, user.tenantId, user.email, user.fullName, user.role, passwordHash] ); return { user, tenant }; } export async function createTestProduct(overrides: any = {}) { const product = { id: randomUUID(), tenantId: overrides.tenantId, name: overrides.name || 'Test Product', price: overrides.price || 9.99, stock: overrides.stock ?? 100, active: true, }; await testClient.query( `INSERT INTO products (id, tenant_id, name, price, stock, active) VALUES ($1, $2, $3, $4, $5, $6)`, [product.id, product.tenantId, product.name, product.price, product.stock, product.active] ); return product; }Running Tests in CI # .github/workflows/ci.yml (test job) services: postgres: image: postgres:16-alpine env: POSTGRES_USER: testuser POSTGRES_PASSWORD: testpass POSTGRES_DB: testdb ports: - 5432:5432 options: &gt;- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - name: Run tests run: npm test env: TEST_DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb NODE_ENV: test JWT_PRIVATE_KEY: ${{ secrets.TEST_JWT_PRIVATE_KEY }}What to Test and What Not To Testing depth should match business risk, not trend pressure. Unit tests do not validate routing, middleware, serialization, and error envelope behavior. Write integration tests for: Authentication flows end-to-end Authorization — verify users cannot access other tenants' data Request validation — confirm invalid inputs return correct error shapes Business logic with database side effects Error response shapes — confirm your error handler works as designed Rate limiting behavior Do not write integration tests for: Pure utility functions (test these as unit tests — faster) Third-party API calls (mock at the HTTP boundary with msw or nock) Things already covered by your ORM or framework The rule: if tests pass while real integration repeatedly fails, mocking is likely hiding boundary problems. Mock at external seams, not core behavior.","contentHash":"sha256:a1f2a2cd01ef3bdc6109b018cb6b13440b49917a2bacc00523c9dd83529f18d3","authorName":"ZyVOP","authorUrl":"https://zyvop.com/author/zyvop","authorSameAs":["https://zyvop.com","https://github.com/zyvop","https://x.com/zyvop1"],"category":null,"tags":["Express integration tests","Vitest Supertest","integration test factories","Jest Supertest TypeScript","test database rollback","Node.js integration testing","Node.js API testing 2026"],"audience":"Software engineers and developers building applications with Express integration tests","tone":"Practical and evidence-based engineering guidance","readingTimeMinutes":7,"wordCount":1633,"faqs":null,"primaryTopic":"Express integration tests","publishedAt":"2026-05-27T05:45:22.219Z","updatedAt":"2026-08-18T10:40:00.377Z","canonicalUrl":"https://zyvop.com/integration-testing-your-node-js-api-the-setup-that-actually-catches-bugs-v5h5g"}