| name | integration-testing |
| description | Use when writing or improving integration tests that verify multiple components working together. Covers Testcontainers, ASP.NET WebApplicationFactory, Supertest, pytest with real databases, Spring Boot testing, test database patterns, and CI/Docker-in-Docker strategies.
USE FOR: integration tests, Testcontainers, WebApplicationFactory, testing with real databases, testing multi-component interactions, service integration tests
DO NOT USE FOR: isolated function tests (use unit-testing), full browser tests (use e2e-testing), service boundary verification (use contract-testing)
|
| license | MIT |
| metadata | {"displayName":"Integration Testing","author":"Tyler-R-Kendrick"} |
| compatibility | claude, copilot, cursor |
| references | [{"title":"Testcontainers Documentation","url":"https://testcontainers.com"},{"title":"Martin Fowler — Integration Testing","url":"https://martinfowler.com/bliki/IntegrationTest.html"},{"title":"ASP.NET Core Integration Testing (Microsoft)","url":"https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests"}] |
Integration Testing — Testing Multiple Units Working Together
Overview
Integration tests verify that multiple components work together correctly — services calling databases, HTTP clients hitting real APIs, message producers and consumers interacting. They provide the highest confidence-per-dollar in the Test Trophy model.
"Write tests. Not too many. Mostly integration." — Kent C. Dodds
When to Use Real Dependencies vs Mocks
| Use Real Dependencies | Use Mocks/Stubs |
|---|
| Database queries and transactions | Third-party APIs with rate limits |
| Message broker publish/subscribe | Payment gateways (use sandbox instead) |
| Cache read/write behavior | Email/SMS sending |
| File system operations | External auth providers |
| Service-to-service HTTP calls (in-process) | Services owned by other teams (use contract tests) |
Testcontainers
Testcontainers spins up real Docker containers for your test dependencies — databases, message brokers, caches, and more. Available for Java, .NET, Node.js, Python, and Go.
Node.js (Vitest + Testcontainers)
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';
import { UserRepository } from './user-repository';
describe('UserRepository', () => {
let container: StartedPostgreSqlContainer;
let pool: Pool;
let repo: UserRepository;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16-alpine')
.withDatabase('testdb')
.withUsername('test')
.withPassword('test')
.start();
pool = new Pool({ connectionString: container.getConnectionUri() });
await pool.query(`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
repo = (pool);
}, );
( () => {
pool.();
container.();
});
(, () => {
user = repo.({ : , : });
found = repo.(user.);
(found).(expect.({
: ,
: ,
}));
});
(, () => {
repo.({ : , : });
(
repo.({ : , : })
)..();
});
(, () => {
( i = ; i < ; i++) {
repo.({ : , : });
}
page1 = repo.({ : , : });
page2 = repo.({ : , : });
(page1).();
(page2.).();
});
});
C# (.NET + Testcontainers)
using Testcontainers.PostgreSql;
using Npgsql;
using Xunit;
public class UserRepositoryTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("testdb")
.WithUsername("test")
.WithPassword("test")
.Build();
private NpgsqlConnection _connection = null!;
private UserRepository _repo = null!;
public async Task InitializeAsync()
{
await _postgres.StartAsync();
_connection = new NpgsqlConnection(_postgres.GetConnectionString());
await _connection.OpenAsync();
await using var cmd = new NpgsqlCommand(
"CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE NOT NULL)",
_connection);
await cmd.ExecuteNonQueryAsync();
_repo = new UserRepository(_connection);
}
public async Task DisposeAsync()
{
await _connection.DisposeAsync();
await _postgres.DisposeAsync();
}
[Fact]
public async Task Create_ValidUser_PersistsToDatabase()
{
user = _repo.CreateAsync( CreateUserRequest(, ));
found = _repo.FindByIdAsync(user.Id);
Assert.NotNull(found);
Assert.Equal(, found.Name);
}
[]
{
_repo.CreateAsync( CreateUserRequest(, ));
Assert.ThrowsAsync<PostgresException>(
() => _repo.CreateAsync( CreateUserRequest(, )));
}
}
Java (JUnit 5 + Testcontainers)
import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.*;
@Testcontainers
class UserRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
private UserRepository repo;
private Connection connection;
@BeforeEach
void setUp() throws SQLException {
connection = DriverManager.getConnection(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword()
);
try (var stmt = connection.createStatement()) {
stmt.execute("""
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
)
""");
}
repo = new UserRepository(connection);
}
@AfterEach
void tearDown() throws SQLException {
try (var stmt = connection.createStatement()) {
stmt.execute();
}
connection.close();
}
{
repo.create(, );
repo.findById(user.getId());
assertNotNull(found);
assertEquals(, found.getName());
}
}
Python (pytest + Testcontainers)
import pytest
from testcontainers.postgres import PostgresContainer
import psycopg2
@pytest.fixture(scope="module")
def postgres():
with PostgresContainer("postgres:16-alpine") as postgres:
yield postgres
@pytest.fixture
def db_connection(postgres):
conn = psycopg2.connect(postgres.get_connection_url())
conn.autocommit = True
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
)
""")
yield conn
cur.execute("DROP TABLE IF EXISTS users")
conn.close()
@pytest.fixture
def user_repo(db_connection):
return UserRepository(db_connection)
def test_create_and_find_user(user_repo):
user = user_repo.create("Alice", "alice@example.com")
found = user_repo.find_by_id(user["id"])
assert found["name"] == "Alice"
assert found["email"] == "alice@example.com"
def test_duplicate_email_raises(user_repo):
user_repo.create("Bob", "bob@example.com")
with pytest.raises(psycopg2.IntegrityError):
user_repo.create(, )
Go (Testcontainers)
package repository_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
func TestUserRepository(t *testing.T) {
ctx := context.Background()
pgContainer, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2)),
)
require.NoError(t, err)
defer pgContainer.Terminate(ctx)
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
repo, err := NewUserRepository(connStr)
require.NoError(t, err)
t.Run("save and retrieve user", func(t *testing.T) {
user, err := repo.Create(ctx, "Alice", "alice@example.com")
require.NoError(t, err)
found, err := repo.FindByID(ctx, user.ID)
require.NoError(t, err)
assert.Equal(t, "Alice", found.Name)
})
}
ASP.NET WebApplicationFactory
WebApplicationFactory runs your entire ASP.NET application in-process without starting a real HTTP server, enabling fast integration tests against your API.
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql;
using System.Net.Http.Json;
using Xunit;
public class UsersApiTests : IClassFixture<CustomWebApplicationFactory>, IAsyncLifetime
{
private readonly HttpClient _client;
private readonly CustomWebApplicationFactory _factory;
public UsersApiTests(CustomWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateUser_ValidInput_Returns201()
{
var request = new { Name = "Alice", Email = "alice@example.com" };
var response = await _client.PostAsJsonAsync("/api/users", request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var user = await response.Content.ReadFromJsonAsync<UserDto>();
Assert.Equal("Alice", user!.Name);
}
[Fact]
public async Task GetUser_NotFound_Returns404()
{
response = _client.GetAsync();
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[]
{
request = { Name = , Email = };
_client.PostAsJsonAsync(, request);
response = _client.PostAsJsonAsync(, request);
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
}
}
: <>,
{
PostgreSqlContainer _postgres = PostgreSqlBuilder()
.WithImage()
.Build();
{
builder.ConfigureServices(services =>
{
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(_postgres.GetConnectionString()));
});
}
{
_postgres.StartAsync();
}
{
_postgres.DisposeAsync();
.DisposeAsync();
}
}
Supertest (Node.js)
Supertest provides HTTP assertions for Express, Koa, Fastify, and other Node.js frameworks.
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createApp } from './app';
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
describe('Users API', () => {
let container: StartedPostgreSqlContainer;
let app: Express.Application;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16-alpine').start();
app = await createApp({
databaseUrl: container.getConnectionUri(),
});
}, 60_000);
afterAll(async () => {
await container.stop();
});
it('POST /api/users should create a user', async () => {
const response = await request(app)
.post('/api/users')
.({ : , : })
.()
.(, );
(response.).({
: ,
: ,
});
(response..).();
});
(, () => {
(app)
.()
.();
});
(, () => {
response = (app)
.()
.({ : , : })
.();
(response..).();
});
});
pytest with Real Database Connections
import pytest
import httpx
from testcontainers.postgres import PostgresContainer
from myapp import create_app
@pytest.fixture(scope="session")
def postgres():
with PostgresContainer("postgres:16-alpine") as pg:
yield pg
@pytest.fixture(scope="session")
def app(postgres):
app = create_app(database_url=postgres.get_connection_url())
with app.app_context():
from myapp.models import db
db.create_all()
return app
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture(autouse=True)
def clean_db(app):
"""Roll back after each test for isolation."""
with app.app_context():
from myapp.models import db
yield
db.session.rollback()
def test_create_user(client):
response = client.post("/api/users", json={
"name": "Alice",
"email":
})
response.status_code ==
data = response.get_json()
data[] ==
():
response = client.get()
response.status_code ==
Spring Boot @SpringBootTest
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class UsersApiIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private TestRestTemplate restTemplate;
@Test
void createUser_validInput_returns201() {
var request = new (, );
restTemplate.postForEntity(, request, UserDto.class);
assertEquals(HttpStatus.CREATED, response.getStatusCode());
assertNotNull(response.getBody());
assertEquals(, response.getBody().getName());
}
{
restTemplate.getForEntity(, String.class);
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
}
}
Test Database Patterns
Per-Test Database
Each test gets its own database — maximum isolation, slower setup.
beforeEach(async () => {
const dbName = `test_${randomUUID().replace(/-/g, '')}`;
await adminPool.query(`CREATE DATABASE ${dbName}`);
testPool = new Pool({ connectionString: `${baseUrl}/${dbName}` });
await runMigrations(testPool);
});
afterEach(async () => {
await testPool.end();
await adminPool.query(`DROP DATABASE ${dbName}`);
});
Transaction Rollback
Each test runs inside a transaction that rolls back — fast, good isolation.
public class TransactionalTestBase : IAsyncLifetime
{
protected NpgsqlConnection Connection { get; private set; } = null!;
private NpgsqlTransaction _transaction = null!;
public async Task InitializeAsync()
{
Connection = new NpgsqlConnection(_connectionString);
await Connection.OpenAsync();
_transaction = await Connection.BeginTransactionAsync();
}
public async Task DisposeAsync()
{
await _transaction.RollbackAsync();
await Connection.DisposeAsync();
}
}
Database Snapshot / Template
Create a template database with seed data, clone it per test.
CREATE DATABASE test_template;
CREATE DATABASE test_run_42 TEMPLATE test_template;
Truncate Tables Between Tests
Fast reset, but tests must not depend on auto-increment IDs.
@pytest.fixture(autouse=True)
def clean_tables(db_connection):
yield
cur = db_connection.cursor()
cur.execute("""
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
""")
db_connection.commit()
CI / Docker-in-Docker Patterns for Testcontainers
GitHub Actions
name: Integration Tests
on: [push, pull_request]
jobs:
integration-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
- name: Run Integration Tests
run: npx vitest run --project integration
env:
TESTCONTAINERS_RYUK_DISABLED: "false"
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: test-results/
Azure DevOps
jobs:
- job: IntegrationTests
pool:
vmImage: "ubuntu-latest"
steps:
- task: DockerInstaller@0
inputs:
dockerVersion: "24.0"
- script: dotnet test --filter "Category=Integration" --logger "trx"
displayName: "Run Integration Tests"
env:
TESTCONTAINERS_RYUK_DISABLED: "false"
GitLab CI
integration-tests:
stage: test
image: node:22
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2375
TESTCONTAINERS_HOST_OVERRIDE: docker
script:
- npm ci
- npm run test:integration
Testcontainers Configuration
# .testcontainers.properties (in project root or ~/.testcontainers.properties)
ryuk.container.timeout=60
ryuk.container.privileged=false
testcontainers.reuse.enable=true
const container = await new PostgreSqlContainer('postgres:16-alpine')
.withReuse()
.start();
Cross-Platform Tool Summary
| Tool | Language | Use Case |
|---|
| Testcontainers | Java, .NET, Node, Python, Go | Docker-based test dependencies |
| WebApplicationFactory | C# / ASP.NET | In-process API testing |
| Supertest | Node.js | HTTP assertions for Express/Koa/Fastify |
| pytest + testcontainers | Python | Python integration tests with Docker |
| @SpringBootTest | Java / Spring | Full Spring context integration tests |
| TestRestTemplate | Java / Spring | HTTP client for Spring Boot tests |
| httpx / requests | Python | HTTP client for API tests |
| testify | Go | Assertions and test suites |
Best Practices
- Use Testcontainers for databases, message brokers, and caches — never rely on shared test infrastructure.
- Prefer transaction rollback for test isolation — it is faster than creating/dropping databases per test.
- Run integration tests on every PR, not just nightly — they catch the most impactful bugs.
- Keep integration tests focused — test one integration point per test, not full workflows.
- Use WebApplicationFactory or Supertest for API integration tests — they are faster than E2E tools.
- Seed test data in fixtures/setup, not in shared SQL scripts — each test should control its own data.
- Configure Testcontainers with
withReuse() locally for faster development cycles.
- Set reasonable timeouts for container startup in CI (60 seconds or more).
- Use
scope="module" or IClassFixture to share expensive containers across tests in the same class/module.
- Do not test third-party API behavior — mock external services and use contract tests for boundaries.
- Clean up test data between tests to prevent ordering dependencies and flaky failures.
- Pin container image versions (e.g.,
postgres:16-alpine) to avoid surprise behavior changes.