Loading learning content...
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.
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:
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:
Drawbacks:
At the heart of component interaction testing is contract verification. A contract defines the expectations between components:
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.
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:
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.
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).
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.
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(); });});We've explored the art and science of testing component interactions in object-oriented systems. Let's consolidate the key principles:
What's next:
Database integration is one of the most common and critical integration concerns. The next page focuses specifically on database integration testing—verifying that repositories, queries, transactions, and migrations work correctly with real database systems.
You now have a comprehensive toolkit for testing component interactions. You understand interaction patterns, boundary decisions, async testing strategies, and how to structure tests for clarity. Next, we'll specialize these techniques for database integration testing.