Senior 4 min · March 30, 2026

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

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

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
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.
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.

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.
● 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?
🔥

That's Advanced Java. Mark it forged?

4 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