Unit Test vs Integration Test — The 8-Min Suite Nobody Ran
Default test build >30 seconds? Developers won't run it.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
- ✓Basic programming fundamentals
- ✓A computer with internet access
- ✓Willingness to follow along with examples
- 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.
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.
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.
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.
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
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.
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
string_agg vs array_agg). You'll pass in CI but fail in production.Unit Test vs Integration Test
Trade-offs in scope, speed, and reliability.
Unit Test
Fast, isolated, narrow scope
Integration Test
Slower, wider, realistic scope
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.
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 } }
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).
<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>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 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.
// 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); } }
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.
// 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()); } }
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.
// 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"); } }
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.
// 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()); } }
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.
// 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()); }
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.for i in {1..10}; do mvn test -Dtest=FailingTest -pl module-name -q; donemvn test -Dtest=FailingTest -X | grep -i 'random|order|shared'@TestMethodOrder(MethodName.class) to enforce consistent order. Remove shared static state in test classes.mvn test -Dtest=MemoryHungryTest -DargLine="-Xmx512m -XX:+HeapDumpOnOutOfMemoryError"jcmd (find PID) jcmd <pid> GC.heap_infoforkCount=1 and argLine=-Xmx1g in pom.xml Surefire config. Consider using @DirtiesContext to clear Spring context after each test.Run with `-Dorg.mockito.verbose=true` to see which stubs weren't usedUse `Mockito.lenient()` on stubs that are conditionally calledwhen(...) line. If the stub is needed for some tests but not all, split the test method.| 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%) |
| Typical failure mode | Assertion errors, null pointer exceptions (mocked dependency returned null) | Connection refused, data constraint violation, serialisation mismatch |
| File | Command / Code | Purpose |
|---|---|---|
| PaymentAmountCalculatorTest.java | class PaymentAmountCalculatorTest { | Unit Tests |
| PaymentRepositoryIntegrationTest.java | @DataJpaTest | Integration Tests |
| MockingGuidelinesExample.java | class BadMockingTest { | Mocking Strategies |
| pom.xml (Maven Surefire + Failsafe config) | Setting Up a Balanced Test Suite in Practice | |
| ConnectionPoolLeakTest.java | @SpringBootTest | The 80/20 Rule |
| PaymentServiceTest.java | class PaymentServiceUnitTest { | The Hidden Cost of Over-Mocking |
| TransactionIsolationTest.java | @SpringBootTest | False Positives Wasted More Dev Hours Than Any Bug Ever Did |
| TestBalanceCheck.java | public class TestBalanceCheck { | Systematic Summary |
| ProductionCheck.java | @Test | Conclusion |
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.When would you use @DataJpaTest vs @SpringBootTest?
Your test suite has 200 tests but takes 8 minutes to run. What would you investigate?
mvn test -Dtest=FailingTest -X. Likely culprits: too many @SpringBootTest tests that load the full context, or integration tests that rely on Docker containers without reuse. I'd separate unit and integration tests into different profiles (Surefire vs Failsafe). Then move business logic to plain Java classes so they can be tested with Mockito. Finally, parallelise independent tests with junit.jupiter.execution.parallel.enabled=true. Target: unit tests under 30s, integration tests under 3 minutes in CI.What is the test pyramid and why does it matter?
Frequently Asked Questions
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.
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.
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).
No. H2 is a different database and supports different SQL syntax. You'll pass tests with H2 that fail on Postgres due to type coercion, function availability, or constraint behaviour. Use Testcontainers with a Postgres image matching your production version. The extra startup time (~10s) is worth the confidence.
@WebMvcTest loads only the web layer — controllers, argument resolvers, serialisers — and mocks all services. It's fast (~2-3s startup). @SpringBootTest loads the full context — all beans, services, repositories — and is slower (~8-15s). Use @WebMvcTest for controller unit tests; use @SpringBootTest only for end-to-end flows that must exercise the entire stack.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
That's Advanced Java. Mark it forged?
6 min read · try the examples if you haven't