Skip to main content

Integration Testing

Unit tests verify individual pieces in isolation. Integration tests verify that those pieces work correctly together. The word "together" is the key: an integration test deliberately allows some real infrastructure — a database, an HTTP server, a message queue — to participate in the test.

This chapter covers the two most common integration test targets: database interactions and HTTP endpoints, for both Node.js/TypeScript (using Supertest) and Java/Spring (using MockMvc and Spring Boot Test), plus Testcontainers for running real databases in Docker.

What Qualifies as an Integration Test?

A test is an integration test when:

  • It exercises more than one component working together
  • It involves infrastructure (real or in-memory) such as a database, file system, or HTTP layer
  • Setup and teardown are more involved than constructing a single object

Integration tests are slower than unit tests but catch a class of bugs that unit tests miss:

  • SQL queries that are syntactically correct but semantically wrong
  • ORM mapping mismatches between your entity and the database schema
  • HTTP status codes and response shapes that differ from what the controller claims to return
  • Transaction rollback behaviour under error conditions

Integration Testing in Node.js with Supertest

Supertest lets you make real HTTP requests to your Express/Fastify/Koa application without starting a server on a port.

Setup

npm install --save-dev supertest @types/supertest

The Application Under Test

// src/app.ts
import express from 'express';

export const app = express();
app.use(express.json());

const users: { id: number; name: string; email: string }[] = [];
let nextId = 1;

app.get('/users/:id', (req, res) => {
const user = users.find(u => u.id === Number(req.params.id));
if (!user) return res.status(404).json({ error: 'User not found' });
return res.json(user);
});

app.post('/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'name and email are required' });
}
const user = { id: nextId++, name, email };
users.push(user);
return res.status(201).json(user);
});

Writing the Integration Tests

// src/app.test.ts
import request from 'supertest';
import { app } from './app';

describe('User API', () => {

describe('POST /users', () => {
it('creates a user and returns 201 with the new resource', async () => {
const response = await request(app)
.post('/users')
.send({ name: 'Alice', email: 'alice@example.com' })
.expect(201)
.expect('Content-Type', /json/);

expect(response.body).toMatchObject({
id: expect.any(Number),
name: 'Alice',
email: 'alice@example.com',
});
});

it('returns 400 when name is missing', async () => {
const response = await request(app)
.post('/users')
.send({ email: 'alice@example.com' })
.expect(400);

expect(response.body.error).toBe('name and email are required');
});

it('returns 400 when body is empty', async () => {
await request(app).post('/users').send({}).expect(400);
});
});

describe('GET /users/:id', () => {
let createdId: number;

beforeEach(async () => {
const res = await request(app)
.post('/users')
.send({ name: 'Bob', email: 'bob@example.com' });
createdId = res.body.id;
});

it('returns the user when found', async () => {
const response = await request(app)
.get(`/users/${createdId}`)
.expect(200);

expect(response.body.name).toBe('Bob');
});

it('returns 404 for a non-existent user', async () => {
await request(app).get('/users/99999').expect(404);
});
});
});

Adding a Real Database

For real persistence testing, connect to SQLite in-memory (or Postgres via Testcontainers — see below):

// src/db.ts
import Database from 'better-sqlite3';

export const db = new Database(':memory:');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
)
`);
// src/userRepository.ts
import { db } from './db';

export interface User { id: number; name: string; email: string; }

export function createUser(name: string, email: string): User {
const stmt = db.prepare('INSERT INTO users (name, email) VALUES (?, ?)');
const result = stmt.run(name, email);
return { id: result.lastInsertRowid as number, name, email };
}

export function findUserById(id: number): User | undefined {
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined;
}
// src/userRepository.test.ts (integration test — hits a real DB)
import { createUser, findUserById } from './userRepository';
import { db } from './db';

describe('UserRepository', () => {
afterEach(() => {
db.exec('DELETE FROM users');
});

it('persists a user and retrieves it by id', () => {
const user = createUser('Carol', 'carol@example.com');
const found = findUserById(user.id);

expect(found?.name).toBe('Carol');
expect(found?.email).toBe('carol@example.com');
});

it('returns undefined for a missing id', () => {
expect(findUserById(9999)).toBeUndefined();
});
});

Integration Testing in Spring Boot with MockMvc

Spring Boot Test provides @SpringBootTest to load the full application context and MockMvc to make HTTP requests through the web layer without starting a real server.

Dependencies (Maven)

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

spring-boot-starter-test includes JUnit 5, Mockito, AssertJ, MockMvc, and more.

The Controller Under Test

// src/main/java/com/example/user/UserController.java
@RestController
@RequestMapping("/users")
public class UserController {

private final UserService userService;

public UserController(UserService userService) {
this.userService = userService;
}

@GetMapping("/{id}")
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}

@PostMapping
public ResponseEntity<UserDto> createUser(@RequestBody @Valid CreateUserRequest request) {
UserDto created = userService.create(request.getName(), request.getEmail());
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
}

MockMvc Slice Test (@WebMvcTest)

@WebMvcTest loads only the web layer (controllers, filters, converters). The service layer is mocked:

import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import com.fasterxml.jackson.databind.ObjectMapper;

import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(UserController.class)
class UserControllerTest {

@Autowired
private MockMvc mockMvc;

@Autowired
private ObjectMapper objectMapper;

@MockBean
private UserService userService;

@Test
@DisplayName("GET /users/{id} returns 200 with user JSON when user exists")
void getUserReturns200() throws Exception {
UserDto alice = new UserDto(1L, "Alice", "alice@example.com");
when(userService.findById(1L)).thenReturn(Optional.of(alice));

mockMvc.perform(get("/users/1")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Alice"))
.andExpect(jsonPath("$.email").value("alice@example.com"));
}

@Test
@DisplayName("GET /users/{id} returns 404 when user is missing")
void getUserReturns404() throws Exception {
when(userService.findById(99L)).thenReturn(Optional.empty());

mockMvc.perform(get("/users/99"))
.andExpect(status().isNotFound());
}

@Test
@DisplayName("POST /users returns 201 with the created user")
void createUserReturns201() throws Exception {
UserDto created = new UserDto(2L, "Bob", "bob@example.com");
when(userService.create(anyString(), anyString())).thenReturn(created);

String body = objectMapper.writeValueAsString(
new CreateUserRequest("Bob", "bob@example.com")
);

mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(2));
}

@Test
@DisplayName("POST /users returns 400 when name is blank")
void createUserReturns400WhenNameBlank() throws Exception {
String body = objectMapper.writeValueAsString(
new CreateUserRequest("", "bob@example.com")
);

mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
}

Full Stack with @SpringBootTest

When you need to test the full stack including the repository layer (against a real or in-memory database):

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase // replaces the configured DataSource with H2 in-memory
class UserIntegrationTest {

@Autowired
private TestRestTemplate restTemplate;

@Autowired
private UserRepository userRepository;

@BeforeEach
void setUp() {
userRepository.deleteAll();
}

@Test
void createAndRetrieveUser() {
// Create via API
ResponseEntity<UserDto> createResponse = restTemplate.postForEntity(
"/users",
new CreateUserRequest("Dave", "dave@example.com"),
UserDto.class
);
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
Long id = createResponse.getBody().getId();

// Retrieve via API
ResponseEntity<UserDto> getResponse = restTemplate.getForEntity(
"/users/" + id,
UserDto.class
);
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(getResponse.getBody().getName()).isEqualTo("Dave");
}
}

Testcontainers — Real Databases in Docker

In-memory databases (H2, SQLite) are convenient but sometimes behave differently from your production database. Testcontainers starts a real Docker container for each test run:

Maven

<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.20.1</version>
<scope>test</scope>
</dependency>

Using a PostgreSQL Container in JUnit 5

import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;

@Testcontainers
@SpringBootTest
class UserRepositoryIntegrationTest {

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");

@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 UserRepository userRepository;

@BeforeEach
void setUp() {
userRepository.deleteAll();
}

@Test
void persistsAndRetrievesUser() {
User user = userRepository.save(new User(null, "Eve", "eve@example.com"));

Optional<User> found = userRepository.findById(user.getId());

assertThat(found).isPresent();
assertThat(found.get().getEmail()).isEqualTo("eve@example.com");
}

@Test
void findByEmailReturnsCorrectUser() {
userRepository.save(new User(null, "Frank", "frank@example.com"));

Optional<User> result = userRepository.findByEmail("frank@example.com");

assertThat(result).isPresent();
assertThat(result.get().getName()).isEqualTo("Frank");
}
}

@Testcontainers manages the container lifecycle. @Container on a static field starts the container once for all tests in the class. @DynamicPropertySource overrides your application's datasource configuration with the container's dynamic port.

Testcontainers for Node.js

The Node.js ecosystem also has Testcontainers support:

npm install --save-dev testcontainers
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';

describe('UserRepository (Postgres)', () => {
let pool: Pool;

beforeAll(async () => {
const container = await new PostgreSqlContainer()
.withDatabase('testdb')
.start();

pool = new Pool({ connectionString: container.getConnectionUri() });
await pool.query(`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
)
`);
}, 30_000); // allow time for Docker pull

afterAll(async () => {
await pool.end();
});

it('inserts and retrieves a user', async () => {
const { rows } = await pool.query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
['Grace', 'grace@example.com']
);
expect(rows[0].name).toBe('Grace');
});
});

When to Write Integration Tests

ScenarioWrite an integration test?
Simple pure functionNo — unit test is enough
Service calling a mocked repositoryNo — unit test with Mockito/jest.fn()
Repository writing to and reading from a real DBYes
HTTP endpoint + serialization + validation + serviceYes (slice test)
Full request-response round trip with DBYes (@SpringBootTest or Supertest + real DB)
External payment APINo — mock at the boundary; contract test separately

Chapter 7 covers TDD, where you will see how these test types fit into the development workflow.