Integration Testing at LLD Level
Testing Component Interactions
Read aloud is unavailable in this browser.
Verifying Collaboration: The Art of Interaction Testing
Object-oriented systems are fundamentally about collaboration. Objects send messages to each other, delegate responsibilities, and coordinate to accomplish complex tasks. While unit tests verify that individual objects behave correctly in isolation, interaction testing verifies that objects collaborate correctly when assembled.
This page explores the techniques and patterns for testing component interactions effectively. You'll learn how to verify that components communicate properly, handle each other's responses correctly, and maintain system integrity when working together.
By the end of this page, you will understand how to design tests that verify component interactions, manage test doubles at integration boundaries, verify both synchronous and asynchronous communication, and structure integration tests for maintainability and clarity.
Interaction Patterns in Object-Oriented Systems
Before we can test interactions, we must understand the patterns of interaction that occur in well-designed OO systems. Each pattern presents different testing challenges.
| Interaction Pattern | Description | Testing Challenge |
|---|---|---|
| Request-Response | Component A calls Component B and waits for a result | Verify correct data is passed and returned |
| Fire-and-Forget | Component A triggers Component B with no return value | Verify side effects occurred correctly |
| Event Publication | Component A publishes events, Components B, C subscribe | Verify all subscribers are notified correctly |
| Chain/Pipeline | Data flows through A → B → C → D sequentially | Verify transformations at each stage |
| Callback/Async | Component A provides callback, B invokes it later | Verify callback is invoked with correct data |
| Bidirectional | Components A and B call each other's methods | Verify no deadlocks, correct state synchronization |
Understanding these patterns shapes how we structure our integration tests:
- Request-Response tests focus on input/output verification
- Fire-and-Forget tests require verifying state changes or database modifications
- Event Publication tests must verify all subscribers received the event
- Chain/Pipeline tests may need to verify intermediate states
- Callback/Async tests require careful handling of timing and promises
- Bidirectional tests must consider execution order and state consistency
The Sociable Unit Test Approach
Martin Fowler distinguishes between solitary and sociable unit tests. Solitary tests isolate the unit under test completely, while sociable tests allow the unit to interact with its real collaborators. Sociable tests are, in essence, narrow integration tests.
This approach offers a pragmatic middle ground:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
// SOLITARY approach: Everything mocked except the unit under testdescribe('OrderProcessor - Solitary', () => { it('should process order', async () => { const mockValidator = mock<OrderValidator>(); const mockPricer = mock<OrderPricer>(); const mockRepository = mock<OrderRepository>(); mockValidator.validate.mockResolvedValue({ isValid: true }); mockPricer.calculateTotal.mockResolvedValue(Money.of(100)); mockRepository.save.mockResolvedValue(undefined); const processor = new OrderProcessor( mockValidator.object, mockPricer.object, mockRepository.object ); await processor.process(createTestOrder()); expect(mockRepository.save).toHaveBeenCalled(); });}); // SOCIABLE approach: Test with real collaborators where practicaldescribe('OrderProcessor - Sociable', () => { it('should process order through validation and pricing', async () => { // Real collaborators that are fast and deterministic const validator = new OrderValidator(new ProductCatalog()); const pricer = new OrderPricer(new TaxCalculator(), new DiscountEngine()); // External dependency still mocked const mockRepository = mock<OrderRepository>(); mockRepository.save.mockResolvedValue(undefined); const processor = new OrderProcessor( validator, pricer, mockRepository.object ); const order = createTestOrder(); const result = await processor.process(order); // Now testing real interactions between Processor, Validator, and Pricer expect(result.validated).toBe(true); expect(result.total.amount).toBeGreaterThan(0); expect(mockRepository.save).toHaveBeenCalledWith( expect.objectContaining({ validated: true }) ); });});Use sociable tests when collaborators are: (1) fast to execute, (2) deterministic (no randomness or external state), (3) unlikely to fail independently (well-tested), and (4) tightly coupled conceptually (testing them separately creates artificial boundaries).
Benefits of sociable tests:
- Catch interface mismatches that mocks would hide
- Test the actual behavior, not the assumed behavior
- Fewer mocks mean less maintenance when interfaces change
- More confidence that the integrated code works
Drawbacks:
- Slower execution than pure unit tests
- Failures may be harder to localize
- Require collaborators to be stable and well-tested first
Verifying Component Contracts
At the heart of component interaction testing is contract verification. A contract defines the expectations between components:
- What inputs are valid?
- What outputs will be produced?
- What side effects will occur?
- What errors might be thrown?
Integration tests verify that both sides of a contract are upheld.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
// The INTERFACE defines the contractinterface IPaymentProcessor { /** * Processes a payment for the given amount. * * CONTRACT: * - Preconditions: amount > 0, valid payment method * - Postconditions: returns PaymentResult with transaction ID * - Invariants: idempotent for same payment request ID * - Exceptions: throws PaymentFailedException on decline */ processPayment(request: PaymentRequest): Promise<PaymentResult>;} // The IMPLEMENTATION fulfills the contractclass StripePaymentProcessor implements IPaymentProcessor { async processPayment(request: PaymentRequest): Promise<PaymentResult> { // Implementation details... }} // The CONSUMER depends on the contractclass CheckoutService { constructor(private paymentProcessor: IPaymentProcessor) {} async checkout(cart: Cart): Promise<Order> { const paymentRequest = this.buildPaymentRequest(cart); const result = await this.paymentProcessor.processPayment(paymentRequest); // Consumer expects result to have transactionId... return this.createOrder(cart, result.transactionId); }} // INTEGRATION TEST: Verify consumer and implementation aligndescribe('Checkout-Payment Integration', () => { let checkoutService: CheckoutService; let paymentProcessor: StripePaymentProcessor; beforeEach(() => { // Using real implementation (with test Stripe key) paymentProcessor = new StripePaymentProcessor(testStripeConfig); checkoutService = new CheckoutService(paymentProcessor); }); it('should complete checkout with valid payment', async () => { const cart = createTestCart(); const order = await checkoutService.checkout(cart); // Verify the CONTRACT was fulfilled: // 1. Order was created (consumer's postcondition) expect(order).toBeDefined(); expect(order.id).toBeDefined(); // 2. Transaction ID was provided (provider's postcondition) expect(order.paymentTransactionId).toMatch(/^pi_/); // Stripe format // 3. Payment was actually processed (side effect verification) const stripePayment = await paymentProcessor.getPayment( order.paymentTransactionId ); expect(stripePayment.status).toBe('succeeded'); }); it('should handle payment decline correctly', async () => { const cart = createTestCart(); // Stripe test card that always declines cart.paymentMethod = { card: '4000000000000002' }; // Verify CONTRACT exception handling: await expect(checkoutService.checkout(cart)) .rejects.toThrow(PaymentFailedException); // Verify no order was created const orders = await orderRepository.findByCartId(cart.id); expect(orders).toHaveLength(0); });});Contract tests (like Pact) verify that provider and consumer agree on the contract format. Integration tests go further—they verify that the actual implementations work correctly together. Both are valuable but serve different purposes.
Managing Test Boundaries: What to Mock, What to Use
One of the most challenging decisions in integration testing is determining where to draw the boundary. Which dependencies should use real implementations, and which should remain mocked?
Here's a decision framework:
COMPONENT DECISIONS FOR INTEGRATION TESTING═══════════════════════════════════════════ For each dependency, ask these questions sequentially: ┌─────────────────────────────┐ │ Is it EXTERNAL to your │ │ system? (3rd party API, │ │ payment processor, etc.) │ └─────────────┬───────────────┘ │ ┌─────────────┴───────────────┐ ▼ YES NO ▼ ┌───────────────────┐ ┌──────────────────────┐ │ MOCK IT │ │ Is it SLOW? │ │ (or use sandbox) │ │ (>100ms per call) │ └───────────────────┘ └──────────┬───────────┘ │ ┌──────────┴───────────┐ ▼ YES NO ▼ ┌───────────────────┐ ┌───────────────────┐ │ Consider mocking │ │ Is it │ │ OR use test │ │ NON-DETERMINISTIC?│ │ instance │ │ (random, time) │ └───────────────────┘ └─────────┬─────────┘ │ ┌─────────┴─────────┐ ▼ YES NO ▼ ┌───────────────────┐ ┌──────────────────┐ │ MOCK or STUB it │ │ Is it the focus │ │ for determinism │ │ of this test? │ └───────────────────┘ └────────┬─────────┘ │ ┌──────────┴──────────┐ ▼ YES NO ▼ ┌───────────────────┐ ┌────────────────┐ │ USE REAL │ │ Your choice │ │ IMPLEMENTATION │ │ Either works │ └───────────────────┘ └────────────────┘Practical guidance by component type:
- •Domain services: Core business logic
- •Repositories: With test database
- •Validators: Input validation logic
- •Mappers/Transformers: Data conversion
- •Factories: Object creation logic
- •Event handlers: Internal event processing
- •External APIs: Payment gateways, shipping
- •Email/SMS services: Side effects
- •Time providers: For determinism
- •Random generators: For reproducibility
- •File systems: Unless testing file logic
- •HTTP clients: To external services
Too many mocks and you're not testing real interactions—you're just testing your mock configurations. Too few mocks and tests become slow, flaky, and dependent on external systems. Find the right balance for each test scenario.
Testing Asynchronous Interactions
Modern applications are heavily asynchronous. Components communicate through promises, events, message queues, and callbacks. Testing these interactions requires special techniques.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
// ===== TESTING PROMISE CHAINS =====describe('OrderWorkflow - Async Interactions', () => { it('should complete multi-step async workflow', async () => { const order = await orderService.createOrder(validOrderRequest); // Each step returns a promise, chained internally // We verify final state after all promises resolve expect(order.status).toBe('CONFIRMED'); expect(order.inventory.reserved).toBe(true); expect(order.payment.charged).toBe(true); });}); // ===== TESTING EVENT EMISSION AND HANDLING =====describe('Event-Based Interactions', () => { let eventBus: EventBus; let orderService: OrderService; let inventoryHandler: InventoryEventHandler; let capturedEvents: DomainEvent[] = []; beforeEach(() => { eventBus = new InMemoryEventBus(); // Capture all emitted events for verification eventBus.subscribeAll((event) => capturedEvents.push(event)); inventoryHandler = new InventoryEventHandler(inventoryRepository); eventBus.subscribe('OrderCreated', inventoryHandler); orderService = new OrderService(orderRepository, eventBus); }); it('should emit OrderCreated event and trigger inventory reservation', async () => { const order = await orderService.createOrder(validOrderRequest); // Wait for async event handling to complete await eventBus.drain(); // Custom method to wait for queue empty // Verify event was emitted expect(capturedEvents).toContainEqual( expect.objectContaining({ type: 'OrderCreated', payload: expect.objectContaining({ orderId: order.id }) }) ); // Verify handler processed the event const inventory = await inventoryRepository.findReservationsForOrder(order.id); expect(inventory).toHaveLength(order.items.length); });}); // ===== TESTING WITH POLLING FOR EVENTUAL CONSISTENCY =====describe('Eventually Consistent Operations', () => { it('should update search index after order creation', async () => { const order = await orderService.createOrder(validOrderRequest); // Search index updates asynchronously // Use polling with timeout to verify eventual consistency await waitFor(async () => { const searchResult = await searchService.findOrder(order.id); return searchResult !== null; }, { timeout: 5000, interval: 100, timeoutMessage: 'Order did not appear in search index within 5s' }); const searchResult = await searchService.findOrder(order.id); expect(searchResult.customerId).toBe(order.customerId); });}); // ===== TESTING TIMEOUT AND RETRY BEHAVIOR =====describe('Timeout Handling', () => { it('should retry failed external calls', async () => { // Simulate intermittent failure let callCount = 0; paymentGateway.charge.mockImplementation(async () => { callCount++; if (callCount < 3) { throw new TimeoutError('Gateway timeout'); } return { success: true, transactionId: 'txn_123' }; }); const order = await orderService.createOrder(validOrderRequest); // Should succeed after retries expect(order.payment.success).toBe(true); expect(callCount).toBe(3); // 2 failures + 1 success }); it('should fail gracefully after max retries', async () => { paymentGateway.charge.mockRejectedValue(new TimeoutError('Gateway timeout')); await expect(orderService.createOrder(validOrderRequest)) .rejects.toThrow('Payment processing failed after 3 attempts'); });}); // ===== UTILITY: Wait for condition with timeout =====async function waitFor( condition: () => Promise<boolean>, options: { timeout: number; interval: number; timeoutMessage: string }): Promise<void> { const startTime = Date.now(); while (Date.now() - startTime < options.timeout) { if (await condition()) return; await new Promise(resolve => setTimeout(resolve, options.interval)); } throw new Error(options.timeoutMessage);}Never use await sleep(1000) and hope the async operation completes. Use polling with conditions, event-based waiting, or explicit synchronization. Fixed delays make tests slow (waiting longer than necessary) and flaky (sometimes not waiting long enough).
Testing Transaction Boundaries
Transaction management is a critical integration concern. When multiple components modify state within a transaction, we must verify that either all changes commit or all changes rollback.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
describe('Transaction Boundary Tests', () => { let transactionManager: TransactionManager; let orderRepository: OrderRepository; let inventoryRepository: InventoryRepository; let paymentService: PaymentService; let orderService: OrderService; beforeEach(async () => { // Setup with real database transaction support transactionManager = new PostgresTransactionManager(testDbPool); orderRepository = new OrderRepository(testDbPool); inventoryRepository = new InventoryRepository(testDbPool); // Seed initial inventory await inventoryRepository.seed({ sku: 'WIDGET-001', quantity: 10 }); }); describe('Successful Transaction', () => { it('should commit all changes when order succeeds', async () => { const request = { customerId: 'cust-123', items: [{ sku: 'WIDGET-001', quantity: 2 }] }; const order = await orderService.placeOrder(request); // Verify ALL changes were committed atomically: // 1. Order exists const savedOrder = await orderRepository.findById(order.id); expect(savedOrder).toBeDefined(); expect(savedOrder?.status).toBe('CONFIRMED'); // 2. Inventory was decremented const inventory = await inventoryRepository.findBySku('WIDGET-001'); expect(inventory?.quantity).toBe(8); // 10 - 2 // 3. Payment record exists const payment = await paymentRepository.findByOrderId(order.id); expect(payment?.status).toBe('CAPTURED'); }); }); describe('Transaction Rollback', () => { it('should rollback all changes when payment fails', async () => { // Configure payment to fail paymentService.configureTestFailure('CARD_DECLINED'); const initialInventory = await inventoryRepository.findBySku('WIDGET-001'); const request = { customerId: 'cust-123', items: [{ sku: 'WIDGET-001', quantity: 2 }] }; // Act - Should fail and rollback await expect(orderService.placeOrder(request)) .rejects.toThrow('Payment declined'); // Assert - ALL changes should be rolled back: // 1. No order exists const orders = await orderRepository.findByCustomerId('cust-123'); expect(orders).toHaveLength(0); // 2. Inventory is unchanged const currentInventory = await inventoryRepository.findBySku('WIDGET-001'); expect(currentInventory?.quantity).toBe(initialInventory?.quantity); }); it('should rollback inventory if order save fails', async () => { // Break the order table to simulate save failure await testDbPool.query(` ALTER TABLE orders ADD CONSTRAINT test_break CHECK (1 = 0) `); const request = { customerId: 'cust-123', items: [{ sku: 'WIDGET-001', quantity: 2 }] }; try { await orderService.placeOrder(request); fail('Should have thrown'); } catch (error) { // Expected } // Inventory should be unchanged const inventory = await inventoryRepository.findBySku('WIDGET-001'); expect(inventory?.quantity).toBe(10); // Cleanup await testDbPool.query('ALTER TABLE orders DROP CONSTRAINT test_break'); }); }); describe('Isolation Level Verification', () => { it('should prevent dirty reads with proper isolation', async () => { // Start order in one transaction (don't commit yet) const tx1 = await transactionManager.begin(); await orderRepository.createWithTransaction(tx1, { id: 'order-uncommitted', customerId: 'cust-123', status: 'PENDING' }); // tx1 NOT committed // Try to read from another connection - should NOT see uncommitted const order = await orderRepository.findById('order-uncommitted'); expect(order).toBeNull(); // Dirty read prevented // Commit and verify it's now visible await tx1.commit(); const orderAfterCommit = await orderRepository.findById('order-uncommitted'); expect(orderAfterCommit).toBeDefined(); }); });});When components span multiple databases or services, traditional ACID transactions may not be available. In these cases, test saga patterns, compensation logic, and eventual consistency mechanisms instead.
Structuring Integration Tests for Maintainability
Integration tests tend toward complexity. Without careful organization, they become unmaintainable. Here are patterns for keeping integration tests clear and manageable:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
// ===== PATTERN 1: Test Data Builders for Complex Setup =====class OrderTestBuilder { private props: Partial<OrderProps> = { customerId: 'default-customer', items: [], status: 'PENDING' }; withCustomer(customerId: string): this { this.props.customerId = customerId; return this; } withItem(sku: string, quantity: number): this { this.props.items!.push({ sku, quantity }); return this; } withStatus(status: OrderStatus): this { this.props.status = status; return this; } build(): Order { return Order.create(this.props as OrderProps); } async buildAndPersist(repository: OrderRepository): Promise<Order> { const order = this.build(); await repository.save(order); return order; }} // Usage in tests:const order = await new OrderTestBuilder() .withCustomer('premium-customer') .withItem('WIDGET-001', 5) .withItem('GADGET-002', 3) .buildAndPersist(orderRepository); // ===== PATTERN 2: Fixture Classes for Complex Scenarios =====class E2EOrderFixture { constructor( private readonly db: TestDatabase, private readonly repos: RepositorySet ) {} async setupCompletedOrder(): Promise<{ order: Order; customer: Customer }> { const customer = await this.repos.customer.save( CustomerFactory.verified() ); await this.repos.inventory.save([ { sku: 'SKU-001', quantity: 100 }, { sku: 'SKU-002', quantity: 50 } ]); const order = await this.repos.order.save( new OrderTestBuilder() .withCustomer(customer.id) .withItem('SKU-001', 2) .withStatus('SHIPPED') .build() ); return { order, customer }; } async cleanup(): Promise<void> { await this.db.truncateAll(); }} // ===== PATTERN 3: Descriptive Test Organization =====describe('Order Processing Integration', () => { describe('when order is valid and payment succeeds', () => { describe('inventory behavior', () => { it('reserves items during checkout', async () => { /* ... */ }); it('decrements stock after payment confirmation', async () => { /* ... */ }); it('releases reservation if order is cancelled', async () => { /* ... */ }); }); describe('notification behavior', () => { it('sends confirmation email to customer', async () => { /* ... */ }); it('notifies warehouse for fulfillment', async () => { /* ... */ }); }); }); describe('when payment fails', () => { it('does not reserve inventory', async () => { /* ... */ }); it('does not send confirmation notifications', async () => { /* ... */ }); it('logs the failure for monitoring', async () => { /* ... */ }); });}); // ===== PATTERN 4: Shared Test Context =====describe('Order Service Integration', () => { const ctx = new IntegrationTestContext(); beforeAll(() => ctx.setup()); afterAll(() => ctx.teardown()); beforeEach(() => ctx.reset()); // ctx provides: // - ctx.db: Test database connection // - ctx.services: Wired service instances // - ctx.factories: Test data factories // - ctx.mocks: Pre-configured mock objects it('should create order', async () => { const order = await ctx.services.order.create( ctx.factories.orderRequest() ); expect(await ctx.db.orders.findById(order.id)).toBeDefined(); });});- •Use test data builders to create complex objects declaratively
- •Create fixture classes for scenarios that require multiple related entities
- •Group tests by scenario not by method—test business behaviors
- •Share setup through context objects to reduce duplication
- •Name tests descriptively so failures are self-explanatory
- •Isolate test state between tests to prevent flaky failures
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.