Integration testing Node.js with Testcontainers
By Flavio Copes
Write Node.js integration tests against a real PostgreSQL database with Testcontainers, including schema setup, isolation, cleanup, and CI.
A database mock can tell us that our code called a function.
It cannot tell us whether a SQL query actually works in PostgreSQL.
PostgreSQL has real types, constraints, transactions, extensions, and query behavior. The most reliable database integration test runs against PostgreSQL itself.
Testcontainers starts a temporary container for a test and gives our code its connection details.
In this tutorial we’ll test a small books repository against a real PostgreSQL database. Each test run gets a fresh database, and the container disappears at the end.
What you need
You need:
- a current Node.js version
- Docker or another supported container runtime
- Vitest
Create the project:
mkdir books-testcontainers
cd books-testcontainers
npm init -y
npm install pg
npm install --save-dev vitest @testcontainers/postgresql
Add "type": "module" and a test script to package.json:
{
"type": "module",
"scripts": {
"test": "vitest run"
}
}
Write the code we want to test
Create src/books.js:
export function createBooksRepository(client) {
return {
async create({ title, author }) {
const result = await client.query(
`
INSERT INTO books (title, author)
VALUES ($1, $2)
RETURNING id, title, author
`,
[title, author]
)
return result.rows[0]
},
async findByAuthor(author) {
const result = await client.query(
`
SELECT id, title, author
FROM books
WHERE author = $1
ORDER BY id
`,
[author]
)
return result.rows
},
}
}
The repository receives a database client. That small design choice makes the code easy to use with production and test connections.
The parameters $1 and $2 keep values separate from SQL. Never build a query by concatenating untrusted input.
Start PostgreSQL in the test
Create src/books.test.js:
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest'
import { PostgreSqlContainer } from '@testcontainers/postgresql'
import pg from 'pg'
import { createBooksRepository } from './books.js'
const { Client } = pg
let container
let client
let books
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:17-alpine').start()
client = new Client({
connectionString: container.getConnectionUri(),
})
await client.connect()
await client.query(`
CREATE TABLE books (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title TEXT NOT NULL,
author TEXT NOT NULL
)
`)
books = createBooksRepository(client)
}, 60_000)
afterAll(async () => {
await client?.end()
await container?.stop()
})
Testcontainers:
- asks the container runtime to start
postgres:17-alpine - waits until PostgreSQL is ready
- maps the container port to an available host port
- returns the real connection URI
Do not hardcode port 5432. The mapped host port is deliberately dynamic, which also lets separate test runs avoid colliding.
The longer timeout is useful on the first run because Docker may need to download the image.
Pin an explicit image version. latest can change underneath a test suite.
Write the first integration test
Add this below the hooks:
describe('books repository', () => {
beforeEach(async () => {
await client.query('TRUNCATE TABLE books RESTART IDENTITY')
})
test('creates and finds books by author', async () => {
await books.create({
title: 'The Hobbit',
author: 'J. R. R. Tolkien',
})
await books.create({
title: 'The Silmarillion',
author: 'J. R. R. Tolkien',
})
await books.create({
title: 'Dune',
author: 'Frank Herbert',
})
const result = await books.findByAuthor('J. R. R. Tolkien')
expect(result).toEqual([
{
id: 1,
title: 'The Hobbit',
author: 'J. R. R. Tolkien',
},
{
id: 2,
title: 'The Silmarillion',
author: 'J. R. R. Tolkien',
},
])
})
})
Run it:
npm test
This is a real integration test. Node sends real SQL over a real database connection.
If the SQL syntax, parameter types, constraint, or ordering is wrong, the test fails for the same reason production would fail.
Test database constraints
Constraints are part of application behavior.
Add this test:
test('rejects a book without an author', async () => {
await expect(
client.query(
`
INSERT INTO books (title, author)
VALUES ($1, $2)
`,
['The Hobbit', null]
)
).rejects.toMatchObject({
code: '23502',
})
})
23502 is PostgreSQL’s not-null violation code.
Checking the stable code is more robust than checking the complete English error message.
Use real migrations
Our inline CREATE TABLE statement will drift if production uses a separate migration system.
Run the same migrations in tests that you deploy in production:
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:17-alpine').start()
process.env.DATABASE_URL = container.getConnectionUri()
await runMigrations({
connectionString: process.env.DATABASE_URL,
})
client = new Client({
connectionString: process.env.DATABASE_URL,
})
await client.connect()
books = createBooksRepository(client)
})
This catches broken migration ordering, unsupported SQL, and assumptions hidden by an old developer database.
It also proves that an empty database can reach the current schema.
Choose an isolation strategy
Tests must not depend on execution order.
For a small suite, truncate changed tables before each test:
TRUNCATE TABLE books RESTART IDENTITY CASCADE
Other options are:
- start a transaction before each test and roll it back afterward
- create a separate schema or database for each test worker
- start one container per test file
- use container snapshots for large, expensive seed datasets
Transaction rollback is fast, but it does not fit tests that use several independent connections or deliberately commit.
One container per individual test gives excellent isolation but can become slow.
A good default is one container per test file, real migrations once, and deterministic cleanup before each test.
Test failures and cleanup
Always close the database client before stopping the container.
Optional chaining in afterAll() ensures cleanup also works when setup failed partway through:
afterAll(async () => {
await client?.end()
await container?.stop()
})
Testcontainers normally uses a resource reaper to clean up containers. Explicit cleanup still makes intent clear and releases resources promptly.
If a test is hanging, check:
- an unclosed database client or pool
- a worker that still holds a connection
- Docker not running
- a corporate proxy blocking image pulls
- a container image built for the wrong CPU architecture
Enable Testcontainers logging when you need to see runtime discovery, pulls, ports, and wait behavior.
Run in CI
On a normal GitHub-hosted Linux runner, Docker is already available. The test job can be ordinary:
name: test
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
You do not need a PostgreSQL service block. The test owns its dependency and chooses the image version.
On other CI platforms, verify that jobs can access a supported Docker-compatible runtime.
What should use Testcontainers?
Testcontainers is a good fit for boundaries where fake behavior often diverges from reality:
- PostgreSQL, MySQL, or MongoDB
- Redis scripts and expiration
- message brokers
- S3-compatible storage
- search engines
Do not replace every unit test with a container test.
Keep fast unit tests for pure business logic. Use a smaller integration suite to prove that your code and the real dependency agree.
The useful distinction is not “unit tests are good, integration tests are bad.” It is knowing which risk each test can actually catch.
A mock cannot validate PostgreSQL behavior. A temporary PostgreSQL container can, while still giving every test run a clean and reproducible environment.
Related posts about node: