Homeβ€Ί Javaβ€Ί Unit Testing vs Integration Testing: Key Differences

Unit Testing vs Integration Testing: Key Differences

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: Advanced Java β†’ Topic 26 of 26
Understand the real difference between unit and integration tests: what they test, how fast they run, when to use each, and how to structure your test pyramid for a Java project.
πŸ§‘β€πŸ’» Beginner-friendly β€” no prior Java experience needed
In this tutorial, you'll learn:
  • Unit tests verify logic in isolation (milliseconds each). Integration tests verify that components work together (seconds each). You need both.
  • Mock external systems (databases, APIs, queues) in unit tests. Use real or realistic substitutes in integration tests.
  • 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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑ Quick Answer
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.java Β· JAVA
123456789101112131415161718192021222324252627282930313233343536373839
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

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.java Β· JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
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
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%)

🎯 Key Takeaways

  • Unit tests verify logic in isolation (milliseconds each). Integration tests verify that components work together (seconds each). You need both.
  • Mock external systems (databases, APIs, queues) in unit tests. Use real or realistic substitutes in integration tests.
  • 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.
  • Use @DataJpaTest for repository integration tests β€” it loads only the JPA slice, not the full Spring context, keeping tests relatively fast.
  • Keep integration tests in a separate Maven profile or Gradle task so the default build remains fast enough to run before every push.

⚠ Common Mistakes to Avoid

  • βœ•Writing @SpringBootTest tests for pure logic tests β€” loads the full Spring context (3-8 seconds) when a 50ms Mockito test would suffice.
  • βœ•Mocking everything in unit tests including your own domain classes β€” if you mock a method in the same package you own, you're not testing your code.
  • βœ•Having no integration tests at all and discovering wiring bugs only in production β€” Spring's dependency injection, JPA mapping, and query execution all need integration coverage.
  • βœ•Not separating unit and integration test execution β€” slow integration tests in the default Maven build discourage developers from running tests locally.

Interview Questions on This Topic

  • QExplain the difference between unit and integration tests with concrete examples.
  • QWhen would you use @DataJpaTest vs @SpringBootTest?
  • QYour test suite has 200 tests but takes 8 minutes to run. What would you investigate?
  • QWhat is the test pyramid and why does it matter?

Frequently Asked Questions

What is a unit test?

A unit test verifies the behaviour of a single class or method in complete isolation from its external dependencies. Dependencies like databases, HTTP clients, and other services are replaced with mocks or stubs. Unit tests are fast (milliseconds) and are run frequently during development to catch logic errors early.

What is an integration test?

An integration test verifies that multiple components work together correctly using real (or realistic substitute) implementations. In Java, this typically means testing a service with a real database, verifying that JPA mappings are correct, or testing an HTTP endpoint with MockMvc. Integration tests are slower than unit tests but catch wiring bugs that mocks can't.

How many unit tests vs integration tests should I have?

A widely cited guideline is the test pyramid: roughly 70% unit tests, 20% integration tests, 10% end-to-end tests. The exact ratio depends on your application, but the principle holds: unit tests for exhaustive logic coverage (they're fast enough to have many), integration tests for critical path wiring verification (they're slow enough to be selective).

πŸ”₯
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousMockito verify(): How to Assert Method Calls in Unit Tests
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged