$ my-app/
├── src/
│ ├── app.js # Express app
│ ├── db.js # DB connection pool
│ └── routes/
│ └── users.js # User routes
├── tests/
│ └── integration/
│ ├── setup.js # Test DB setup/teardown
│ └── users.test.js
├── package.json
└── jest.config.js
my-app/
├── src/
│ ├── app.js # Express app
│ ├── db.js # DB connection pool
│ └── routes/
│ └── users.js # User routes
├── tests/
│ └── integration/
│ ├── setup.js # Test DB setup/teardown
│ └── users.test.js
├── package.json
└── jest.config.js
my-app/
├── src/
│ ├── app.js # Express app
│ ├── db.js # DB connection pool
│ └── routes/
│ └── users.js # User routes
├── tests/
│ └── integration/
│ ├── setup.js # Test DB setup/teardown
│ └── users.test.js
├── package.json
└── jest.config.js
-weight: 500;">npm -weight: 500;">install express pg
-weight: 500;">npm -weight: 500;">install --save-dev jest supertest testcontainers @testcontainers/postgresql
-weight: 500;">npm -weight: 500;">install express pg
-weight: 500;">npm -weight: 500;">install --save-dev jest supertest testcontainers @testcontainers/postgresql
-weight: 500;">npm -weight: 500;">install express pg
-weight: 500;">npm -weight: 500;">install --save-dev jest supertest testcontainers @testcontainers/postgresql
const { Pool } = require('pg'); let pool; function getPool() { if (!pool) { pool = new Pool({ host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT || '5432'), database: process.env.DB_NAME || 'myapp', user: process.env.DB_USER || 'postgres', password: process.env.DB_PASSWORD || 'postgres', max: 10, idleTimeoutMillis: 30000, }); } return pool;
} async function closePool() { if (pool) { await pool.end(); pool = null; }
} module.exports = { getPool, closePool };
const { Pool } = require('pg'); let pool; function getPool() { if (!pool) { pool = new Pool({ host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT || '5432'), database: process.env.DB_NAME || 'myapp', user: process.env.DB_USER || 'postgres', password: process.env.DB_PASSWORD || 'postgres', max: 10, idleTimeoutMillis: 30000, }); } return pool;
} async function closePool() { if (pool) { await pool.end(); pool = null; }
} module.exports = { getPool, closePool };
const { Pool } = require('pg'); let pool; function getPool() { if (!pool) { pool = new Pool({ host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT || '5432'), database: process.env.DB_NAME || 'myapp', user: process.env.DB_USER || 'postgres', password: process.env.DB_PASSWORD || 'postgres', max: 10, idleTimeoutMillis: 30000, }); } return pool;
} async function closePool() { if (pool) { await pool.end(); pool = null; }
} module.exports = { getPool, closePool };
const express = require('express');
const { getPool } = require('../db'); const router = express.Router(); // GET /users — fetch all users
router.get('/', async (req, res) => { try { const pool = getPool(); const result = await pool.query( 'SELECT id, name, email, created_at FROM users ORDER BY created_at DESC' ); res.json(result.rows); } catch (err) { console.error('Error fetching users:', err.message); res.-weight: 500;">status(500).json({ error: 'Internal server error' }); }
}); // POST /users — create a user
router.post('/', async (req, res) => { const { name, email } = req.body; if (!name || !email) { return res.-weight: 500;">status(400).json({ error: 'name and email are required' }); } // Basic email format check const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { return res.-weight: 500;">status(400).json({ error: 'Invalid email format' }); } try { const pool = getPool(); const result = await pool.query( 'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email, created_at', [name, email] ); res.-weight: 500;">status(201).json(result.rows[0]); } catch (err) { // PostgreSQL unique constraint violation if (err.code === '23505') { return res.-weight: 500;">status(409).json({ error: 'Email already exists' }); } console.error('Error creating user:', err.message); res.-weight: 500;">status(500).json({ error: 'Internal server error' }); }
}); module.exports = router;
const express = require('express');
const { getPool } = require('../db'); const router = express.Router(); // GET /users — fetch all users
router.get('/', async (req, res) => { try { const pool = getPool(); const result = await pool.query( 'SELECT id, name, email, created_at FROM users ORDER BY created_at DESC' ); res.json(result.rows); } catch (err) { console.error('Error fetching users:', err.message); res.-weight: 500;">status(500).json({ error: 'Internal server error' }); }
}); // POST /users — create a user
router.post('/', async (req, res) => { const { name, email } = req.body; if (!name || !email) { return res.-weight: 500;">status(400).json({ error: 'name and email are required' }); } // Basic email format check const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { return res.-weight: 500;">status(400).json({ error: 'Invalid email format' }); } try { const pool = getPool(); const result = await pool.query( 'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email, created_at', [name, email] ); res.-weight: 500;">status(201).json(result.rows[0]); } catch (err) { // PostgreSQL unique constraint violation if (err.code === '23505') { return res.-weight: 500;">status(409).json({ error: 'Email already exists' }); } console.error('Error creating user:', err.message); res.-weight: 500;">status(500).json({ error: 'Internal server error' }); }
}); module.exports = router;
const express = require('express');
const { getPool } = require('../db'); const router = express.Router(); // GET /users — fetch all users
router.get('/', async (req, res) => { try { const pool = getPool(); const result = await pool.query( 'SELECT id, name, email, created_at FROM users ORDER BY created_at DESC' ); res.json(result.rows); } catch (err) { console.error('Error fetching users:', err.message); res.-weight: 500;">status(500).json({ error: 'Internal server error' }); }
}); // POST /users — create a user
router.post('/', async (req, res) => { const { name, email } = req.body; if (!name || !email) { return res.-weight: 500;">status(400).json({ error: 'name and email are required' }); } // Basic email format check const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { return res.-weight: 500;">status(400).json({ error: 'Invalid email format' }); } try { const pool = getPool(); const result = await pool.query( 'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email, created_at', [name, email] ); res.-weight: 500;">status(201).json(result.rows[0]); } catch (err) { // PostgreSQL unique constraint violation if (err.code === '23505') { return res.-weight: 500;">status(409).json({ error: 'Email already exists' }); } console.error('Error creating user:', err.message); res.-weight: 500;">status(500).json({ error: 'Internal server error' }); }
}); module.exports = router;
const express = require('express');
const usersRouter = require('./routes/users'); const app = express();
app.use(express.json());
app.use('/users', usersRouter); module.exports = app;
const express = require('express');
const usersRouter = require('./routes/users'); const app = express();
app.use(express.json());
app.use('/users', usersRouter); module.exports = app;
const express = require('express');
const usersRouter = require('./routes/users'); const app = express();
app.use(express.json());
app.use('/users', usersRouter); module.exports = app;
const { PostgreSqlContainer } = require('@testcontainers/postgresql');
const { Pool } = require('pg'); let container;
let pool; async function setupTestDatabase() { // Spin up a real PostgreSQL instance in Docker // Each test suite gets its own isolated database container = await new PostgreSqlContainer('postgres:15-alpine') .withDatabase('testdb') .withUsername('testuser') .withPassword('testpass') .-weight: 500;">start(); // Point the app to this container process.env.DB_HOST = container.getHost(); process.env.DB_PORT = String(container.getMappedPort(5432)); process.env.DB_NAME = container.getDatabase(); process.env.DB_USER = container.getUsername(); process.env.DB_PASSWORD = container.getPassword(); // Create a pool directly to run migrations pool = new Pool({ host: container.getHost(), port: container.getMappedPort(5432), database: container.getDatabase(), user: container.getUsername(), password: container.getPassword(), }); // Run schema — in production you'd use a migration tool like Flyway or node-pg-migrate await pool.query(` CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL UNIQUE, created_at TIMESTAMP DEFAULT NOW() ) `); return pool;
} async function teardownTestDatabase() { if (pool) await pool.end(); if (container) await container.-weight: 500;">stop();
} // Wipe all rows between tests — faster than dropping/recreating tables
async function clearDatabase() { await pool.query('TRUNCATE TABLE users RESTART IDENTITY CASCADE');
} module.exports = { setupTestDatabase, teardownTestDatabase, clearDatabase };
const { PostgreSqlContainer } = require('@testcontainers/postgresql');
const { Pool } = require('pg'); let container;
let pool; async function setupTestDatabase() { // Spin up a real PostgreSQL instance in Docker // Each test suite gets its own isolated database container = await new PostgreSqlContainer('postgres:15-alpine') .withDatabase('testdb') .withUsername('testuser') .withPassword('testpass') .-weight: 500;">start(); // Point the app to this container process.env.DB_HOST = container.getHost(); process.env.DB_PORT = String(container.getMappedPort(5432)); process.env.DB_NAME = container.getDatabase(); process.env.DB_USER = container.getUsername(); process.env.DB_PASSWORD = container.getPassword(); // Create a pool directly to run migrations pool = new Pool({ host: container.getHost(), port: container.getMappedPort(5432), database: container.getDatabase(), user: container.getUsername(), password: container.getPassword(), }); // Run schema — in production you'd use a migration tool like Flyway or node-pg-migrate await pool.query(` CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL UNIQUE, created_at TIMESTAMP DEFAULT NOW() ) `); return pool;
} async function teardownTestDatabase() { if (pool) await pool.end(); if (container) await container.-weight: 500;">stop();
} // Wipe all rows between tests — faster than dropping/recreating tables
async function clearDatabase() { await pool.query('TRUNCATE TABLE users RESTART IDENTITY CASCADE');
} module.exports = { setupTestDatabase, teardownTestDatabase, clearDatabase };
const { PostgreSqlContainer } = require('@testcontainers/postgresql');
const { Pool } = require('pg'); let container;
let pool; async function setupTestDatabase() { // Spin up a real PostgreSQL instance in Docker // Each test suite gets its own isolated database container = await new PostgreSqlContainer('postgres:15-alpine') .withDatabase('testdb') .withUsername('testuser') .withPassword('testpass') .-weight: 500;">start(); // Point the app to this container process.env.DB_HOST = container.getHost(); process.env.DB_PORT = String(container.getMappedPort(5432)); process.env.DB_NAME = container.getDatabase(); process.env.DB_USER = container.getUsername(); process.env.DB_PASSWORD = container.getPassword(); // Create a pool directly to run migrations pool = new Pool({ host: container.getHost(), port: container.getMappedPort(5432), database: container.getDatabase(), user: container.getUsername(), password: container.getPassword(), }); // Run schema — in production you'd use a migration tool like Flyway or node-pg-migrate await pool.query(` CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL UNIQUE, created_at TIMESTAMP DEFAULT NOW() ) `); return pool;
} async function teardownTestDatabase() { if (pool) await pool.end(); if (container) await container.-weight: 500;">stop();
} // Wipe all rows between tests — faster than dropping/recreating tables
async function clearDatabase() { await pool.query('TRUNCATE TABLE users RESTART IDENTITY CASCADE');
} module.exports = { setupTestDatabase, teardownTestDatabase, clearDatabase };
const request = require('supertest');
const app = require('../../src/app');
const { closePool } = require('../../src/db');
const { setupTestDatabase, teardownTestDatabase, clearDatabase,
} = require('./setup'); describe('Users API — Integration Tests', () => { // Runs once before all tests in this file beforeAll(async () => { await setupTestDatabase(); }, 60000); // 60s timeout — Docker pull can take a moment first run // Runs once after all tests complete afterAll(async () => { await closePool(); // Close the app's connection pool await teardownTestDatabase(); // Stop the Docker container }); // Runs before each individual test — wipes DB state beforeEach(async () => { await clearDatabase(); }); // ─── GET /users ────────────────────────────────────────────── describe('GET /users', () => { it('returns an empty array when no users exist', async () => { const res = await request(app).get('/users'); expect(res.-weight: 500;">status).toBe(200); expect(res.body).toEqual([]); }); it('returns all users ordered by created_at descending', async () => { // Seed two users directly into the DB await request(app) .post('/users') .send({ name: 'Alice', email: '[email protected]' }); await request(app) .post('/users') .send({ name: 'Bob', email: '[email protected]' }); const res = await request(app).get('/users'); expect(res.-weight: 500;">status).toBe(200); expect(res.body).toHaveLength(2); // Bob was created last, should appear first expect(res.body[0].name).toBe('Bob'); expect(res.body[1].name).toBe('Alice'); }); }); // ─── POST /users ───────────────────────────────────────────── describe('POST /users', () => { it('creates a user and returns 201 with the created record', async () => { const res = await request(app) .post('/users') .send({ name: 'Charlie', email: '[email protected]' }); expect(res.-weight: 500;">status).toBe(201); expect(res.body).toMatchObject({ id: expect.any(Number), name: 'Charlie', email: '[email protected]', created_at: expect.any(String), }); }); it('returns 400 when name is missing', async () => { const res = await request(app) .post('/users') .send({ email: '[email protected]' }); expect(res.-weight: 500;">status).toBe(400); expect(res.body.error).toMatch(/name/i); }); it('returns 400 when email format is invalid', async () => { const res = await request(app) .post('/users') .send({ name: 'Dave', email: 'not-an-email' }); expect(res.-weight: 500;">status).toBe(400); expect(res.body.error).toMatch(/email/i); }); it('returns 409 when email already exists — tests real DB unique constraint', async () => { // First insert succeeds await request(app) .post('/users') .send({ name: 'Eve', email: '[email protected]' }); // Second insert with same email hits PostgreSQL unique constraint const res = await request(app) .post('/users') .send({ name: 'Eve Again', email: '[email protected]' }); expect(res.-weight: 500;">status).toBe(409); expect(res.body.error).toMatch(/already exists/i); }); });
});
const request = require('supertest');
const app = require('../../src/app');
const { closePool } = require('../../src/db');
const { setupTestDatabase, teardownTestDatabase, clearDatabase,
} = require('./setup'); describe('Users API — Integration Tests', () => { // Runs once before all tests in this file beforeAll(async () => { await setupTestDatabase(); }, 60000); // 60s timeout — Docker pull can take a moment first run // Runs once after all tests complete afterAll(async () => { await closePool(); // Close the app's connection pool await teardownTestDatabase(); // Stop the Docker container }); // Runs before each individual test — wipes DB state beforeEach(async () => { await clearDatabase(); }); // ─── GET /users ────────────────────────────────────────────── describe('GET /users', () => { it('returns an empty array when no users exist', async () => { const res = await request(app).get('/users'); expect(res.-weight: 500;">status).toBe(200); expect(res.body).toEqual([]); }); it('returns all users ordered by created_at descending', async () => { // Seed two users directly into the DB await request(app) .post('/users') .send({ name: 'Alice', email: '[email protected]' }); await request(app) .post('/users') .send({ name: 'Bob', email: '[email protected]' }); const res = await request(app).get('/users'); expect(res.-weight: 500;">status).toBe(200); expect(res.body).toHaveLength(2); // Bob was created last, should appear first expect(res.body[0].name).toBe('Bob'); expect(res.body[1].name).toBe('Alice'); }); }); // ─── POST /users ───────────────────────────────────────────── describe('POST /users', () => { it('creates a user and returns 201 with the created record', async () => { const res = await request(app) .post('/users') .send({ name: 'Charlie', email: '[email protected]' }); expect(res.-weight: 500;">status).toBe(201); expect(res.body).toMatchObject({ id: expect.any(Number), name: 'Charlie', email: '[email protected]', created_at: expect.any(String), }); }); it('returns 400 when name is missing', async () => { const res = await request(app) .post('/users') .send({ email: '[email protected]' }); expect(res.-weight: 500;">status).toBe(400); expect(res.body.error).toMatch(/name/i); }); it('returns 400 when email format is invalid', async () => { const res = await request(app) .post('/users') .send({ name: 'Dave', email: 'not-an-email' }); expect(res.-weight: 500;">status).toBe(400); expect(res.body.error).toMatch(/email/i); }); it('returns 409 when email already exists — tests real DB unique constraint', async () => { // First insert succeeds await request(app) .post('/users') .send({ name: 'Eve', email: '[email protected]' }); // Second insert with same email hits PostgreSQL unique constraint const res = await request(app) .post('/users') .send({ name: 'Eve Again', email: '[email protected]' }); expect(res.-weight: 500;">status).toBe(409); expect(res.body.error).toMatch(/already exists/i); }); });
});
const request = require('supertest');
const app = require('../../src/app');
const { closePool } = require('../../src/db');
const { setupTestDatabase, teardownTestDatabase, clearDatabase,
} = require('./setup'); describe('Users API — Integration Tests', () => { // Runs once before all tests in this file beforeAll(async () => { await setupTestDatabase(); }, 60000); // 60s timeout — Docker pull can take a moment first run // Runs once after all tests complete afterAll(async () => { await closePool(); // Close the app's connection pool await teardownTestDatabase(); // Stop the Docker container }); // Runs before each individual test — wipes DB state beforeEach(async () => { await clearDatabase(); }); // ─── GET /users ────────────────────────────────────────────── describe('GET /users', () => { it('returns an empty array when no users exist', async () => { const res = await request(app).get('/users'); expect(res.-weight: 500;">status).toBe(200); expect(res.body).toEqual([]); }); it('returns all users ordered by created_at descending', async () => { // Seed two users directly into the DB await request(app) .post('/users') .send({ name: 'Alice', email: '[email protected]' }); await request(app) .post('/users') .send({ name: 'Bob', email: '[email protected]' }); const res = await request(app).get('/users'); expect(res.-weight: 500;">status).toBe(200); expect(res.body).toHaveLength(2); // Bob was created last, should appear first expect(res.body[0].name).toBe('Bob'); expect(res.body[1].name).toBe('Alice'); }); }); // ─── POST /users ───────────────────────────────────────────── describe('POST /users', () => { it('creates a user and returns 201 with the created record', async () => { const res = await request(app) .post('/users') .send({ name: 'Charlie', email: '[email protected]' }); expect(res.-weight: 500;">status).toBe(201); expect(res.body).toMatchObject({ id: expect.any(Number), name: 'Charlie', email: '[email protected]', created_at: expect.any(String), }); }); it('returns 400 when name is missing', async () => { const res = await request(app) .post('/users') .send({ email: '[email protected]' }); expect(res.-weight: 500;">status).toBe(400); expect(res.body.error).toMatch(/name/i); }); it('returns 400 when email format is invalid', async () => { const res = await request(app) .post('/users') .send({ name: 'Dave', email: 'not-an-email' }); expect(res.-weight: 500;">status).toBe(400); expect(res.body.error).toMatch(/email/i); }); it('returns 409 when email already exists — tests real DB unique constraint', async () => { // First insert succeeds await request(app) .post('/users') .send({ name: 'Eve', email: '[email protected]' }); // Second insert with same email hits PostgreSQL unique constraint const res = await request(app) .post('/users') .send({ name: 'Eve Again', email: '[email protected]' }); expect(res.-weight: 500;">status).toBe(409); expect(res.body.error).toMatch(/already exists/i); }); });
});
module.exports = { testEnvironment: 'node', testMatch: ['**/tests/integration/**/*.test.js'], testTimeout: 60000, // Docker container startup maxWorkers: 1, // Run test files sequentially — prevents port conflicts
};
module.exports = { testEnvironment: 'node', testMatch: ['**/tests/integration/**/*.test.js'], testTimeout: 60000, // Docker container startup maxWorkers: 1, // Run test files sequentially — prevents port conflicts
};
module.exports = { testEnvironment: 'node', testMatch: ['**/tests/integration/**/*.test.js'], testTimeout: 60000, // Docker container startup maxWorkers: 1, // Run test files sequentially — prevents port conflicts
};
{ "scripts": { "test:integration": "jest --config jest.config.js", "test:integration:watch": "jest --config jest.config.js --watch" }
}
{ "scripts": { "test:integration": "jest --config jest.config.js", "test:integration:watch": "jest --config jest.config.js --watch" }
}
{ "scripts": { "test:integration": "jest --config jest.config.js", "test:integration:watch": "jest --config jest.config.js --watch" }
}
-weight: 500;">npm run test:integration
-weight: 500;">npm run test:integration
-weight: 500;">npm run test:integration
PASS tests/integration/users.test.js Users API — Integration Tests GET /users ✓ returns an empty array when no users exist (48ms) ✓ returns all users ordered by created_at descending (61ms) POST /users ✓ creates a user and returns 201 with the created record (42ms) ✓ returns 400 when name is missing (12ms) ✓ returns 400 when email format is invalid (11ms) ✓ returns 409 when email already exists (39ms) Test Suites: 1 passed, 1 total
Tests: 6 passed, 6 total
Time: 8.3s
PASS tests/integration/users.test.js Users API — Integration Tests GET /users ✓ returns an empty array when no users exist (48ms) ✓ returns all users ordered by created_at descending (61ms) POST /users ✓ creates a user and returns 201 with the created record (42ms) ✓ returns 400 when name is missing (12ms) ✓ returns 400 when email format is invalid (11ms) ✓ returns 409 when email already exists (39ms) Test Suites: 1 passed, 1 total
Tests: 6 passed, 6 total
Time: 8.3s
PASS tests/integration/users.test.js Users API — Integration Tests GET /users ✓ returns an empty array when no users exist (48ms) ✓ returns all users ordered by created_at descending (61ms) POST /users ✓ creates a user and returns 201 with the created record (42ms) ✓ returns 400 when name is missing (12ms) ✓ returns 400 when email format is invalid (11ms) ✓ returns 409 when email already exists (39ms) Test Suites: 1 passed, 1 total
Tests: 6 passed, 6 total
Time: 8.3s
name: Integration Tests on: push: branches: [main, develop] pull_request: branches: [main] jobs: integration-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: '-weight: 500;">npm' - name: Install dependencies run: -weight: 500;">npm ci - name: Run integration tests run: -weight: 500;">npm run test:integration # No need to manually -weight: 500;">start Postgres — Testcontainers handles it
name: Integration Tests on: push: branches: [main, develop] pull_request: branches: [main] jobs: integration-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: '-weight: 500;">npm' - name: Install dependencies run: -weight: 500;">npm ci - name: Run integration tests run: -weight: 500;">npm run test:integration # No need to manually -weight: 500;">start Postgres — Testcontainers handles it
name: Integration Tests on: push: branches: [main, develop] pull_request: branches: [main] jobs: integration-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: '-weight: 500;">npm' - name: Install dependencies run: -weight: 500;">npm ci - name: Run integration tests run: -weight: 500;">npm run test:integration # No need to manually -weight: 500;">start Postgres — Testcontainers handles it - Node.js (Express API)
- PostgreSQL (via pg pool)
- Jest (test runner)
- Testcontainers (spins up a real Postgres Docker container per test suite)
- Supertest (HTTP assertion) - A real Express + PostgreSQL app
- Integration tests using Testcontainers (real Docker-based Postgres per suite)
- Proper setup/teardown with beforeAll, afterAll, beforeEach
- Fast state reset with TRUNCATE RESTART IDENTITY
- Proper connection pool cleanup to prevent hanging Jest processes
- A GitHub Actions CI config that works without any extra setup