Unit Testing vs Integration Testing vs System Testing in Java

“We have 90% test coverage” means nothing if 90% of your tests are mocking away the parts that matter. A codebase full of unit tests that mock every dependency can have excellent coverage numbers while completely missing bugs that manifest when real components interact. Understanding the difference between unit tests, integration tests, and system tests — and when to use each — is the foundation of a test suite that actually finds bugs.


What You’ll Learn

  • What unit tests, integration tests, and system tests are — and what each can and cannot catch
  • The testing pyramid and how to apply it to a Spring Boot application
  • Practical cost/benefit analysis for each test level
  • How Testcontainers fits into the integration test layer
  • Organizing your Maven/Gradle build for multiple test levels
  • Anti-patterns: test suites that look good but do not catch real bugs

The Testing Pyramid

The testing pyramid is a guide to test suite composition:

            ┌──────────────────┐
            │  System Tests    │  5–10%
            │   (End-to-End)   │  slow, expensive, high coverage
           ┌┴──────────────────┴┐
           │  Integration Tests  │  20–30%
           │  (Component + DB)   │  moderate speed, catches wiring bugs
          ┌┴────────────────────┴┐
          │     Unit Tests        │  60–70%
          │ (Single class/method) │  fast, cheap, high volume
          └──────────────────────┘

Each level has a distinct purpose, cost profile, and category of bugs it can catch.


Unit Tests

What They Are

A unit test tests a single unit of behavior — typically a single class or method — in isolation from all dependencies. External dependencies (databases, message brokers, external APIs) are replaced with test doubles (mocks, stubs, fakes).

class OrderServiceUnitTest {

    private OrderRepository orderRepository = mock(OrderRepository.class);
    private OrderEventPublisher eventPublisher = mock(OrderEventPublisher.class);
    private OrderService orderService = new OrderService(orderRepository, eventPublisher);

    @Test
    void shouldCalculateTotalWithDiscount() {
        // Arrange
        CreateOrderRequest request = new CreateOrderRequest(
            "customer-1",
            List.of(
                new OrderItem("product-1", 2, BigDecimal.valueOf(49.99)),
                new OrderItem("product-2", 1, BigDecimal.valueOf(19.99))
            )
        );

        // Act
        BigDecimal total = orderService.calculateTotal(request.items());

        // Assert
        assertThat(total).isEqualByComparingTo(BigDecimal.valueOf(119.97));
    }

    @Test
    void shouldApplyTenPercentDiscountForOrdersOver100() {
        List<OrderItem> items = List.of(
            new OrderItem("product-1", 3, BigDecimal.valueOf(50.00))
        );  // total = $150.00

        BigDecimal discountedTotal = orderService.applyDiscount(BigDecimal.valueOf(150.00));

        assertThat(discountedTotal).isEqualByComparingTo(BigDecimal.valueOf(135.00));
    }

    @Test
    void shouldPublishOrderCreatedEventOnSuccessfulOrder() {
        when(orderRepository.save(any())).thenAnswer(inv -> {
            Order o = inv.getArgument(0);
            o.setId(1L);
            return o;
        });

        CreateOrderRequest request = new CreateOrderRequest("customer-1",
            List.of(new OrderItem("product-1", 1, BigDecimal.valueOf(99.99))));

        orderService.createOrder(request);

        verify(eventPublisher).publishOrderCreated(any(Order.class));
    }
}

What Unit Tests Catch

  • Logic errors in calculation (discount logic, tax calculation, pricing)
  • Edge cases in business rules (null inputs, boundary values, overflow)
  • Incorrect conditional logic (if/else, switch statements)
  • Algorithm correctness

What Unit Tests Cannot Catch

  • Database query correctness (mocked findById always returns what you told it to)
  • Integration wiring (the bean is mis-configured, the query is wrong, the serialization breaks)
  • Transaction behavior (rollback, isolation level effects)
  • Constraint violations
  • Network behavior (timeouts, retries, circuit breakers under real load)

When to Write Unit Tests

Write unit tests for every piece of business logic. Business logic includes:

  • Calculation and transformation methods
  • Validation logic
  • State machine transitions
  • Decision trees and conditional logic
  • Algorithm implementations

Do not write unit tests for:

  • Simple getters/setters with no logic
  • Configuration classes
  • Controller classes (test these with @WebMvcTest)
  • Repository interfaces (test these with @DataJpaTest)

Integration Tests

What They Are

Integration tests verify that multiple components work correctly together — your service class with its real repository, your repository with the real database, your Kafka consumer with the real broker.

Integration tests use real dependencies — or at minimum, dependencies that behave exactly like real ones (Testcontainers).

@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    @BeforeEach
    void cleanup() {
        orderRepository.deleteAll();
    }

    @Test
    void shouldCreateOrderAndPersistToDatabase() {
        CreateOrderRequest request = new CreateOrderRequest(
            "customer-1",
            List.of(new OrderItem("product-1", 2, BigDecimal.valueOf(49.99)))
        );

        Order created = orderService.createOrder(request);

        assertThat(created.getId()).isNotNull();
        assertThat(created.getStatus()).isEqualTo(OrderStatus.PENDING);

        // Verify it was actually persisted — not just returned from a mock
        Optional<Order> fromDb = orderRepository.findById(created.getId());
        assertThat(fromDb).isPresent();
        assertThat(fromDb.get().getCustomerId()).isEqualTo("customer-1");
    }

    @Test
    void shouldRollbackTransactionOnFailure() {
        // Simulate a failure mid-transaction
        assertThatThrownBy(() ->
            orderService.createOrderWithInvalidData(new CreateOrderRequest(null, List.of()))
        ).isInstanceOf(ConstraintViolationException.class);

        // Database should have no partial data
        assertThat(orderRepository.count()).isEqualTo(0);
    }
}

What Integration Tests Catch

  • Database query behavior (wrong SQL, wrong column names, wrong joins)
  • Transaction rollback behavior
  • ORM mapping issues (wrong column types, missing nullable annotations)
  • Database constraint enforcement
  • Real service-to-service wiring
  • Configuration mistakes (wrong property names, missing beans)
  • Migration correctness

When to Write Integration Tests

Write integration tests for:

  • Every repository or DAO class
  • Service classes with database interactions
  • Kafka producers/consumers
  • REST client integrations (with WireMock)
  • Security configuration
  • Database migration scripts

Do not write integration tests for pure business logic — that is what unit tests are for.


System Tests (End-to-End Tests)

What They Are

System tests test the entire deployed system from the outside — making HTTP requests to the running application and verifying responses, without any knowledge of the internal implementation.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderApiSystemTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    @Container
    @ServiceConnection
    static KafkaContainer kafka =
        new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void completeOrderLifecycle() {
        // Step 1: Create an order
        CreateOrderRequest createRequest = new CreateOrderRequest(
            "customer-1",
            List.of(new OrderItem("product-1", 2, BigDecimal.valueOf(49.99)))
        );

        ResponseEntity<OrderResponse> created = restTemplate.postForEntity(
            "http://localhost:" + port + "/api/v1/orders",
            createRequest,
            OrderResponse.class
        );

        assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        Long orderId = created.getBody().getId();

        // Step 2: Verify the order is retrievable
        ResponseEntity<OrderResponse> fetched = restTemplate.getForEntity(
            "http://localhost:" + port + "/api/v1/orders/" + orderId,
            OrderResponse.class
        );

        assertThat(fetched.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(fetched.getBody().getStatus()).isEqualTo("PENDING");

        // Step 3: Confirm the order
        ResponseEntity<OrderResponse> confirmed = restTemplate.exchange(
            "http://localhost:" + port + "/api/v1/orders/" + orderId + "/confirm",
            HttpMethod.POST,
            HttpEntity.EMPTY,
            OrderResponse.class
        );

        assertThat(confirmed.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(confirmed.getBody().getStatus()).isEqualTo("CONFIRMED");

        // Step 4: Verify Kafka event was published
        // (with Awaitility polling the consumer)
    }
}

What System Tests Catch

  • End-to-end HTTP request/response flows
  • Response format and status codes
  • Full integration of all layers
  • Real deployment configuration
  • Pagination, filtering, and sorting behavior via the API

When to Write System Tests

System tests are expensive — slow to run, harder to debug, sensitive to environmental factors. Write system tests for:

  • Critical business flows (order creation → payment → fulfillment)
  • API contract verification (response shape, status codes)
  • Security flows (authentication, authorization)

Do not write system tests for edge cases — those belong in unit and integration tests.


Test Suite Organization

Maven Multi-Profile Setup

Separate unit tests and integration tests into Maven profiles:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <!-- runs unit tests (excludes *IT.java, *SystemTest.java) -->
            <configuration>
                <excludes>
                    <exclude>**/*IT.java</exclude>
                    <exclude>**/*SystemTest.java</exclude>
                </excludes>
            </configuration>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-failsafe-plugin</artifactId>
            <!-- runs integration and system tests -->
            <configuration>
                <includes>
                    <include>**/*IT.java</include>
                    <include>**/*SystemTest.java</include>
                </includes>
            </configuration>
            <executions>
                <execution>
                    <goals>
                        <goal>integration-test</goal>
                        <goal>verify</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

Gradle Setup

// Separate source set for integration tests
sourceSets {
    create("integrationTest") {
        java.srcDir("src/integrationTest/java")
        resources.srcDir("src/integrationTest/resources")
        compileClasspath += sourceSets.main.get().output + configurations.testRuntimeClasspath.get()
        runtimeClasspath += output + compileClasspath
    }
}

val integrationTest by tasks.registering(Test::class) {
    testClassesDirs = sourceSets["integrationTest"].output.classesDirs
    classpath = sourceSets["integrationTest"].runtimeClasspath
    useJUnitPlatform()
}

tasks.check { dependsOn(integrationTest) }

Naming Convention

Consistent naming makes it easy to run specific test levels:

LevelSuffixExample
UnitTestOrderServiceTest
IntegrationITOrderRepositoryIT
SystemSystemTestOrderApiSystemTest

Anti-Patterns

The mock pyramid (inverted pyramid). Too many unit tests mocking everything, too few integration tests. Coverage looks high, but real integrations are untested.

Testing the mock. Writing tests that assert what you told the mock to return: when(repo.findById(1)).thenReturn(order); assertThat(service.findById(1)).isEqualTo(order); — this tests the mock, not the service.

One integration test to rule them all. A single @SpringBootTest test that covers every scenario by calling services directly. This is slow, hard to debug, and tests multiple things at once.

Using @SpringBootTest for repository tests. @DataJpaTest is faster and loads less. Reserve @SpringBootTest for tests that need the full application context.


Summary

Unit tests verify business logic in isolation. Integration tests verify that real components work together. System tests verify end-to-end behavior from the outside. A healthy test suite has many fast unit tests (60–70%), a solid layer of integration tests backed by Testcontainers (20–30%), and a few system tests for critical paths (5–10%).

The next article covers Spring Boot test slices — how to use @DataJpaTest, @WebMvcTest, and @SpringBootTest to test specific layers efficiently.

Next: Spring Boot Testing Slices