Unit Testing vs Integration Testing: Key Differences
- 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.
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.
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
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.
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
PaymentRepositoryIntegrationTest > findByCustomerId_returnsAllPaymentsForCustomer PASSED
Tests run: 2, Failures: 0, Time elapsed: 3.2 s
| Characteristic | Unit Test | Integration Test |
|---|---|---|
| What it tests | Single class/method in isolation | Multiple components wired together |
| External dependencies | Mocked/stubbed | Real (or realistic substitutes) |
| Speed | Milliseconds (50-200ms per test) | Seconds (1-10s per test) |
| Feedback cycle | Immediate β run on every save | Slower β run before push or in CI |
| Catches | Logic errors, edge cases, branches | Wiring errors, DB constraints, serialisation, config |
| Misses | Wiring bugs, DB type mismatches, config errors | Pure logic edge cases (too slow to cover exhaustively) |
| Spring annotation | @ExtendWith(MockitoExtension) | @SpringBootTest, @DataJpaTest, @WebMvcTest |
| Volume in test pyramid | Many (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).
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.