Integration Testing at LLD Level
What is Integration Testing
Read aloud is unavailable in this browser.
Beyond the Unit: Testing Components in Concert
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.
Defining Integration Testing
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:
- Interface mismatches: When components make incorrect assumptions about each other's contracts
- Data format inconsistencies: When serialization/deserialization or type conversions fail
- Communication failures: When network calls, database queries, or message passing breaks
- Configuration errors: When components are misconfigured for their environment
- Resource contention issues: When components compete for shared resources
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
OrderServicecorrectly callsInventoryRepositorywithout 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.
Integration Testing vs Unit Testing: A Critical Distinction
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.
- •Logic errors within a single method
- •Edge cases in algorithms
- •Incorrect state management
- •Missing null checks
- •Off-by-one errors
- •Boundary condition failures
- •Interface contract violations
- •Incorrect dependency wiring
- •Serialization/deserialization issues
- •Transaction boundary problems
- •Database query errors
- •Configuration mismatches
The Integration Testing Spectrum
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.
Why Integration Tests Matter at the LLD Level
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:
- Does
PricingEngine.calculateTotal()return data in the formatOrderServiceexpects? - Does
InventoryService.reserveItems()correctly handle theOrderItem[]format we're passing? - Does
OrderRepository.save()correctly persist all fields of theOrderentity? - Do transaction boundaries work correctly if payment fails after inventory is reserved?
These are integration concerns—they arise from the interaction between components, not from bugs within any single component.
- •Contract drift: Interface implementations evolve but callers aren't updated
- •Null assumption mismatches: One component returns null, another doesn't expect it
- •Date/time format inconsistencies: Components assume different timestamp formats
- •Encoding issues: Character encoding mismatches between components
- •Transaction isolation: Database operations interleave unexpectedly
- •Async coordination failures: Promise chains or callbacks execute out of order
- •Circular dependency issues: Components deadlock waiting for each other
Anatomy of an Integration Test
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.
When to Write Integration Tests
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:
- •Database interactions: Repository implementations, query correctness, transaction behavior
- •External API integrations: HTTP client configurations, request/response mapping, error handling
- •Message queue interactions: Message publishing, consumption, serialization formats
- •Cross-service communication: Service-to-service contracts, especially with shared data types
- •Authentication/Authorization flows: Token validation, permission checks across layers
- •Critical business workflows: Order processing, payment flows, inventory management
- •Data format transformations: DTOs to entities, API responses to domain objects
- •Configuration-driven behavior: Features that change based on environment configuration
- •Service orchestrating multiple repositories
- •Controller invoking service with real validation
- •Event handler publishing to message queue
- •Caching layer with real cache store
- •File processing pipeline
- •Pure algorithmic logic
- •Input validation rules
- •Domain entity invariants
- •Utility/helper functions
- •State machine transitions
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.
Summary: Understanding Integration Testing
We've established a comprehensive foundation for understanding integration testing in object-oriented designs. Let's consolidate the key insights:
- •Integration tests verify component collaboration — They test the wiring between components, not the components themselves.
- •They complement, not replace, unit tests — Each testing level catches different categories of defects.
- •Integration tests exist on a spectrum — From narrow pair tests to broad subsystem tests, choose the appropriate level for each scenario.
- •OO design creates integration points — Every dependency, interface, and collaboration is a potential integration concern.
- •Setup complexity is inherent — Real databases, real services, and careful state management are expected.
- •Strategic deployment is essential — Focus on high-value seams where integration failures would be most costly.
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.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.