Unit Test vs Integration Test — The 8-Min Suite Nobody Ran
Default test build >30 seconds? Developers won't run it.
- 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 teststays fast. - Biggest mistake: mocking your own domain logic — you're not testing your code.
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.
Optional.ofNullable() in your code or default stubs with lenient().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.
string_agg vs array_agg). You'll pass in CI but fail in production.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.
- 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.
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 on a get()Map that you pass as an argument. Just use a real HashMap. The test is simpler and more reliable.
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.@ExtendWith(MockitoExtension.class) and @Mock only for external dependencies. For domain objects, use builders or factory methods that create complete instances.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).
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.*IntegrationTest from being placed in src/test/java.The 8-Minute Test Suite Nobody Ran
mvn test command loaded the full Spring context for every test. Developers stopped running tests locally.mvn test runs only unit tests (<1 min), mvn verify -Pintegration runs integration tests in CI after the fast build passes.- 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.
mvn test -Dtest=FailingTestClass -X to see debug logs. Ensure CI doesn't reuse stale test databases.@Container with withReuse(true) for local debugging.Optional but the code expects null.junit.jupiter.execution.parallel.enabled=true for independent tests.@TestMethodOrder(MethodName.class) to enforce consistent order. Remove shared static state in test classes.Key takeaways
Common mistakes to avoid
5 patternsWriting @SpringBootTest for pure logic tests
Mocking everything in unit tests, including your own domain classes
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.Having no integration tests and discovering wiring bugs only in production
Not separating unit and integration test execution
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
ERROR: operator does not exist: text = integer because H2 is more permissive.Interview Questions on This Topic
Explain the difference between unit and integration tests with concrete examples.
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.Frequently Asked Questions
That's Advanced Java. Mark it forged?
4 min read · try the examples if you haven't