Home Integration Testing
Post
Cancel
Integration Testing | SEG

Integration Testing

This post builds on testing fundamentals. If you’re new to testing, check out our post on Unit Testing first to understand the basics!

What Is Integration Testing?

Integration testing is the phase of software testing where individual units or components are combined and tested as a group. While unit testing verifies that each piece works correctly in isolation, integration testing ensures these pieces work together properly.

Think of it like assembling a jigsaw puzzle. Unit testing verifies each puzzle piece is correctly shaped and colored. Integration testing checks that the pieces actually fit together to form the complete picture. A single piece might be perfect, but if it doesn’t connect with its neighbors, the puzzle fails.

Why Integration Testing Matters

Even when all your units pass their tests, problems can arise when they interact:

  • Interface mismatches: One component expects JSON, another sends XML
  • Timing issues: Components work individually but fail when dealing with concurrent requests
  • Data flow problems: Data transformations between components introduce bugs
  • External dependencies: Database connections, APIs, and file systems behave differently than mocks
  • Configuration errors: Services are misconfigured or incompatible in the integrated environment

Pro Tip: Integration tests catch bugs that unit tests miss, they’re your safety net for component interactions.

Unit Testing vs Integration Testing

Understanding the differences helps you choose the right testing approach:

AspectUnit TestingIntegration Testing
ScopeIndividual methods/functionsMultiple components together
DependenciesMocked or stubbedReal or partially real
SpeedVery fast (milliseconds)Slower (seconds)
IsolationComplete isolationLimited isolation
PurposeVerify logic correctnessVerify component interaction
CostLow (quick to write/run)Higher (more complex setup)
DebuggingEasy (narrow scope)Harder (multiple components)
When to RunEvery buildRegular builds, pre-deployment

Visual Comparison

flowchart TB
    subgraph Unit["Unit Testing"]
        direction TB
        U1[Component A]
        U2[Component B]
        U3[Component C]
        UM1[Mock Dependencies]
        UM2[Mock Dependencies]
        UM3[Mock Dependencies]
        
        U1 -.->|uses| UM1
        U2 -.->|uses| UM2
        U3 -.->|uses| UM3
    end
    
    subgraph Integration["Integration Testing"]
        direction TB
        I1[Component A]
        I2[Component B]
        I3[Component C]
        ID[(Real Database)]
        
        I1 -->|calls| I2
        I2 -->|calls| I3
        I3 -->|reads/writes| ID
    end
    
    style U1 fill:#90EE90
    style U2 fill:#90EE90
    style U3 fill:#90EE90
    style I1 fill:#87CEEB
    style I2 fill:#87CEEB
    style I3 fill:#87CEEB

Testing Pyramid

The balanced approach to testing.

The testing pyramid is a concept that helps teams balance different types of tests:

graph TB
    subgraph Pyramid["Testing Pyramid"]
        E2E["End-to-End Tests<br/>(Few, Slow, Expensive)"]
        INT["Integration Tests<br/>(Moderate, Medium Speed)"]
        UNIT["Unit Tests<br/>(Many, Fast, Cheap)"]
    end
    
    E2E -.-> INT
    INT -.-> UNIT
    
    style E2E fill:#FFB6C1,stroke:#333,stroke-width:2px
    style INT fill:#87CEEB,stroke:#333,stroke-width:2px
    style UNIT fill:#90EE90,stroke:#333,stroke-width:2px

Guidelines:

  • 70% Unit tests - Fast feedback on logic
  • 20% Integration tests - Verify component interaction
  • 10% End-to-end tests - Validate complete workflows

This balance gives you confidence while keeping tests maintainable and fast.

Types of Integration Testing Approaches

Different projects benefit from different integration testing strategies:

1. Big Bang Approach

Big Bang integration testing combines all components at once and tests them together.

flowchart LR
    A[Component A]
    B[Component B]
    C[Component C]
    D[Component D]
    I[Integrated<br/>System]
    
    A --> I
    B --> I
    C --> I
    D --> I
    
    I --> T{Test All<br/>Together}
    
    style I fill:#FFB6C1
    style T fill:#FF6B6B

Advantages:

  • Simple approach
  • Quick for small systems
  • All components tested together immediately

Disadvantages:

  • Hard to isolate defects
  • Late defect detection
  • Complex debugging
  • High risk for large systems

When to use: Small applications with few components or when all components are developed simultaneously.

2. Top-Down Approach

Top-down testing starts with high-level components and progressively integrates lower-level components.

flowchart TB
    A[Main Module]
    B[Module B]
    C[Module C]
    D[Sub-module D]
    E[Sub-module E]
    F[Sub-module F]
    
    A --> B
    A --> C
    B --> D
    B --> E
    C --> F
    
    S1[Step 1: Test A with stubs]
    S2[Step 2: Integrate B, test A+B]
    S3[Step 3: Integrate C, test A+B+C]
    S4[Step 4: Integrate D,E,F]
    
    style A fill:#90EE90
    style B fill:#87CEEB
    style C fill:#87CEEB
    style D fill:#FFE4B5
    style E fill:#FFE4B5
    style F fill:#FFE4B5

Advantages:

  • Early validation of major functionality
  • Defects in high-level modules found early
  • Easier to demonstrate core features

Disadvantages:

  • Requires stubs for lower modules
  • Lower-level defects found late
  • Stubs need maintenance

When to use: When high-level logic is complex or when you need early demonstrations of core functionality.

3. Bottom-Up Approach

Bottom-up testing starts with low-level components and progressively integrates higher-level components.

flowchart BT
    D[Module D]
    E[Module E]
    F[Module F]
    B[Module B]
    C[Module C]
    A[Main Module]
    
    D --> B
    E --> B
    F --> C
    B --> A
    C --> A
    
    S1[Step 1: Test D, E, F independently]
    S2[Step 2: Integrate D+E into B]
    S3[Step 3: Integrate F into C]
    S4[Step 4: Integrate B+C into A]
    
    style D fill:#90EE90
    style E fill:#90EE90
    style F fill:#90EE90
    style B fill:#87CEEB
    style C fill:#87CEEB
    style A fill:#FFE4B5

Advantages:

  • Easy to create test conditions
  • Defects in critical modules found early
  • No stubs needed
  • Parallel testing possible

Disadvantages:

  • Requires drivers for top modules
  • High-level defects found late
  • Major flaws detected late

When to use: When lower-level components contain complex logic or when bottom modules are developed first.

4. Sandwich (Hybrid) Approach

Sandwich testing combines top-down and bottom-up approaches simultaneously.

flowchart TB
    subgraph Top["Top-Down Testing"]
        A[Main Module]
        B[Module B]
    end
    
    subgraph Middle["Target Integration Layer"]
        M[Middle Layer]
    end
    
    subgraph Bottom["Bottom-Up Testing"]
        D[Module D]
        E[Module E]
    end
    
    A --> B
    B --> M
    D --> M
    E --> M
    
    style A fill:#90EE90
    style B fill:#87CEEB
    style M fill:#FFB6C1
    style D fill:#87CEEB
    style E fill:#90EE90

Advantages:

  • Balances both approaches
  • Parallel testing of top and bottom
  • Better test coverage
  • Defects found at multiple levels

Disadvantages:

  • Most complex approach
  • Requires more resources
  • Coordination needed between teams

When to use: Large systems with multiple teams or when you need comprehensive early testing.

Pro Tip: Most modern projects use a hybrid approach, adapting the strategy based on component criticality and team structure.

When to Apply Integration Testing

Integration testing should occur at specific points in your development cycle:

Development Lifecycle Integration

flowchart LR
    A[Write Code] --> B[Unit Tests]
    B --> C[Integrate<br/>Components]
    C --> D[Integration<br/>Tests]
    D --> E{Tests Pass?}
    E -->|No| A
    E -->|Yes| F[Commit]
    F --> G[CI Pipeline]
    G --> H[Deploy]
    
    style B fill:#90EE90
    style D fill:#87CEEB
    style E fill:#FFB6C1

Integration Testing Scenarios

Test integration when:

  1. Multiple components interact
    • Service calls another service
    • Controller uses a repository
    • Business logic accesses a database
  2. External systems are involved
    • REST API calls
    • Database operations
    • Message queues
    • File system operations
  3. Data flows between layers
    • Request → Controller → Service → Repository → Database
    • API → Parser → Validator → Storage
  4. Configuration matters
    • Database connection settings
    • API endpoints
    • Security configurations

Important: Don’t integration test everything! Focus on critical paths and component boundaries.

Practical Java Examples

Let’s build a realistic example using Spring Boot to demonstrate integration testing.

Example Application Structure

We’ll test a simple OrderService that integrates with a database and an external payment service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// Domain Model
public class Order {
    private Long id;
    private String customerName;
    private Double amount;
    private String status; // PENDING, PAID, FAILED
    
    // Constructors, getters, setters
    public Order(String customerName, Double amount) {
        this.customerName = customerName;
        this.amount = amount;
        this.status = "PENDING";
    }
    
    // Getters and setters omitted for brevity
}

// Repository Layer
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    List<Order> findByStatus(String status);
    Optional<Order> findByCustomerName(String customerName);
}

// Service Layer
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
    
    @Autowired
    public OrderService(OrderRepository orderRepository, 
                       PaymentService paymentService) {
        this.orderRepository = orderRepository;
        this.paymentService = paymentService;
    }
    
    public Order createOrder(String customerName, Double amount) {
        Order order = new Order(customerName, amount);
        return orderRepository.save(order);
    }
    
    public Order processPayment(Long orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
            
        boolean paymentSuccess = paymentService.processPayment(
            order.getCustomerName(), 
            order.getAmount()
        );
        
        order.setStatus(paymentSuccess ? "PAID" : "FAILED");
        return orderRepository.save(order);
    }
    
    public List<Order> getPaidOrders() {
        return orderRepository.findByStatus("PAID");
    }
}

// External Payment Service
@Service
public class PaymentService {
    public boolean processPayment(String customerName, Double amount) {
        // In real application, this would call external payment API
        // For testing, we'll use a test double
        return true;
    }
}

Integration Test Example

Now let’s write integration tests using Spring Boot Test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
@Transactional
class OrderServiceIntegrationTest {
    
    @Autowired
    private OrderService orderService;
    
    @Autowired
    private OrderRepository orderRepository;
    
    @MockBean // Mock the external payment service
    private PaymentService paymentService;
    
    @Test
    void shouldCreateAndSaveOrder() {
        // Given
        String customerName = "John Doe";
        Double amount = 99.99;
        
        // When
        Order createdOrder = orderService.createOrder(customerName, amount);
        
        // Then
        assertNotNull(createdOrder.getId());
        assertEquals(customerName, createdOrder.getCustomerName());
        assertEquals(amount, createdOrder.getAmount());
        assertEquals("PENDING", createdOrder.getStatus());
        
        // Verify it's actually in the database
        Optional<Order> foundOrder = orderRepository.findById(createdOrder.getId());
        assertTrue(foundOrder.isPresent());
        assertEquals(customerName, foundOrder.get().getCustomerName());
    }
    
    @Test
    void shouldProcessPaymentSuccessfully() {
        // Given
        Order order = orderService.createOrder("Jane Smith", 150.00);
        when(paymentService.processPayment(anyString(), anyDouble()))
            .thenReturn(true);
        
        // When
        Order processedOrder = orderService.processPayment(order.getId());
        
        // Then
        assertEquals("PAID", processedOrder.getStatus());
        verify(paymentService).processPayment("Jane Smith", 150.00);
        
        // Verify database is updated
        Order dbOrder = orderRepository.findById(order.getId()).orElseThrow();
        assertEquals("PAID", dbOrder.getStatus());
    }
    
    @Test
    void shouldHandlePaymentFailure() {
        // Given
        Order order = orderService.createOrder("Bob Johnson", 200.00);
        when(paymentService.processPayment(anyString(), anyDouble()))
            .thenReturn(false);
        
        // When
        Order processedOrder = orderService.processPayment(order.getId());
        
        // Then
        assertEquals("FAILED", processedOrder.getStatus());
        
        // Verify database reflects failure
        Order dbOrder = orderRepository.findById(order.getId()).orElseThrow();
        assertEquals("FAILED", dbOrder.getStatus());
    }
    
    @Test
    void shouldRetrievePaidOrders() {
        // Given - Create multiple orders with different statuses
        Order order1 = orderService.createOrder("Alice", 100.00);
        Order order2 = orderService.createOrder("Charlie", 200.00);
        Order order3 = orderService.createOrder("Diana", 300.00);
        
        // Process payments with different outcomes
        when(paymentService.processPayment(anyString(), anyDouble()))
            .thenReturn(true);
        
        orderService.processPayment(order1.getId());
        orderService.processPayment(order2.getId());
        
        // When
        List<Order> paidOrders = orderService.getPaidOrders();
        
        // Then
        assertEquals(2, paidOrders.size());
        assertTrue(paidOrders.stream()
            .allMatch(order -> order.getStatus().equals("PAID")));
    }
    
    @Test
    void shouldThrowExceptionForNonexistentOrder() {
        // Given
        Long nonexistentId = 99999L;
        
        // When & Then
        assertThrows(OrderNotFoundException.class, () -> {
            orderService.processPayment(nonexistentId);
        });
    }
}

REST Controller Integration Test

Testing the web layer with database integration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class OrderControllerIntegrationTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @Autowired
    private OrderRepository orderRepository;
    
    @MockBean
    private PaymentService paymentService;
    
    @Test
    void shouldCreateOrderViaAPI() throws Exception {
        // Given
        String orderJson = """
            {
                "customerName": "John Doe",
                "amount": 99.99
            }
            """;
        
        // When & Then
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(orderJson))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").exists())
            .andExpect(jsonPath("$.customerName").value("John Doe"))
            .andExpect(jsonPath("$.amount").value(99.99))
            .andExpect(jsonPath("$.status").value("PENDING"));
        
        // Verify database
        assertEquals(1, orderRepository.count());
    }
    
    @Test
    void shouldProcessPaymentViaAPI() throws Exception {
        // Given
        Order order = new Order("Jane Smith", 150.00);
        order = orderRepository.save(order);
        
        when(paymentService.processPayment(anyString(), anyDouble()))
            .thenReturn(true);
        
        // When & Then
        mockMvc.perform(post("/api/orders/{id}/payment", order.getId()))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.status").value("PAID"));
        
        // Verify database updated
        Order updatedOrder = orderRepository.findById(order.getId()).orElseThrow();
        assertEquals("PAID", updatedOrder.getStatus());
    }
    
    @Test
    void shouldGetPaidOrders() throws Exception {
        // Given - Create test data
        Order order1 = orderRepository.save(new Order("Alice", 100.00));
        order1.setStatus("PAID");
        orderRepository.save(order1);
        
        Order order2 = orderRepository.save(new Order("Bob", 200.00));
        order2.setStatus("PENDING");
        orderRepository.save(order2);
        
        // When & Then
        mockMvc.perform(get("/api/orders/paid"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$", hasSize(1)))
            .andExpect(jsonPath("$[0].customerName").value("Alice"))
            .andExpect(jsonPath("$[0].status").value("PAID"));
    }
}

Test Configuration

Setting up integration test dependencies.

For Spring Boot integration tests, add these dependencies to your pom.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<dependencies>
    <!-- Spring Boot Test -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    
    <!-- H2 In-Memory Database for Testing -->
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>test</scope>
    </dependency>
    
    <!-- Additional test utilities -->
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-core</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

And configure application-test.properties:

1
2
3
4
5
6
7
8
9
10
11
# Use H2 in-memory database for tests
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect

# Show SQL for debugging
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=create-drop

# Disable external service calls in test
app.payment.external.enabled=false

Integration Testing Flow

Here’s how integration tests execute in a typical scenario:

sequenceDiagram
    participant Test as Integration Test
    participant Controller as REST Controller
    participant Service as Order Service
    participant Repo as Repository
    participant DB as H2 Database
    participant External as Payment Service<br/>(Mocked)
    
    Test->>Controller: POST /api/orders
    Controller->>Service: createOrder()
    Service->>Repo: save(order)
    Repo->>DB: INSERT INTO orders
    DB-->>Repo: Order saved
    Repo-->>Service: Order object
    Service-->>Controller: Order created
    Controller-->>Test: 201 Created
    
    Test->>Test: Verify response
    Test->>DB: Query database
    DB-->>Test: Confirm order exists
    
    Test->>Controller: POST /api/orders/{id}/payment
    Controller->>Service: processPayment()
    Service->>Repo: findById()
    Repo->>DB: SELECT order
    DB-->>Repo: Order found
    Repo-->>Service: Order object
    Service->>External: processPayment()
    External-->>Service: true (mocked)
    Service->>Repo: save(updatedOrder)
    Repo->>DB: UPDATE orders
    DB-->>Repo: Updated
    Repo-->>Service: Updated order
    Service-->>Controller: Order with PAID status
    Controller-->>Test: 200 OK
    
    Test->>Test: Assert status = PAID

Common Pitfalls and Best Practices

Pitfalls to Avoid

1. Testing Too Much

Integration tests shouldn’t duplicate unit test coverage.

Problem: Writing integration tests that verify individual method logic already covered by unit tests.

Bad Example:

1
2
3
4
5
6
7
@Test
void shouldCalculateOrderTotal() {
    // This is unit test logic in an integration test
    Order order = new Order("Customer", 100.0);
    order.addTax(0.1);
    assertEquals(110.0, order.getTotal());
}

Good Example:

1
2
3
4
5
6
7
8
@Test
void shouldSaveOrderWithCalculatedTotal() {
    // Focus on integration: does the total persist correctly?
    Order order = orderService.createOrderWithTax("Customer", 100.0, 0.1);
    
    Order saved = orderRepository.findById(order.getId()).orElseThrow();
    assertEquals(110.0, saved.getTotal());
}

Rule: Integration tests verify component interactions, not individual component logic.

2. Shared Test State

Tests that depend on execution order fail unpredictably.

Problem: Tests that rely on data created by other tests.

Bad Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@SpringBootTest
class OrderTests {
    static Order sharedOrder; // Dangerous!
    
    @Test
    void test1_CreateOrder() {
        sharedOrder = orderService.createOrder("John", 100.0);
    }
    
    @Test
    void test2_ProcessOrder() {
        // Fails if test1 doesn't run first!
        orderService.processPayment(sharedOrder.getId());
    }
}

Good Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@SpringBootTest
@Transactional // Rolls back after each test
class OrderTests {
    
    @Test
    void shouldProcessOrder() {
        // Each test creates its own data
        Order order = orderService.createOrder("John", 100.0);
        orderService.processPayment(order.getId());
        
        Order result = orderRepository.findById(order.getId()).orElseThrow();
        assertEquals("PAID", result.getStatus());
    }
}

Rule: Each test should be completely independent and create its own data.

3. Not Mocking External Services

Tests become slow and unreliable.

Problem: Calling real external APIs or services in integration tests.

Bad Example:

1
2
3
4
5
6
@Test
void shouldProcessRealPayment() {
    // Don't do this! Calls real payment gateway
    Order order = orderService.createOrder("John", 100.0);
    orderService.processPayment(order.getId()); // Real external call
}

Good Example:

1
2
3
4
5
6
7
8
9
10
11
@MockBean
private PaymentService paymentService;

@Test
void shouldProcessPayment() {
    when(paymentService.processPayment(anyString(), anyDouble()))
        .thenReturn(true);
    
    Order order = orderService.createOrder("John", 100.0);
    orderService.processPayment(order.getId());
}

Rule: Mock external boundaries (APIs, third-party services) but keep internal integrations real (database, message queues).

4. Ignoring Test Database State

Tests fail due to data pollution.

Problem: Not cleaning up test data between tests.

Solution Options:

Option 1: Use @Transactional (Recommended)

1
2
3
4
5
@SpringBootTest
@Transactional // Automatically rolls back after each test
class OrderTests {
    // Tests here
}

Option 2: Use @DirtiesContext (Slower)

1
2
3
4
5
@SpringBootTest
@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD)
class OrderTests {
    // Tests here - context rebuilt each time
}

Option 3: Manual Cleanup

1
2
3
4
@AfterEach
void cleanup() {
    orderRepository.deleteAll();
}

Rule: Always ensure clean database state between tests.

Best Practices

Follow these practices to write effective integration tests:

  1. Test Real Interactions
    • Use real databases (H2, TestContainers)
    • Mock only external boundaries
    • Test actual HTTP requests for APIs
  2. Keep Tests Fast
    • Use in-memory databases
    • Mock slow external services
    • Run integration tests in parallel when possible
    • Aim for tests under 5 seconds each
  3. Focus on Critical Paths
    • Test main user workflows
    • Test error handling across layers
    • Test data persistence and retrieval
    • Don’t test every permutation (that’s for unit tests)
  4. Use Proper Test Data
    • Create realistic test scenarios
    • Use test data builders for complex objects
    • Include edge cases (null, empty, boundary values)
  5. Verify Side Effects
    1
    2
    3
    4
    5
    6
    7
    
    @Test
    void shouldSendEmailWhenOrderPlaced() {
        Order order = orderService.createOrder("John", 100.0);
           
        // Verify the integration effect
        verify(emailService).sendOrderConfirmation("John", order.getId());
    }
    
  6. Test Configuration
    • Use separate test configuration
    • Test with realistic data volumes
    • Verify database constraints and indexes
  7. Use TestContainers for Real Dependencies
    1
    2
    3
    4
    5
    
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");
    

Debugging Integration Test Failures

When integration tests fail, follow this systematic approach:

flowchart TD
    Start[Test Fails] --> Check1{Is it consistent?}
    Check1 -->|No| Timing[Check for timing issues]
    Check1 -->|Yes| Check2{Does unit test pass?}
    
    Check2 -->|No| Unit[Fix unit test first]
    Check2 -->|Yes| Check3{Check test isolation}
    
    Check3 --> Data{Data state correct?}
    Data -->|No| Clean[Add cleanup or Transactional]
    Data -->|Yes| Config{Config correct?}
    
    Config -->|No| FixConfig[Update test configuration]
    Config -->|Yes| Mock{Mock setup correct?}
    
    Mock -->|No| FixMock[Fix mock behavior]
    Mock -->|Yes| Integration[Real integration issue found]
    
    Timing --> Await[Add explicit waits]
    Unit --> Retest[Rerun integration test]
    Clean --> Retest
    FixConfig --> Retest
    FixMock --> Retest
    Await --> Retest
    Integration --> Fix[Fix actual bug]
    
    style Start fill:#FFB6C1
    style Integration fill:#90EE90
    style Fix fill:#87CEEB

Debugging Tips

  1. Enable SQL Logging
    1
    2
    
    spring.jpa.show-sql=true
    logging.level.org.hibernate.SQL=DEBUG
    
  2. Use Debugger Breakpoints
    • Set breakpoints in service methods
    • Inspect database state during test execution
    • Step through integration points
  3. Check Test Logs
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    @Slf4j
    @SpringBootTest
    class OrderTests {
        @Test
        void shouldProcessOrder() {
            log.info("Starting test with order creation");
            Order order = orderService.createOrder("John", 100.0);
            log.info("Order created with ID: {}", order.getId());
            // ...
        }
    }
    
  4. Verify Test Data
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    @Test
    void debugTest() {
        Order order = orderService.createOrder("John", 100.0);
           
        // Print database state for debugging
        List<Order> allOrders = orderRepository.findAll();
        allOrders.forEach(o -> 
            System.out.println("Order: " + o.getId() + " - " + o.getStatus())
        );
    }
    

Real-World Integration Testing Scenarios

Scenario 1: Testing Database Transactions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
void shouldRollbackOnPaymentFailure() {
    // Given
    Order order = orderService.createOrder("John", 100.0);
    when(paymentService.processPayment(anyString(), anyDouble()))
        .thenThrow(new PaymentException("Payment gateway down"));
    
    // When
    assertThrows(PaymentException.class, () -> {
        orderService.processPayment(order.getId());
    });
    
    // Then - Order status should remain PENDING (transaction rolled back)
    Order unchangedOrder = orderRepository.findById(order.getId()).orElseThrow();
    assertEquals("PENDING", unchangedOrder.getStatus());
}

Scenario 2: Testing API Pagination

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
void shouldPaginateOrders() throws Exception {
    // Given - Create 25 orders
    for (int i = 0; i < 25; i++) {
        orderRepository.save(new Order("Customer" + i, 100.0));
    }
    
    // When & Then - First page
    mockMvc.perform(get("/api/orders?page=0&size=10"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.content", hasSize(10)))
        .andExpect(jsonPath("$.totalElements").value(25))
        .andExpect(jsonPath("$.totalPages").value(3));
    
    // Last page
    mockMvc.perform(get("/api/orders?page=2&size=10"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.content", hasSize(5)));
}

Scenario 3: Testing Concurrent Access

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Test
void shouldHandleConcurrentOrderCreation() throws Exception {
    // Given
    int numberOfThreads = 10;
    ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads);
    CountDownLatch latch = new CountDownLatch(numberOfThreads);
    
    // When - Create orders concurrently
    for (int i = 0; i < numberOfThreads; i++) {
        final int index = i;
        executor.submit(() -> {
            try {
                orderService.createOrder("Customer" + index, 100.0);
            } finally {
                latch.countDown();
            }
        });
    }
    
    latch.await(10, TimeUnit.SECONDS);
    executor.shutdown();
    
    // Then - All orders should be created
    List<Order> orders = orderRepository.findAll();
    assertEquals(numberOfThreads, orders.size());
}

Conclusion

Integration testing is a crucial skill that bridges the gap between unit testing and end-to-end testing. While unit tests verify individual components work correctly, integration tests ensure these components cooperate properly in realistic scenarios.

Key Takeaways

  • Purpose: Integration tests verify component interactions and data flow
  • Scope: Test multiple components together with real or partially real dependencies
  • Approach: Choose the right strategy (Big Bang, Top-Down, Bottom-Up, or Sandwich) based on your project
  • Balance: Follow the testing pyramid, more unit tests, moderate integration tests, few end-to-end tests
  • Focus: Test critical paths and boundary interactions, not every permutation
  • Speed: Keep tests fast using in-memory databases and mocking external services
  • Isolation: Ensure tests are independent and don’t share state

Remember: Integration tests complement unit tests, they don’t replace them. Together they give you confidence that your application works correctly both in parts and as a whole.

Next Steps

Now that you understand integration testing, consider exploring:

  • Test Containers - Run real databases in Docker for more realistic tests
  • Contract Testing - Verify API contracts between services
  • Performance Testing - Ensure integrations perform under load
  • Acceptance Testing - Validate complete user workflows

Start small, add integration tests to your most critical workflows first, then expand coverage as you gain experience. Happy testing!

This post is licensed under CC BY 4.0 by the author.