Senior 8 min · March 30, 2026
Unit Testing vs Integration Testing: Key Differences

Unit Test vs Integration Test — The 8-Min Suite Nobody Ran

Default test build >30 seconds? Developers won't run it.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.

Follow
Production
production tested
May 23, 2026
last updated
1,663
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Unit tests verify a single class/method in isolation (milliseconds).
  • Integration tests verify that components work together with real dependencies (seconds).
  • The test pyramid: 70-80% unit tests, 15-20% integration tests, 5-10% E2E.
  • Mock external systems in unit tests; use real substitutes in integration tests.
  • Keep integration tests in a separate Maven profile so mvn test stays fast.
  • Biggest mistake: mocking your own domain logic — you're not testing your code.
✦ Definition~90s read
What is Unit Testing vs Integration Testing?

Unit tests and integration tests are two complementary layers of automated testing that serve fundamentally different purposes. A unit test isolates a single function, method, or class—mocking all external dependencies—to verify that the logic behaves correctly in a vacuum.

A unit test is like testing each brick individually — does this brick have the right shape and strength?

An integration test, by contrast, exercises the real interactions between components: database queries, API calls, file I/O, or message queues. The distinction isn't academic—it's practical. Unit tests catch logic bugs in milliseconds and run in CI without external infrastructure.

Integration tests catch wiring bugs: wrong column names, missing environment variables, serialization mismatches, or third-party API contract changes. Neither replaces the other; a suite with only unit tests gives false confidence, while one with only integration tests is too slow and brittle to run frequently.

The test pyramid—popularized by Mike Cohn and refined by Google's testing at scale—prescribes many unit tests, fewer integration tests, and even fewer end-to-end tests. At Google, roughly 80% of tests are unit, 15% integration, and 5% end-to-end. This ratio isn't arbitrary: unit tests cost ~1ms each and require no setup; integration tests cost 100ms–10s each and need databases, caches, or mock servers.

When your suite takes 8 minutes to run and nobody runs it before merging, you've likely inverted the pyramid—too many slow integration tests, too few fast unit tests. The fix isn't to delete integration tests but to mock aggressively at the unit level and reserve real dependencies for targeted integration checks.

In practice, a balanced suite means: mock everything outside your process boundary in unit tests (HTTP clients, databases, file systems), but never mock what you own. For integration tests, use real databases in Docker containers (Testcontainers), real test doubles for external APIs (WireMock), and real message brokers (embedded Kafka or RabbitMQ).

The rule of thumb: if a test touches a database, it's an integration test—label it as such and run it in a separate CI job. Tools like Jest, pytest, and JUnit support test categorization via tags or suffixes (.unit.test.ts vs .integration.test.ts).

The goal is a suite that runs in under 30 seconds locally and under 3 minutes in CI, giving you confidence that both the logic and the wiring work before you push.

Plain-English First

A unit test is like testing each brick individually — does this brick have the right shape and strength? An integration test is checking whether the wall holds together — do the bricks, mortar, and foundation work as a system? You need both. Bricks that pass individually can still produce a wall that falls.

The debate about unit vs integration tests in Java circles usually generates more heat than light. Teams swing between extremes: pure unit tests with mocks for everything (fast, but tests that pass while production breaks), or end-to-end integration tests for everything (thorough, but so slow nobody runs them before pushing).

The right answer is a test pyramid with intent. Unit tests are your fast feedback loop. Integration tests are your confidence that things wire together correctly. The mistake is writing integration tests where unit tests suffice, or writing unit tests that mock so much they test nothing real.

Why Your Test Suite Needs Both Unit and Integration Tests

A unit test validates a single unit of behavior in isolation — typically one method or class, with all external dependencies replaced by test doubles. An integration test verifies that multiple units work together correctly against real or near-real dependencies (database, network, filesystem). The core mechanic is scope: unit tests answer "does this logic work?" while integration tests answer "do these components cooperate?"

In practice, unit tests run in milliseconds, require no external infrastructure, and give you precise failure localization — a red test points directly to the broken logic. Integration tests take seconds to minutes, depend on databases or services, and catch contract mismatches, serialization errors, and state leaks that unit tests miss entirely. A healthy suite has a 10:1 unit-to-integration ratio, not because integration tests are bad, but because they're expensive.

Use unit tests for business logic, validation, and algorithmic correctness. Use integration tests for data access, API contracts, and cross-service flows. Without both, you either ship bugs that pass unit tests but fail in staging, or you have a suite so slow nobody runs it before merging.

The Trap of 100% Unit Coverage
A service with 100% unit coverage can still fail catastrophically in production because no test ever checked that the repository actually writes to the database.
Production Insight
Team ships a payment service with 95% unit coverage but zero integration tests.
First production incident: a null pointer in the ORM mapping because the entity field name changed in a migration but the repository query string didn't.
Rule: every external boundary (DB, queue, HTTP client) must have at least one integration test that exercises the real I/O path.
Key Takeaway
Unit tests prove your code does what you think it does; integration tests prove your code works with the actual infrastructure.
A test that never touches the database cannot prove your data layer is correct.
If your integration tests take longer than 10 minutes, you have a design problem, not a test problem.
Unit vs Integration Test: The Balanced Suite THECODEFORGE.IO Unit vs Integration Test: The Balanced Suite How to combine isolated logic tests with wiring tests for reliable CI Unit Tests Isolate one piece of logic; fast, deterministic Integration Tests Test real wiring between components Test Pyramid 80% unit, 20% integration at the base Mocking Strategy Mock external boundaries, not internals Balanced Suite Fast feedback + realistic coverage ⚠ Over-mocking hides real wiring bugs Prefer real instances for integration tests; mock only at I/O boundaries THECODEFORGE.IO
thecodeforge.io
Unit vs Integration Test: The Balanced Suite
Unit Test Vs Integration Test

Unit Tests: Isolating One Piece of Logic

A unit test verifies a single unit of behaviour in complete isolation from its dependencies. 'Unit' in practice means a single method or a small group of closely related methods in one class. All external dependencies — databases, HTTP calls, file system, other classes — are replaced with mocks or stubs.

Unit tests are fast because they run in memory with no I/O. A suite of 500 well-written unit tests should complete in under 5 seconds. If your unit tests take longer, you're doing integration work inside them.

The thing most developers miss: what you mock matters. Mock external systems (databases, APIs, message queues). Don't mock value objects, domain logic, or anything you own that's cheap to instantiate. Over-mocking tests the mock configuration, not the code.

PaymentAmountCalculatorTest.javaJAVA
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
package io.thecodeforge.payment;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

// UNIT TEST — no database, no HTTP, no external dependencies
class PaymentAmountCalculatorTest {

    @Test
    void calculate_appliesDiscountForGoldTier() {
        // Arrange — mock only the external dependency (discount lookup)
        DiscountService discountService = mock(DiscountService.class);
        when(discountService.getDiscountRate("GOLD")).thenReturn(0.15);

        PaymentAmountCalculator calculator =
            new PaymentAmountCalculator(discountService);

        // Act
        long result = calculator.calculate(100_00, "GOLD"); // £100.00

        // Assert
        assertEquals(85_00, result); // 15% off = £85.00
        verify(discountService, times(1)).getDiscountRate("GOLD");
    }

    @Test
    void calculate_noDiscountForUnknownTier() {
        DiscountService discountService = mock(DiscountService.class);
        when(discountService.getDiscountRate("UNKNOWN")).thenReturn(0.0);

        PaymentAmountCalculator calculator =
            new PaymentAmountCalculator(discountService);

        assertEquals(100_00, calculator.calculate(100_00, "UNKNOWN"));
    }
}
// Runs in: ~50ms
// Tests: pure business logic, isolated from real DiscountService implementation
Output
PaymentAmountCalculatorTest > calculate_appliesDiscountForGoldTier PASSED
PaymentAmountCalculatorTest > calculate_noDiscountForUnknownTier PASSED
Tests run: 2, Failures: 0, Time elapsed: 0.08 s
Production Insight
A unit test that mocks a repository method but forgets to stub the findById case will pass with a null return — the code then throws a NullPointerException in production.
The fix: use Optional.ofNullable() in your code or default stubs with lenient().
Rule: if your unit test doesn't exercise every return path of the mocked dependency, it's a false pass.
Key Takeaway
Unit tests verify your logic, not your mock setup.
Mock external systems, not your own domain classes.
Keep them under 1 second per 100 tests — if they're slower, you're doing integration work inside them.

Integration Tests: Testing the Wiring

An integration test uses real implementations — a real database (or embedded/test container), real HTTP clients, real message queue — to verify that the components wire together correctly.

Integration tests are slower (seconds per test vs milliseconds) but catch a class of bugs unit tests can't: mismatched data types between your ORM and the database schema, SQL that works on H2 but fails on Postgres, Spring Bean wiring errors, JPA N+1 query problems.

For Java/Spring projects, @SpringBootTest with an embedded database or Testcontainers is the standard integration test setup. Keep them in a separate source set or Maven profile so unit tests remain fast in the default build.

PaymentRepositoryIntegrationTest.javaJAVA
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
package io.thecodeforge.payment;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import static org.junit.jupiter.api.Assertions.*;

// INTEGRATION TEST — uses real database (H2 in-memory for @DataJpaTest)
// Tests that the JPA mapping, queries, and constraints work correctly
@DataJpaTest
class PaymentRepositoryIntegrationTest {

    @Autowired
    private PaymentRepository paymentRepository;

    @Test
    void save_andFind_roundTrip() {
        // Arrange
        Payment payment = Payment.builder()
            .customerId("customer-42")
            .amountInPence(100_00)
            .currency("GBP")
            .status(PaymentStatus.PENDING)
            .build();

        // Act — actually hits the database
        Payment saved = paymentRepository.save(payment);
        Payment found = paymentRepository.findById(saved.getId()).orElseThrow();

        // Assert — verifies JPA mapping, column types, NOT NULL constraints
        assertEquals("customer-42", found.getCustomerId());
        assertEquals(100_00, found.getAmountInPence());
        assertEquals(PaymentStatus.PENDING, found.getStatus());
        assertNotNull(found.getCreatedAt()); // Verify @CreatedDate audit field
    }

    @Test
    void findByCustomerId_returnsAllPaymentsForCustomer() {
        paymentRepository.save(buildPayment("customer-42", 100_00));
        paymentRepository.save(buildPayment("customer-42", 200_00));
        paymentRepository.save(buildPayment("customer-99", 50_00));

        // Tests the actual JPQL query — unit test with a mock wouldn't catch typos
        var results = paymentRepository.findByCustomerId("customer-42");
        assertEquals(2, results.size());
    }
}
// Runs in: ~2-8 seconds (database startup)
// Tests: JPA mapping, query correctness, constraint enforcement
Output
PaymentRepositoryIntegrationTest > save_andFind_roundTrip PASSED
PaymentRepositoryIntegrationTest > findByCustomerId_returnsAllPaymentsForCustomer PASSED
Tests run: 2, Failures: 0, Time elapsed: 3.2 s
Production Insight
@DataJpaTest is fast because it loads only JPA slice — no full Spring context. But it defaults to H2, not your production database.
The bug: H2 supports syntax Postgres doesn't (e.g., string_agg vs array_agg). You'll pass in CI but fail in production.
Rule: use Testcontainers with a real Postgres image for integration tests that touch database-specific features.
Key Takeaway
Integration tests catch wiring bugs that mocks can't.
Use slice annotations (@DataJpaTest, @WebMvcTest) over @SpringBootTest when possible.
Test against the same database engine you run in production — H2 is not Postgres.

The Test Pyramid — Where Unit and Integration Tests Fit

The test pyramid is a visual guide: lots of unit tests at the base, fewer integration tests in the middle, and very few end-to-end tests at the top. It forces a trade-off: speed vs confidence.

Why it works: unit tests give you fast feedback on logic errors. Integration tests give you confidence that components work together. End-to-end tests give you confidence the system works as a whole, but they're slow and brittle.

In practice, you want about 70% unit tests, 20% integration tests, 10% end-to-end tests. But this ratio shifts based on domain. A data pipeline might have more integration tests. A pure algorithm library needs almost no integration tests.

The critical rule: the default build command (mvn test) must run only unit tests. Integration and E2E tests live in separate profiles or tags. If mvn test takes more than 30 seconds, developers won't run it.

The Iceberg Model
  • Unit tests: exhaustive coverage, fast feedback, easy to write, easy to run.
  • Integration tests: critical path coverage, slower, catch wiring bugs.
  • End-to-end tests: user journey validation, very slow, high maintenance.
  • The optimal ratio depends on your application: an API service needs more integration tests than a utility library.
Production Insight
A team I worked with had 90% E2E tests and 10% unit tests. CI took 45 minutes. Developers merged without waiting for the build. Production broke weekly.
The fix: inverted the pyramid. Wrote unit tests for business logic, kept a handful of integration tests for critical API paths, deleted the brittle E2E tests that tested the same thing multiple times.
Rule: if you can't fit your test pyramid on a whiteboard in 30 seconds, you don't understand your test strategy.
Key Takeaway
The test pyramid is a trade-off: speed vs confidence.
Unit tests for fast feedback, integration tests for wiring confidence, E2E for critical paths.
Keep your default build fast — separate integration tests into a different profile.
Should I Write a Unit Test or an Integration Test?
IfTesting pure business logic (calculations, transformations, validation)
UseUnit test with Mockito — no Spring context required.
IfTesting a repository method that interacts with the database
UseIntegration test with @DataJpaTest or Testcontainers.
IfTesting a controller endpoint that calls a service
UseUnit test the service with mocks + @WebMvcTest for the controller slice.
IfTesting a multi-step user flow (e.g., signup → payment → confirmation)
UseEnd-to-end test with Testcontainers + full Spring context (only one or two per critical path).

Mocking Strategies — What to Mock and What Not to Mock

Mocking is the most abused technique in unit testing. The rule is simple: mock what you don't own, don't mock what you do own.

Mock external systems: databases, HTTP clients, message queues, third-party APIs. These are slow, have side effects, and you can't control them in a unit test.

Don't mock your own domain objects, value objects, or utility classes. If you mock a DiscountCalculator that's in the same package, you're testing the mock configuration, not your logic. Instead, instantiate the real class. If it's cheap to create, do it. If it's expensive, consider extracting an interface.

The exception: if the class makes network calls internally (e.g., a service that calls another microservice), that's an external dependency — mock it. But if it's pure logic, use the real implementation.

Common trap: mocking get() on a Map that you pass as an argument. Just use a real HashMap. The test is simpler and more reliable.

MockingGuidelinesExample.javaJAVA
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
package io.thecodeforge.testing;

// BAD: mocking value objects and own classes
class BadMockingTest {
    @Test
    void bad_calculateDiscount() {
        Order order = mock(Order.class);          // Don't mock domain objects
        when(order.getTotal()).thenReturn(100.0);  // Just use a real Order
        DiscountCalculator calc = mock(DiscountCalculator.class);
        when(calc.apply(any())).thenReturn(90.0);  // You're testing the mock
        OrderProcessor processor = new OrderProcessor(calc);
        assertEquals(90.0, processor.process(order));
    }
}

// GOOD: mock external, use real for own code
class GoodMockingTest {
    @Test
    void good_calculateDiscount() {
        Order order = new Order(100.0, "GOLD");   // Real domain object
        DiscountCalculator calc = new DiscountCalculator(); // Real logic
        OrderProcessor processor = new OrderProcessor(calc);
        assertEquals(85.0, processor.process(order)); // 15% discount for Gold
    }
}
Output
BadMockingTest > bad_calculateDiscount PASSED (but false confidence)
GoodMockingTest > good_calculateDiscount PASSED (real logic verified)
Production Insight
A developer mocked UserRepository.findById() to return a full user object but forgot to set the role field. The test passed because the mock returned a partially constructed object. In production, the real repository returned a proper user, but the code that checked user.getRole() was never exercised by the mock.
The fix: use @ExtendWith(MockitoExtension.class) and @Mock only for external dependencies. For domain objects, use builders or factory methods that create complete instances.
Rule: if you mock a method and then stub it to return something that would never happen in production, you're not testing — you're guessing.
Key Takeaway
Mock external systems (databases, APIs, queues).
Don't mock your own domain objects or value classes.
If you can instantiate a real object in a few lines, do it — it's more reliable than a mock.

Setting Up a Balanced Test Suite in Practice

A healthy test suite balances speed, coverage, and maintainability. Here's how to achieve it in a typical Spring Boot project.

First, structure your project: put unit tests in src/test/java and integration tests in src/integration-test/java (or use Maven profiles). The surefire plugin runs unit tests by default. Failsafe plugin runs integration tests when you activate the integration profile.

Second, use slice tests for Spring slices: @DataJpaTest for repositories, @WebMvcTest for controllers, @JsonTest for serialisation. These load only the necessary Spring context, keeping tests fast.

Third, write integration tests for the critical paths: a new user signs up, a payment goes through, a report is generated. Don't write an integration test for every possible input — that's what unit tests are for.

Fourth, measure test coverage with JaCoCo, but don't chase 100% coverage. Focus on covering all branches of business logic. Untested logic paths are bugs waiting to happen.

Fifth, enforce test quality in CI: block PRs that decrease coverage, or that add integration tests without a corresponding unit test. Use ArchUnit to enforce test patterns (e.g., no @SpringBootTest in the unit test package).

pom.xml (Maven Surefire + Failsafe config)XML
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
<project>
  <build>
    <plugins>
      <!-- Unit tests (default) -->
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <configuration>
          <excludes>
            <exclude>**/*IntegrationTest.java</exclude>
            <exclude>**/*E2ETest.java</exclude>
          </excludes>
        </configuration>
      </plugin>
      <!-- Integration tests (runs with -Pintegration) -->
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-failsafe-plugin</artifactId>
        <configuration>
          <includes>
            <include>**/*IntegrationTest.java</include>
          </includes>
        </configuration>
      </plugin>
    </plugins>
  </build>

  <profiles>
    <profile>
      <id>integration</id>
      <build>
        <plugins>
          <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-failsafe-plugin</artifactId>
            <executions>
              <execution>
                <goals><goal>integration-test</goal><goal>verify</goal></goals>
              </execution>
            </executions>
          </plugin>
        </plugins>
      </build>
    </profile>
  </profiles>
</project>
Output
# Run only unit tests (fast)
mvn test
# Run integration tests (slower)
mvn verify -Pintegration
Production Insight
A startup I advised had a single mvn test that ran everything — 300 integration tests. It took 12 minutes. Developers skipped tests before pushing. Then they added a separate CI pipeline step that ran integration tests only after unit tests passed. But they didn't enforce the profile split. Someone accidentally moved a unit test into the integration test package, and the unit tests stopped running.
The fix: enforce package naming conventions with ArchUnit. Block any class named *IntegrationTest from being placed in src/test/java.
Rule: automate test separation — don't rely on developer discipline alone.
Key Takeaway
Separate unit and integration tests by Maven profile or source set.
Use slice tests (@DataJpaTest, @WebMvcTest) to keep integration tests fast.
Enforce test structure with ArchUnit or checkstyle — make the right thing the easy thing.

The 80/20 Rule: Why 80% Unit Tests and 20% Integration Tests Crashed Production

That golden ratio you heard at a conference? It's a lie for most Java services. We ran 80% unit tests, 20% integration tests, and still got paged at 2 AM because a real database connection pool timeout looked nothing like our mocked one. The problem isn't the ratio—it's what you're testing.

Integration tests catch wiring failures: missing @Transactional, wrong fetch type, connection leaks. Unit tests catch logic errors: null pointers, off-by-one loops, incorrect state transitions. If your service talks to a database, you need at least 40% integration tests covering the critical paths. Start there. If your service is a pure calculation engine, you can push 90% unit tests. The ratio follows the architecture, not the other way around.

ConnectionPoolLeakTest.javaJAVA
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
// io.thecodeforge — java tutorial

// Integration test that catches a connection pool leak
@SpringBootTest
class OrderRepositoryIntegrationTest {

    @Autowired
    private OrderRepository repository;

    @Test
    void shouldNotLeakConnectionsWhenBulkInsertFails() {
        List<Order> orders = IntStream.range(0, 100)
                .mapToObj(i -> new Order("invalid-data-" + i))
                .toList();

        // This will throw DataIntegrityViolationException
        assertThrows(DataIntegrityViolationException.class,
                () -> repository.saveAll(orders));

        // After the failure, can we still execute a query?
        // If connection is leaked, this hits org.hibernate.exception.JDBCConnectionException
        long count = repository.count();
        System.out.println("Count after failed bulk insert: " + count);
    }
}
Output
Count after failed bulk insert: 42
// If connection pool is configured correctly.
// If leaked: org.hibernate.exception.JDBCConnectionException: Connection is not available
Production Trap:
A mocked connection never times out. Your unit tests will pass, your integration will catch it, but only if you have enough of them.
Key Takeaway
Match your test ratio to your service's data dependencies—not a generic slide from a conference talk.

The Hidden Cost of Over-Mocking: When Your Unit Tests Lie to You

Mocking is a drug. It feels productive. You write tests fast, they pass, you merge. Then production burns because your mock returned an object that the real service never would. I've seen teams mock UserRepository.findById() to return a valid user, but the real method throws DataAccessException under load. The unit test passed. The pager didn't.

The fix: never mock what you own if it touches I/O. Mock the boundary, not the implementation. If you're mocking a database call, ask yourself—could an integration test catch this faster? Probably yes. Reserve mocks for external APIs you don't control (payment gateways, third-party auth). For your own DAOs? Let the real thing run in a test container. It costs 3 seconds more but saves 3 hours of incident response.

PaymentServiceTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.thecodeforge — java tutorial

// Unit test that doesn't mock our own DB—just the external API
class PaymentServiceUnitTest {

    @Mock
    private StripeClient stripeClient;  // external API we don't control

    @InjectMocks
    private PaymentService paymentService;

    @Test
    void shouldRetryOnStripeTimeout() {
        when(stripeClient.charge(any())).thenThrow(new TimeoutException("gateway timeout"));

        // This is fine—Stripe is an uncontrolled dependency
        PaymentResult result = paymentService.process(new Payment(100, "usd"));

        assertThat(result.status()).isEqualTo(Status.RETRY_SCHEDULED);
        verify(stripeClient, times(3)).charge(any());
    }
}
Output
// Test passes. No DB mock needed.
// PaymentResult(status=RETRY_SCHEDULED, attempts=3)
Senior Shortcut:
If you're mocking your own repository, you're probably writing the wrong test. Swap it for a Testcontainers integration test.
Key Takeaway
Mock external boundaries, not internal infrastructure. The test that never touches the real database is the test that will betray you.

False Positives Wasted More Dev Hours Than Any Bug Ever Did

A broken integration test that fails because of a config mismatch is gold. A broken unit test that passes because you mocked everything into submission is trash. But the silent killer? False positives in integration tests. When your CI pipeline fails because an embedded database has a different collation than production, your team starts ignoring test failures. Then the real bugs slip through.

Test containers solved half of this—same database version, same schema. But the other half is discipline: your integration tests must use the exact same configuration as production. Same connection pool size? Same transaction isolation level? If no, your tests are lying. We wasted two weeks debugging a transaction rollback issue that only happened in production because our test used REPEATABLE_READ while prod used READ_COMMITTED. Align your test config or burn your CI cache.

TransactionIsolationTest.javaJAVA
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
// io.thecodeforge — java tutorial

// Integration test that catches isolation level mismatch
@SpringBootTest
@Testcontainers
class OrderTransactionTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
            .withEnv("PGOPTIONS", "-c default_transaction_isolation='read committed'");

    @DynamicPropertySource
    static void configure(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
    }

    @Autowired
    private OrderService orderService;

    @Test
    void shouldNotReadUncommittedOrders() {
        // Two concurrent transactions—one commits, one rolls back
        CompletableFuture<Void> t1 = CompletableFuture.runAsync(() -> {
            orderService.createOrderWithStatus("ORDER-1", "PENDING");
        });
        CompletableFuture<Void> t2 = CompletableFuture.runAsync(() -> {
            orderService.createOrderWithStatus("ORDER-2", "CANCELLED");
        });

        CompletableFuture.allOf(t1, t2).join();

        // If isolation is READ_UNCOMMITTED, ORDER-2 might be visible before commit
        List<Order> activeOrders = orderService.getActiveOrders();
        assertThat(activeOrders).extracting(Order::status)
                .doesNotContain("CANCELLED");
    }
}
Output
// Passes with READ_COMMITTED in test and prod.
// Fails with REPEATABLE_READ in test, READ_COMMITTED in prod.
Production Trap:
Use Testcontainers with the exact production config. One env var mismatch equals days of wasted debugging.
Key Takeaway
False positives erode trust faster than any bug. Align test and production config exactly, or don't run the tests at all.

Systematic Summary: The Only Rule That Prevents a Test Suite from Rotting

A balanced test suite isn't magic. It's a deliberate trade-off between confidence and speed. Unit tests prove individual functions work in isolation. Integration tests prove the system works as a whole. Mixing them without discipline creates a maintenance nightmare.

Here's the hard rule: every integration test should fail for a reason that a unit test cannot catch. If your integration test fails because of a null pointer inside a single method, you wasted compute time and dev attention. The inverse is also true. If your unit test passes but the API call to the database fails, your mock is lying to you.

The practical split is not 80/20 by count but by confidence per test. Each integration test should cover a real boundary: file I/O, network calls, database transactions, or third-party APIs. Each unit test should cover logic paths and edge cases. Keep the ratio by execution time, not line count.

TestBalanceCheck.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — java tutorial

public class TestBalanceCheck {
    // Real integration test — hits DB
    @Test
    void shouldPersistAndRetrieveUser() {
        User user = new User("alice");
        userDao.save(user);
        User loaded = userDao.findById(user.getId());
        assertEquals("alice", loaded.name());
    }

    // Unit test — no DB, just logic
    @Test
    void shouldCapitalizeName() {
        User user = new User("alice");
        assertEquals("Alice", user.capitalized());
    }
}
Output
Both tests pass independently. One proves DB wiring works. The other proves logic works.
Senior Shortcut:
When reviewing a PR, count the integration tests. If they don't touch a real I/O boundary, reject them as slow unit tests in disguise.
Key Takeaway
Write integration tests only for boundaries unit tests cannot reach. Everything else is just slow unit testing.

Conclusion: Ship with Confidence, Not with False Coverage

Unit tests and integration tests are not enemies. They are two tools in the same belt. Use the wrong one for the job, and your production bugs will laugh at your green CI pipeline.

The real metric is not test count. It's test effectiveness. A single well-aimed integration test that catches a real schema mismatch is worth a hundred unit tests that verify getters and setters. But those hundred fast unit tests catch the stupid edge case that would take down your entire checkout flow.

Stop pretending 80/20 is a golden rule. Start asking: 'What happens when this mock is wrong?' and 'What happens when this test runs in production?'. The best test suites are boring. They run fast, break loudly, and tell you exactly what failed and why. No flaky retries. No false hope.

Go write tests that earn their keep. Your future self, debugging at 2 AM, will thank you.

ProductionCheck.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
// io.thecodeforge — java tutorial

// The test that matters — catches real wiring issues
@Test
void fullOrderFlowShouldSucceed() {
    // This is NOT a unit test
    OrderRequest request = new OrderRequest("user-42", List.of("sku-1", "sku-2"));
    OrderResult result = orderService.placeOrder(request);
    assertEquals(OrderStatus.CONFIRMED, result.status());
    assertNotNull(result.confirmationId());
}
Output
If this test fails, you know exactly where: the DB is down, the inventory service is misconfigured, or the payment gateway changed their API.
Production Trap:
A test suite that never fails is not reliable. It's hiding something. If all your tests pass but production breaks, your integration coverage is lying to you.
Key Takeaway
The best test is the one that breaks when it should, and stays green when it shouldn't. That's the only definition of confidence that matters.
● Production incidentPOST-MORTEMseverity: high

The 8-Minute Test Suite Nobody Ran

Symptom
CI pipeline green on every PR, but production broke after every deploy. The test suite never failed — because nobody ran it before merging.
Assumption
"The CI catches everything." But developers merged code, CI ran the full suite (8 minutes), and by the time it passed, they'd moved on to the next task. The one test that covered the new code — an integration test with @SpringBootTest — was slow and flaky, so it was skipped.
Root cause
The test pyramid was inverted. 80% integration tests, 20% unit tests. The team wrote @SpringBootTest for every service method because it was easier to let Spring wire everything. No one separated unit and integration test execution. The mvn test command loaded the full Spring context for every test. Developers stopped running tests locally.
Fix
Refactored to a proper test pyramid: moved business logic into plain Java classes, wrote unit tests with Mockito (no Spring context), kept @DataJpaTest for repositories and @SpringBootTest only for critical API end-to-end paths. Used Maven profiles: mvn test runs only unit tests (<1 min), mvn verify -Pintegration runs integration tests in CI after the fast build passes.
Key lesson
  • If your default test build takes more than 30 seconds, developers stop running it. Separate unit and integration test execution.
  • Integration tests test wiring — they should cover critical paths, not every branch. Exhaustive coverage belongs in unit tests.
  • Flaky integration tests erode trust. If a test fails randomly three times, delete it and write a unit test for the real logic instead.
Production debug guideWhen tests pass locally but fail in CI (or vice versa), here's the systematic approach.4 entries
Symptom · 01
Test passes locally but fails in CI
Fix
Check CI environment variables, database state, and test ordering. Use mvn test -Dtest=FailingTestClass -X to see debug logs. Ensure CI doesn't reuse stale test databases.
Symptom · 02
Integration test fails with 'connection refused'
Fix
Verify Testcontainers is starting the database container. Check port mapping and Docker resource limits. Add @Container with withReuse(true) for local debugging.
Symptom · 03
Unit test passes but integration test fails on the same logic
Fix
Your mock didn't match real behaviour. Compare the mock configuration to the actual dependency's behaviour. Common: mocking a repository method that returns Optional but the code expects null.
Symptom · 04
Test suite takes >5 minutes and CI timeout is 10
Fix
Measure test duration per class with Maven Surefire report. Split into unit and integration profiles. Parallelise with junit.jupiter.execution.parallel.enabled=true for independent tests.
★ Quick Debug Commands for Java Test IssuesRun these commands to isolate and fix the most common test problems.
Flaky integration test (sometimes passes, sometimes fails)
Immediate action
Run the test 10 times in a loop to confirm flakiness
Commands
for i in {1..10}; do mvn test -Dtest=FailingTest -pl module-name -q; done
mvn test -Dtest=FailingTest -X | grep -i 'random|order|shared'
Fix now
Add @TestMethodOrder(MethodName.class) to enforce consistent order. Remove shared static state in test classes.
Test fails with java.lang.OutOfMemoryError+
Immediate action
Check heap usage and increase for Surefire fork
Commands
mvn test -Dtest=MemoryHungryTest -DargLine="-Xmx512m -XX:+HeapDumpOnOutOfMemoryError"
jcmd (find PID) jcmd <pid> GC.heap_info
Fix now
Add forkCount=1 and argLine=-Xmx1g in pom.xml Surefire config. Consider using @DirtiesContext to clear Spring context after each test.
Mockito test throws 'UnnecessaryStubbingException'+
Immediate action
Remove unused stubs — they slow down the test
Commands
Run with `-Dorg.mockito.verbose=true` to see which stubs weren't used
Use `Mockito.lenient()` on stubs that are conditionally called
Fix now
Delete the unused when(...) line. If the stub is needed for some tests but not all, split the test method.
Unit Test vs Integration Test Comparison
CharacteristicUnit TestIntegration Test
What it testsSingle class/method in isolationMultiple components wired together
External dependenciesMocked/stubbedReal (or realistic substitutes)
SpeedMilliseconds (50-200ms per test)Seconds (1-10s per test)
Feedback cycleImmediate — run on every saveSlower — run before push or in CI
CatchesLogic errors, edge cases, branchesWiring errors, DB constraints, serialisation, config
MissesWiring bugs, DB type mismatches, config errorsPure logic edge cases (too slow to cover exhaustively)
Spring annotation@ExtendWith(MockitoExtension)@SpringBootTest, @DataJpaTest, @WebMvcTest
Volume in test pyramidMany (70-80%)Some (15-20%)
Typical failure modeAssertion errors, null pointer exceptions (mocked dependency returned null)Connection refused, data constraint violation, serialisation mismatch

Key takeaways

1
Unit tests verify logic in isolation (milliseconds each). Integration tests verify that components work together (seconds each). You need both.
2
Mock external systems (databases, APIs, queues) in unit tests. Use real or realistic substitutes in integration tests.
3
The test pyramid
70-80% unit tests for fast feedback, 15-20% integration tests for wiring confidence, 5-10% end-to-end for critical paths.
4
Use @DataJpaTest for repository integration tests
it loads only the JPA slice, not the full Spring context, keeping tests relatively fast.
5
Keep integration tests in a separate Maven profile or Gradle task so the default build remains fast enough to run before every push.
6
Don't mock your own domain objects
if you can instantiate it, use the real class. Over-mocking tests the mock, not your code.

Common mistakes to avoid

5 patterns
×

Writing @SpringBootTest for pure logic tests

Symptom
Your test suite takes 3-8 seconds per test because it loads the full Spring context, even though the test only needs to check a simple calculation.
Fix
Use @ExtendWith(MockitoExtension.class) and instantiate the class directly. Move business logic into plain Java classes without Spring dependencies.
×

Mocking everything in unit tests, including your own domain classes

Symptom
You mock a User object or a DiscountCalculator that's in the same package. The test passes, but the real code breaks because the mock doesn't behave like the real implementation.
Fix
Instantiate value objects and domain logic directly. Mock only external systems (database, HTTP client, message queue). If the class is expensive to create, extract an interface.
×

Having no integration tests and discovering wiring bugs only in production

Symptom
Your JPA mapping has a typo, or the H2 dialect accepts syntax that Postgres rejects. Unit tests with mocks can't catch database-level issues.
Fix
Add integration tests for each repository and critical service method. Use @DataJpaTest or Testcontainers with a real database image.
×

Not separating unit and integration test execution

Symptom
Developers stop running tests locally because the default build takes too long (8+ minutes). They merge code that breaks the build.
Fix
Keep unit tests in src/test/java and integration tests in a separate source set or Maven profile. mvn test should run only unit tests (<30 seconds). Use mvn verify -Pintegration for the slow ones.
×

Using in-memory H2 for integration tests but deploying to Postgres

Symptom
Your integration tests pass in CI but the same SQL fails in production with ERROR: operator does not exist: text = integer because H2 is more permissive.
Fix
Use Testcontainers with a Postgres image that matches your production version. Yes, it adds ~10 seconds to container startup — that's acceptable for the confidence it gives.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Explain the difference between unit and integration tests with concrete ...
Q02SENIOR
When would you use @DataJpaTest vs @SpringBootTest?
Q03SENIOR
Your test suite has 200 tests but takes 8 minutes to run. What would you...
Q04SENIOR
What is the test pyramid and why does it matter?
Q01 of 04JUNIOR

Explain the difference between unit and integration tests with concrete examples.

ANSWER
A unit test verifies a single piece of logic in isolation. For example, testing PaymentAmountCalculator.calculate() by mocking DiscountService. An integration test verifies that components work together — like testing PaymentRepository.save() against a real database to ensure JPA mappings and constraints are correct. Unit tests run in milliseconds, integration tests in seconds. Unit tests catch logic errors, integration tests catch wiring bugs.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is a unit test?
02
What is an integration test?
03
How many unit tests vs integration tests should I have?
04
Should I use H2 for integration tests if I deploy to Postgres?
05
What is the difference between @WebMvcTest and @SpringBootTest?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.

Follow
Verified
production tested
May 23, 2026
last updated
1,663
articles · all by Naren
🔥

That's Advanced Java. Mark it forged?

8 min read · try the examples if you haven't

Previous
Mockito verify(): How to Assert Method Calls in Unit Tests
28 / 28 · Advanced Java
Next
Spring Boot Application Properties Explained