Loading learning content...
Unit tests verify that individual classes and methods work correctly in isolation. But software systems are not collections of isolated components—they are interconnected webs of collaborating objects, each depending on others to fulfill their responsibilities. A perfectly unit-tested OrderService is worthless if it fails to communicate correctly with the PaymentGateway, InventoryRepository, or NotificationService it depends on.
Integration testing bridges this gap. It validates that components work together correctly when assembled, revealing the subtle bugs that unit tests—by design—cannot catch. These tests verify that the seams between components are solid, that data flows correctly across boundaries, and that the system behaves as expected when real collaborators replace test doubles.
By the end of this page, you will understand what integration testing is, why it exists as a distinct testing category, how it differs fundamentally from unit testing, and when to employ it in your testing strategy. You'll develop the conceptual foundation for designing effective integration tests in object-oriented systems.
Integration testing is a level of software testing where individual software modules or components are combined and tested as a group. The primary purpose is to verify that the interfaces between components function correctly and that integrated components produce expected results when working together.
Unlike unit tests, which isolate a single unit of code using test doubles (mocks, stubs, fakes), integration tests allow two or more real components to interact. This exposes:
Think of integration testing as testing the wiring between components. Unit tests verify the components themselves work—like testing that each light bulb illuminates. Integration tests verify the wiring, switches, and connections—ensuring that flipping a switch actually lights the correct bulb.
The scope of integration testing varies:
Narrow integration tests: Test a small number of collaborating components, perhaps two or three, with external dependencies still mocked. For example, testing that OrderService correctly calls InventoryRepository without actually hitting a database.
Broad integration tests: Test larger portions of the system, potentially including real databases, message queues, or external services via test instances. For example, testing the entire order flow from API endpoint through service layer to database.
Both approaches serve legitimate purposes, and a mature testing strategy typically includes both.
Understanding the distinction between unit and integration tests is fundamental to building an effective testing strategy. These are not arbitrary categories—they represent fundamentally different approaches to verification, each with distinct strengths and tradeoffs.
| Dimension | Unit Tests | Integration Tests |
|---|---|---|
| Scope | Single class/method in isolation | Multiple collaborating components |
| Dependencies | Replaced with test doubles (mocks/stubs) | Real implementations (or test instances) |
| Speed | Extremely fast (milliseconds) | Slower (seconds to minutes) |
| Reliability | Highly deterministic | May be affected by external factors |
| Failure diagnosis | Pinpoints exact failure location | Requires investigation across components |
| Setup complexity | Minimal | May require databases, services, fixtures |
| What they verify | Unit logic is correct | Components work correctly together |
| What they miss | Integration issues | Deep internal logic errors |
Neither approach is sufficient alone. A system with 100% unit test coverage but no integration tests may have components that work perfectly in isolation but fail catastrophically when connected. Conversely, a system tested only through integration tests may have hard-to-diagnose internal bugs and extremely slow feedback loops.
The key insight is that unit and integration tests are complementary, not competing. Each catches different categories of defects, and a robust testing strategy uses both strategically.
Integration testing is not a single, monolithic category. It exists on a spectrum from narrow, focused tests to broad, system-wide tests. Understanding this spectrum helps you choose the right approach for each testing scenario.
┌─────────────────────────────────────────────────────────────────────────┐│ THE INTEGRATION TESTING SPECTRUM │├─────────────────────────────────────────────────────────────────────────┤│ ││ NARROW BROAD ││ ◀───────────────────────────────────────────────────────────────────▶ ││ ││ ┌─────────────┐ ┌─────────────────┐ ┌───────────────────────────┐ ││ │ Pair │ │ Component │ │ Subsystem │ ││ │ Tests │ │ Integration │ │ Integration │ ││ │ │ │ Tests │ │ Tests │ ││ │ 2 classes │ │ Service + │ │ API → Service → │ ││ │ interact │ │ Repository │ │ Repository → DB │ ││ └─────────────┘ └─────────────────┘ └───────────────────────────┘ ││ ││ ● Fast ● Moderate ● Slower ││ ● Focused ● Balanced ● Comprehensive ││ ● Easy to debug ● Moderate effort ● Complex setup ││ ││ ┌─────────────────────────────────┐ ││ │ End-to-End / System Tests │ ││ │ Full system with all │ ││ │ external dependencies │ ││ └─────────────────────────────────┘ ││ ● Slowest ││ ● Most realistic ││ ● Hardest to maintain ││ │└─────────────────────────────────────────────────────────────────────────┘Level 1: Pair Tests (Narrow)
Test two specific classes working together, with all other dependencies mocked. These are the fastest integration tests and are closest to unit tests in character.
// Testing OrderService + OrderValidator together
// with PaymentGateway still mocked
const mockPaymentGateway = mock<PaymentGateway>();
const validator = new OrderValidator(); // Real implementation
const service = new OrderService(validator, mockPaymentGateway);
Level 2: Component Integration Tests
Test a service with its immediate collaborators, perhaps including a real repository with an in-memory or test database.
Level 3: Subsystem Integration Tests
Test an entire slice of the application, from entry point through multiple layers to data persistence.
Level 4: End-to-End Tests (Broadest)
Test the complete system including UI, APIs, databases, and external services. While technically integration tests, these are often categorized separately due to their distinct characteristics and challenges.
The classic testing pyramid suggests having many unit tests at the base, fewer integration tests in the middle, and even fewer end-to-end tests at the top. This reflects the tradeoffs: unit tests are cheap and fast, integration tests are more expensive but catch different bugs, and E2E tests are most expensive but most realistic.
In object-oriented design, we carefully craft classes with specific responsibilities, define interfaces and contracts, and wire components together through dependency injection or other patterns. This design approach—while essential for maintainability—creates numerous integration points that require testing.
Consider a typical service layer pattern:
123456789101112131415161718192021222324252627282930313233
// A well-designed service with multiple dependenciesclass OrderService { constructor( private readonly orderRepository: IOrderRepository, private readonly inventoryService: IInventoryService, private readonly paymentGateway: IPaymentGateway, private readonly notificationService: INotificationService, private readonly pricingEngine: IPricingEngine ) {} async placeOrder(customerId: string, items: OrderItem[]): Promise<Order> { // 1. Calculate pricing (integration with PricingEngine) const pricing = await this.pricingEngine.calculateTotal(items); // 2. Check inventory (integration with InventoryService) await this.inventoryService.reserveItems(items); // 3. Process payment (integration with PaymentGateway) const paymentResult = await this.paymentGateway.charge( customerId, pricing.total ); // 4. Create and persist order (integration with Repository) const order = Order.create(customerId, items, pricing, paymentResult); await this.orderRepository.save(order); // 5. Send notifications (integration with NotificationService) await this.notificationService.sendOrderConfirmation(order); return order; }}This single method has five integration points. Unit tests can verify the orchestration logic with mocks, but they cannot verify:
PricingEngine.calculateTotal() return data in the format OrderService expects?InventoryService.reserveItems() correctly handle the OrderItem[] format we're passing?OrderRepository.save() correctly persist all fields of the Order entity?These are integration concerns—they arise from the interaction between components, not from bugs within any single component.
Integration tests follow the same Arrange-Act-Assert pattern as unit tests, but with important differences in each phase:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
describe('OrderService Integration Tests', () => { // ARRANGE (Setup Phase) - More complex than unit tests let orderService: OrderService; let testDatabase: TestDatabase; let orderRepository: PostgresOrderRepository; let inventoryService: InventoryService; let inventoryRepository: PostgresInventoryRepository; beforeAll(async () => { // Start test database (could be Docker container, in-memory DB, etc.) testDatabase = await TestDatabase.start(); // Run migrations to ensure schema is correct await testDatabase.runMigrations(); }); beforeEach(async () => { // Clean database state between tests await testDatabase.truncateAll(); // Wire up REAL implementations, not mocks orderRepository = new PostgresOrderRepository(testDatabase.pool); inventoryRepository = new PostgresInventoryRepository(testDatabase.pool); inventoryService = new InventoryService(inventoryRepository); // Some dependencies may still be mocked (external payment gateway) const mockPaymentGateway = createMockPaymentGateway(); const mockNotificationService = createMockNotificationService(); const pricingEngine = new PricingEngine(); // Real implementation orderService = new OrderService( orderRepository, inventoryService, mockPaymentGateway, mockNotificationService, pricingEngine ); // Seed test data await inventoryRepository.save( new InventoryItem('SKU-001', 'Widget', 100) ); }); afterAll(async () => { await testDatabase.stop(); }); // ACT + ASSERT - Testing real component interactions it('should correctly persist order and update inventory', async () => { // Arrange const customerId = 'customer-123'; const items = [{ sku: 'SKU-001', quantity: 5 }]; // Act - Real service calling real repository const order = await orderService.placeOrder(customerId, items); // Assert - Verify integration worked correctly // 1. Order was persisted const persistedOrder = await orderRepository.findById(order.id); expect(persistedOrder).toBeDefined(); expect(persistedOrder?.customerId).toBe(customerId); expect(persistedOrder?.items).toHaveLength(1); // 2. Inventory was decremented const inventory = await inventoryRepository.findBySku('SKU-001'); expect(inventory?.quantity).toBe(95); // 100 - 5 // 3. Order contains correct pricing from PricingEngine expect(order.pricing.subtotal).toBeGreaterThan(0); }); it('should rollback inventory if order persistence fails', async () => { // Arrange - Corrupt database connection for order table await testDatabase.simulateTableFailure('orders'); const customerId = 'customer-123'; const items = [{ sku: 'SKU-001', quantity: 5 }]; // Act & Assert await expect(orderService.placeOrder(customerId, items)) .rejects.toThrow(); // Inventory should be rolled back const inventory = await inventoryRepository.findBySku('SKU-001'); expect(inventory?.quantity).toBe(100); // Unchanged });});Key observations:
Setup is more complex: We need to start databases, run migrations, and wire real components together.
Some dependencies remain mocked: External services like payment gateways are still mocked to avoid side effects and costs.
Database state management is critical: We truncate tables between tests to ensure isolation.
Assertions cross component boundaries: We verify that changes propagated correctly through the system.
Tests are slower but more realistic: Each test may take seconds rather than milliseconds.
Integration tests must be isolated from each other. If Test A modifies database state and Test B depends on initial state, you'll have flaky tests that fail randomly depending on execution order. Always reset state in beforeEach() or use transaction rollbacks.
Not every interaction requires an integration test. Given their higher cost (slower execution, more complex setup, harder debugging), integration tests should be deployed strategically. Here's guidance on when they provide the most value:
Focus integration tests on seams—the boundaries where components connect. If there's an interface between two components, that's a seam. The more critical the seam (data persistence, external communication, security boundaries), the more it benefits from integration testing.
We've established a comprehensive foundation for understanding integration testing in object-oriented designs. Let's consolidate the key insights:
What's next:
Now that we understand what integration testing is and why it matters, we'll explore specific techniques for testing component interactions. The next page dives deep into practical strategies for verifying that collaborating components work correctly together, including how to structure tests, manage dependencies, and handle asynchronous interactions.
You now have a solid conceptual understanding of integration testing. You understand its purpose, how it differs from unit testing, and when to employ it. Next, we'll get practical with techniques for testing component interactions effectively.