Master integration testing strategies to ensure your system components work together seamlessly
Integration testing is a level of software testing where individual units or components are combined and tested as a group. The purpose is to verify that different parts of the application work together correctly and identify issues in the interaction between integrated components.
Integration testing bridges the gap between unit tests and end-to-end tests:
| Testing Level | Scope | Speed | Cost | Quantity |
|---|---|---|---|---|
| Unit Tests | Single component | Very Fast | Low | Many (70-80%) |
| Integration Tests | Multiple components | Medium | Medium | Moderate (15-20%) |
| E2E Tests | Entire system | Slow | High | Few (5-10%) |
All or most components are combined simultaneously and tested together.
Pros: Quick to implement
Cons: Difficult to isolate defects, late detection
Best for: Small systems
Components are integrated and tested one at a time or in small groups.
Pros: Early defect detection, easier debugging
Cons: Requires more planning
Best for: Most projects
Testing starts from top-level modules, moving downward using stubs for lower modules.
Pros: Early UI testing, top-level logic verified first
Cons: Requires stub creation, lower levels tested later
Testing starts from lowest-level modules, moving upward using drivers.
Pros: Core functionality tested first, no stubs needed
Cons: UI tested late, requires test drivers
Combination of top-down and bottom-up approaches, testing from both ends simultaneously.
Pros: Parallel testing, balanced approach
Cons: More complex, requires coordination
Priority given to integrating and testing high-risk or critical components first.
Pros: Early detection of critical issues
Cons: Requires thorough risk analysis
Top-Down Approach:
[Main Module]
|
[Stub] [Stub]
|
[Module A] [Module B]
|
[Stub] [Real Module]
Bottom-Up Approach:
[Driver]
|
[Module C] [Module D]
|
[Test Driver]
Sandwich Approach:
[Top Modules] ← Test simultaneously → [Bottom Modules]
[Middle Layer]
Testing interaction between software components within the same application.
Testing interaction between different systems or applications.
Testing REST/GraphQL/SOAP APIs and their interactions.
Testing application interaction with databases.
Testing asynchronous messaging systems.
Testing frontend integration with backend services.
Ensures that services conform to agreed-upon contracts, especially useful in microservices architectures.
// Consumer test (Frontend)
const { Pact } = require('@pact-foundation/pact');
describe('User API', () => {
const provider = new Pact({
consumer: 'UserService',
provider: 'UserAPI'
});
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
it('should get user by ID', async () => {
// Define expected interaction
await provider.addInteraction({
state: 'user with ID 1 exists',
uponReceiving: 'a request for user 1',
withRequest: {
method: 'GET',
path: '/users/1',
headers: {
'Accept': 'application/json'
}
},
willRespondWith: {
status: 200,
headers: {
'Content-Type': 'application/json'
},
body: {
id: 1,
name: 'John Doe',
email: 'john@example.com'
}
}
});
// Test consumer
const response = await userService.getUser(1);
expect(response.name).toBe('John Doe');
});
});
Use Real Dependencies for:
Use Mocks/Stubs for:
const request = require('supertest');
const app = require('../app');
const db = require('../db');
describe('User API Integration Tests', () => {
beforeAll(async () => {
await db.connect();
await db.clear();
});
afterAll(async () => {
await db.disconnect();
});
beforeEach(async () => {
await db.seed();
});
describe('POST /api/users', () => {
it('should create a new user', async () => {
const userData = {
name: 'Jane Doe',
email: 'jane@example.com',
password: 'securePassword123'
};
const response = await request(app)
.post('/api/users')
.send(userData)
.expect('Content-Type', /json/)
.expect(201);
expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe(userData.name);
expect(response.body.email).toBe(userData.email);
expect(response.body).not.toHaveProperty('password');
// Verify in database
const user = await db.users.findById(response.body.id);
expect(user).toBeDefined();
expect(user.email).toBe(userData.email);
});
it('should return 400 for invalid email', async () => {
const response = await request(app)
.post('/api/users')
.send({
name: 'Invalid User',
email: 'not-an-email',
password: 'password'
})
.expect(400);
expect(response.body.error).toContain('email');
});
it('should return 409 for duplicate email', async () => {
const userData = {
name: 'Duplicate User',
email: 'existing@example.com',
password: 'password'
};
// Create first user
await request(app).post('/api/users').send(userData);
// Attempt duplicate
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(409);
expect(response.body.error).toContain('already exists');
});
});
describe('GET /api/users/:id', () => {
it('should retrieve user by ID', async () => {
const user = await db.users.create({
name: 'Test User',
email: 'test@example.com'
});
const response = await request(app)
.get(`/api/users/${user.id}`)
.expect(200);
expect(response.body.id).toBe(user.id);
expect(response.body.name).toBe(user.name);
});
it('should return 404 for non-existent user', async () => {
await request(app)
.get('/api/users/99999')
.expect(404);
});
});
describe('PUT /api/users/:id', () => {
it('should update user information', async () => {
const user = await db.users.create({
name: 'Original Name',
email: 'original@example.com'
});
const updateData = {
name: 'Updated Name',
email: 'updated@example.com'
};
const response = await request(app)
.put(`/api/users/${user.id}`)
.send(updateData)
.expect(200);
expect(response.body.name).toBe(updateData.name);
expect(response.body.email).toBe(updateData.email);
// Verify in database
const updatedUser = await db.users.findById(user.id);
expect(updatedUser.name).toBe(updateData.name);
});
});
describe('DELETE /api/users/:id', () => {
it('should delete user', async () => {
const user = await db.users.create({
name: 'To Delete',
email: 'delete@example.com'
});
await request(app)
.delete(`/api/users/${user.id}`)
.expect(204);
// Verify deletion
const deletedUser = await db.users.findById(user.id);
expect(deletedUser).toBeNull();
});
});
});
import io.restassured.RestAssured;
import org.junit.jupiter.api.*;
import static io.restassured.RestAssured.*;
import static org.hamcrest.Matchers.*;
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class UserApiIntegrationTest {
private static final String BASE_URL = "http://localhost:8080/api";
private static int createdUserId;
@BeforeAll
public static void setup() {
RestAssured.baseURI = BASE_URL;
}
@Test
@Order(1)
public void testCreateUser() {
String requestBody = """
{
"name": "John Doe",
"email": "john@example.com",
"password": "password123"
}
""";
createdUserId = given()
.contentType("application/json")
.body(requestBody)
.when()
.post("/users")
.then()
.statusCode(201)
.body("name", equalTo("John Doe"))
.body("email", equalTo("john@example.com"))
.body("id", notNullValue())
.extract()
.path("id");
}
@Test
@Order(2)
public void testGetUser() {
given()
.pathParam("id", createdUserId)
.when()
.get("/users/{id}")
.then()
.statusCode(200)
.body("id", equalTo(createdUserId))
.body("name", equalTo("John Doe"));
}
@Test
@Order(3)
public void testUpdateUser() {
String updateBody = """
{
"name": "John Updated",
"email": "john.updated@example.com"
}
""";
given()
.contentType("application/json")
.pathParam("id", createdUserId)
.body(updateBody)
.when()
.put("/users/{id}")
.then()
.statusCode(200)
.body("name", equalTo("John Updated"));
}
@Test
@Order(4)
public void testDeleteUser() {
given()
.pathParam("id", createdUserId)
.when()
.delete("/users/{id}")
.then()
.statusCode(204);
// Verify deletion
given()
.pathParam("id", createdUserId)
.when()
.get("/users/{id}")
.then()
.statusCode(404);
}
}
Fast, isolated tests using databases like H2, SQLite, or in-memory MongoDB.
Pros: Fast, no cleanup needed
Cons: May not match production database exactly
Dedicated test database instance with same engine as production.
Pros: Accurate, tests real database features
Cons: Slower, requires cleanup
Spin up database containers for each test run using Testcontainers.
Pros: Isolated, matches production, clean state
Cons: Requires Docker, slower startup
import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
public class UserRepositoryIntegrationTest {
@Container
static PostgreSQLContainer> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
private UserRepository userRepository;
@BeforeAll
static void setupDatabase() {
// Database migrations/schema setup
Flyway flyway = Flyway.configure()
.dataSource(postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword())
.load();
flyway.migrate();
}
@BeforeEach
void setup() {
DataSource dataSource = createDataSource();
userRepository = new UserRepository(dataSource);
}
@Test
void testSaveAndFindUser() {
// Given
User user = new User("John Doe", "john@example.com");
// When
User savedUser = userRepository.save(user);
// Then
assertNotNull(savedUser.getId());
User foundUser = userRepository.findById(savedUser.getId());
assertEquals("John Doe", foundUser.getName());
assertEquals("john@example.com", foundUser.getEmail());
}
@Test
void testUpdateUser() {
// Given
User user = userRepository.save(new User("Jane", "jane@example.com"));
// When
user.setName("Jane Updated");
userRepository.update(user);
// Then
User updated = userRepository.findById(user.getId());
assertEquals("Jane Updated", updated.getName());
}
@Test
void testDeleteUser() {
// Given
User user = userRepository.save(new User("Delete Me", "delete@example.com"));
// When
userRepository.delete(user.getId());
// Then
assertNull(userRepository.findById(user.getId()));
}
@Test
void testFindByEmail() {
// Given
userRepository.save(new User("User1", "user1@example.com"));
userRepository.save(new User("User2", "user2@example.com"));
// When
User found = userRepository.findByEmail("user1@example.com");
// Then
assertNotNull(found);
assertEquals("User1", found.getName());
}
@Test
void testTransactionRollback() {
// Given
User user = new User("Transaction Test", "trans@example.com");
// When - This should rollback
assertThrows(Exception.class, () -> {
userRepository.saveWithError(user);
});
// Then - User should not be saved
assertNull(userRepository.findByEmail("trans@example.com"));
}
}
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models import Base, User
@pytest.fixture(scope='function')
def db_session():
# Create in-memory SQLite database
engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
@pytest.fixture
def user_repository(db_session):
return UserRepository(db_session)
def test_create_user(user_repository):
# Given
user_data = {
'name': 'John Doe',
'email': 'john@example.com'
}
# When
user = user_repository.create(user_data)
# Then
assert user.id is not None
assert user.name == 'John Doe'
assert user.email == 'john@example.com'
def test_find_user_by_email(user_repository, db_session):
# Given
user1 = User(name='User 1', email='user1@example.com')
user2 = User(name='User 2', email='user2@example.com')
db_session.add_all([user1, user2])
db_session.commit()
# When
found = user_repository.find_by_email('user1@example.com')
# Then
assert found is not None
assert found.name == 'User 1'
def test_update_user(user_repository, db_session):
# Given
user = User(name='Original', email='original@example.com')
db_session.add(user)
db_session.commit()
user_id = user.id
# When
user_repository.update(user_id, {'name': 'Updated'})
# Then
updated = user_repository.find_by_id(user_id)
assert updated.name == 'Updated'
assert updated.email == 'original@example.com'
def test_delete_user(user_repository, db_session):
# Given
user = User(name='Delete Me', email='delete@example.com')
db_session.add(user)
db_session.commit()
user_id = user.id
# When
user_repository.delete(user_id)
# Then
assert user_repository.find_by_id(user_id) is None
def test_list_users_with_pagination(user_repository, db_session):
# Given - Create 15 users
for i in range(15):
user = User(name=f'User {i}', email=f'user{i}@example.com')
db_session.add(user)
db_session.commit()
# When
page1 = user_repository.list(page=1, per_page=10)
page2 = user_repository.list(page=2, per_page=10)
# Then
assert len(page1) == 10
assert len(page2) == 5
Test service contracts independently without running all services.
Tools: Pact, Spring Cloud Contract
Create virtual copies of dependent services.
Tools: WireMock, Mockito, Mountebank
Test service in isolation with mocked dependencies.
Approach: Test each microservice independently
Test interactions between services.
Approach: Run multiple services together
import com.github.tomakehurst.wiremock.WireMockServer;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
public class OrderServiceIntegrationTest {
private WireMockServer wireMockServer;
private OrderService orderService;
@BeforeEach
void setup() {
// Start WireMock server to mock external services
wireMockServer = new WireMockServer(8089);
wireMockServer.start();
configureFor("localhost", 8089);
// Configure service to use mock server
orderService = new OrderService("http://localhost:8089");
}
@AfterEach
void teardown() {
wireMockServer.stop();
}
@Test
void testCreateOrderWithPaymentService() {
// Mock payment service response
stubFor(post(urlEqualTo("/api/payments"))
.withRequestBody(matchingJsonPath("$.amount"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"paymentId": "pay_123",
"status": "success"
}
""")));
// Mock inventory service response
stubFor(post(urlEqualTo("/api/inventory/reserve"))
.willReturn(aResponse()
.withStatus(200)
.withBody("""
{
"reservationId": "res_456",
"status": "reserved"
}
""")));
// When
Order order = new Order("item123", 2, 99.99);
OrderResult result = orderService.createOrder(order);
// Then
assertTrue(result.isSuccess());
assertEquals("pay_123", result.getPaymentId());
// Verify calls were made
verify(postRequestedFor(urlEqualTo("/api/payments"))
.withRequestBody(matchingJsonPath("$.amount", equalTo("199.98"))));
verify(postRequestedFor(urlEqualTo("/api/inventory/reserve"))
.withRequestBody(matchingJsonPath("$.itemId", equalTo("item123"))));
}
@Test
void testCreateOrderHandlesPaymentFailure() {
// Mock payment service failure
stubFor(post(urlEqualTo("/api/payments"))
.willReturn(aResponse()
.withStatus(402)
.withBody("""
{
"error": "Insufficient funds"
}
""")));
// When
Order order = new Order("item123", 2, 99.99);
OrderResult result = orderService.createOrder(order);
// Then
assertFalse(result.isSuccess());
assertTrue(result.getError().contains("payment failed"));
// Verify inventory was not called
verify(0, postRequestedFor(urlEqualTo("/api/inventory/reserve")));
}
}
Pre-defined test data loaded before tests run.
Use for: Reference data, common scenarios
Fluent APIs to create test data programmatically.
Use for: Flexible, maintainable test data
Populate database with initial data using migration tools.
Use for: Complex data relationships
Generate realistic fake data using libraries.
Tools: Faker, Chance.js, JavaFaker
// Test Data Builder
class UserTestBuilder {
private User user;
constructor() {
this.user = {
name: 'Test User',
email: 'test@example.com',
role: 'user',
active: true
};
}
withName(name) {
this.user.name = name;
return this;
}
withEmail(email) {
this.user.email = email;
return this;
}
asAdmin() {
this.user.role = 'admin';
return this;
}
inactive() {
this.user.active = false;
return this;
}
build() {
return { ...this.user };
}
async buildAndSave() {
const user = this.build();
return await userRepository.create(user);
}
}
// Usage in tests
describe('User Service Integration Tests', () => {
it('should process admin user', async () => {
const admin = await new UserTestBuilder()
.withName('Admin User')
.withEmail('admin@example.com')
.asAdmin()
.buildAndSave();
const result = await userService.process(admin.id);
expect(result.adminActionsAllowed).toBe(true);
});
it('should reject inactive users', async () => {
const inactiveUser = await new UserTestBuilder()
.withEmail('inactive@example.com')
.inactive()
.buildAndSave();
await expect(userService.process(inactiveUser.id))
.rejects.toThrow('User is inactive');
});
});
const { faker } = require('@faker-js/faker');
describe('User Registration', () => {
it('should handle multiple user registrations', async () => {
// Generate realistic test data
const users = Array.from({ length: 10 }, () => ({
name: faker.person.fullName(),
email: faker.internet.email(),
password: faker.internet.password(),
address: {
street: faker.location.streetAddress(),
city: faker.location.city(),
zipCode: faker.location.zipCode()
},
phone: faker.phone.number()
}));
// Register all users
for (const userData of users) {
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(201);
expect(response.body.email).toBe(userData.email);
}
// Verify all were created
const allUsers = await userRepository.findAll();
expect(allUsers.length).toBeGreaterThanOrEqual(10);
});
});
Each test should be independent and not affect others. Clean up test data after each test.
Never run integration tests against production databases. Use dedicated test databases or containers.
Use data that resembles production scenarios. Test edge cases and boundary conditions.
Optimize test execution time. Run critical tests first, parallelize when possible.
When tests fail, provide detailed error messages that help identify the problem quickly.
Don't depend on external third-party APIs in tests. Use mocks or stubs instead.
Test successful scenarios and error conditions, timeouts, and failure cases.
Keep test fixtures and seeds in version control for consistency across environments.
Document required services, configurations, and environment variables needed to run tests.
Problem: Tests pass sometimes but fail randomly.
Solutions:
Problem: Integration tests take too long to run.
Solutions:
Problem: Managing complex test data and state.
Solutions:
Problem: Tests require complex infrastructure setup.
Solutions:
describe('Order Processing Integration Tests', () => {
let db, orderService, paymentGateway, inventoryService;
beforeAll(async () => {
// Setup test database
db = await setupTestDatabase();
// Initialize services with test configuration
orderService = new OrderService(db);
paymentGateway = new MockPaymentGateway();
inventoryService = new InventoryService(db);
});
afterAll(async () => {
await db.close();
});
beforeEach(async () => {
// Clean and seed database before each test
await db.clean();
await db.seed({
products: [
{ id: 1, name: 'Widget', price: 29.99, stock: 100 },
{ id: 2, name: 'Gadget', price: 49.99, stock: 50 }
],
users: [
{ id: 1, name: 'John Doe', email: 'john@example.com' }
]
});
});
it('should successfully process order with payment and inventory', async () => {
// Given
const orderData = {
userId: 1,
items: [
{ productId: 1, quantity: 2 },
{ productId: 2, quantity: 1 }
]
};
// When
const order = await orderService.createOrder(orderData);
// Then
expect(order.id).toBeDefined();
expect(order.status).toBe('confirmed');
expect(order.total).toBe(109.97); // (29.99 * 2) + 49.99
// Verify payment was processed
const payment = await db.payments.findByOrderId(order.id);
expect(payment.status).toBe('completed');
expect(payment.amount).toBe(109.97);
// Verify inventory was updated
const widget = await db.products.findById(1);
const gadget = await db.products.findById(2);
expect(widget.stock).toBe(98); // 100 - 2
expect(gadget.stock).toBe(49); // 50 - 1
// Verify order items were saved
const orderItems = await db.orderItems.findByOrderId(order.id);
expect(orderItems).toHaveLength(2);
});
it('should rollback order if payment fails', async () => {
// Given
paymentGateway.setShouldFail(true);
const orderData = {
userId: 1,
items: [{ productId: 1, quantity: 2 }]
};
// When
await expect(orderService.createOrder(orderData))
.rejects.toThrow('Payment failed');
// Then - Inventory should not be updated
const widget = await db.products.findById(1);
expect(widget.stock).toBe(100); // Original stock
// Order should not exist or be marked as failed
const orders = await db.orders.findByUserId(1);
expect(orders.filter(o => o.status === 'confirmed')).toHaveLength(0);
});
it('should handle insufficient inventory', async () => {
// Given - Order more than available
const orderData = {
userId: 1,
items: [{ productId: 1, quantity: 150 }] // Only 100 in stock
};
// When
await expect(orderService.createOrder(orderData))
.rejects.toThrow('Insufficient inventory');
// Then - No payment should be processed
const payments = await db.payments.findByUserId(1);
expect(payments).toHaveLength(0);
});
it('should calculate correct totals with discounts', async () => {
// Given - Add discount code to database
await db.discounts.create({
code: 'SAVE20',
percentage: 20,
active: true
});
const orderData = {
userId: 1,
items: [{ productId: 1, quantity: 1 }],
discountCode: 'SAVE20'
};
// When
const order = await orderService.createOrder(orderData);
// Then
expect(order.subtotal).toBe(29.99);
expect(order.discount).toBe(6.00); // 20% of 29.99
expect(order.total).toBe(23.99); // 29.99 - 6.00
const payment = await db.payments.findByOrderId(order.id);
expect(payment.amount).toBe(23.99);
});
});