Skip to content
QSWEQB
hardConceptDesignScenarioMidSeniorStaffLead

Where do you draw the line between a unit test and an integration test, and how do you keep a few thousand of them under ten minutes?

Draw the line by what the test is allowed to touch - a unit test stays in-process, an integration test crosses a real boundary. Mock collaborators, never the component under test. Use Testcontainers for real dependencies and keep the suite fast by reusing the Spring context and the containers.

5 min readUpdated 2026-07-26

What the interviewer is scoring

  • Can they define the boundary by what the test touches rather than by how many classes it instantiates
  • Whether mocking is treated as a cost with a justification, not as the default way to write a test
  • That substituting H2 for the production database is identified as untested divergence rather than a speed win
  • Do they know why adding a mocked bean to a Spring test multiplies the number of application contexts
  • Does the candidate propose a measurement - slowest tests, context count, wall clock - before proposing a fix

Answer

Define the boundary by what the test touches

The useful definition is not "a unit test tests one class". Plenty of good unit tests instantiate five collaborators, because those five together are the unit of behaviour worth asserting. The line that predicts a test's cost and its failure modes is whether it crosses a process boundary. A unit test runs entirely in the JVM, needs no socket, no file system it did not create, and no clock it does not control, and therefore starts in milliseconds and cannot fail for an environmental reason. An integration test deliberately involves something real — a database, a broker, an HTTP dependency — and accepts start-up cost and flakiness in exchange for evidence that the wiring and the SQL are right.

That split then tells you what each kind of test is for, which is the part that stops the suite from becoming shapeless. Unit tests are where you cover branches: every validation rule, every rounding edge, every error path, because they are cheap enough to write dozens of. Integration tests are where you cover the things a mock cannot tell you the truth about: does this query return what I think, does this transaction actually roll back, does the JSON on the wire match the contract. If you find yourself enumerating business rules through a Spring context and a live Postgres, the boundary has slipped, and the suite's runtime will show it long before anyone notices the design problem.

Mocking is a cost, not a default

Every mock is an assertion that you know how the collaborator behaves, recorded in a place that will not be updated when the collaborator changes. That is an acceptable trade for a dependency you own and whose contract is narrow, and a poor one for a library or driver whose real behaviour is precisely what you are unsure about. Hence the old rule, which is still the right one: do not mock types you do not own. Wrap them in an interface you do own, and mock that.

Mockito's MockitoExtension defaults to strict stubs, which fails a test that sets up a stubbing nothing uses. This annoys people and it is genuinely valuable, because an unused stub is evidence that the test's model of the interaction no longer matches the code. Take the failure as information.

Over-verification is the other half of the cost. verify(repo).save(order) couples the test to the fact that saving happens through that method, so an internal refactor that changes nothing observable breaks a passing test. Prefer asserting the resulting state or the returned value, and reserve verify for interactions that are the requirement — that the payment gateway was called exactly once, that the audit event was emitted.

The test that passes because it mocked the thing under test

This is the failure worth naming explicitly, because it produces a green suite and ships the bug. If you doubt a query, and the query lives in a repository, then mocking the repository means your test asserts the contents of your own stub.

// The query is the thing we are unsure about - and it has been replaced by a stub.
@Test
void findsOverdueOrders() {
    when(orders.findOverdue(any())).thenReturn(List.of(order));
    assertThat(service.overdue()).containsExactly(order);   // asserts thenReturn
}

The test above will pass with a findOverdue whose WHERE clause has the comparison inverted, whose date arithmetic drops a time zone, or which does not compile as JPQL at all until runtime. The rule that follows: mock the collaborators of the component under test, never the component under test, and if the risk lives in SQL then the test that covers it must execute SQL.

The same reasoning disqualifies swapping H2 in for Postgres. It is not merely a slightly different dialect; it has a different planner, different JSONB and array support, different locking and isolation behaviour, and different case-folding. A suite that is green against H2 has tested that your code works against H2. That divergence used to be a price worth paying because real databases in tests were painful. It no longer is.

Testcontainers for the real dependency

Testcontainers starts the actual dependency in a container from the test, and tears it down afterwards, so the integration tests run against the same engine version production does. Declaring the container static starts it once for the whole class rather than once per test method, which is the difference between an acceptable and an intolerable suite. In Spring Boot, @ServiceConnection — available since Boot 3.1 — reads the container and contributes the connection properties itself, so you no longer hand-write a @DynamicPropertySource block per container.

@SpringBootTest
@Testcontainers
class OrderRepositoryIT {

    @Container                                    // static: one container per class
    @ServiceConnection                            // wires the datasource properties
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    OrderRepository orders;

    @Test
    void overdueExcludesTheBoundaryDate() {
        orders.save(new Order("A-1", LocalDate.of(2026, 1, 31)));
        // Runs on the real planner, so an inverted comparison fails here.
        assertThat(orders.findOverdue(LocalDate.of(2026, 1, 31))).isEmpty();
    }
}

Two operational points make this workable at scale. Define the containers once in a shared base class or a @TestConfiguration so every integration test resolves to the same Spring configuration and therefore reuses one container and one context. And for local development, withReuse(true) together with testcontainers.reuse.enable=true in ~/.testcontainers.properties leaves the container running between JVM invocations, which removes start-up from the inner loop entirely. Leave reuse off in CI, where each build should be clean.

Where the ten minutes actually goes

In a Spring Boot suite, the dominant cost is almost never the assertions. It is application-context start-up, and the framework already caches contexts across test classes, keyed on the merged test configuration. Anything that changes that key produces another context and another start-up: a different @TestPropertySource, an extra @Import, a @MockitoBean (the replacement for @MockBean, which Spring Boot deprecated in 3.4) that no other class declares, or a @DirtiesContext that evicts a context other classes were going to reuse. A suite with forty distinct contexts spends most of its life starting Spring. Converging on a handful of shared configurations is usually the single largest win available, and it is a refactor of annotations rather than of tests.

After that, in rough order of payoff: use the sliced annotations — @WebMvcTest, @DataJpaTest, @JsonTest — so a test that exercises a controller does not start the whole application; split the build so Surefire runs the fast *Test classes and Failsafe runs *IT classes, letting the fast set gate the pull request; and enable JUnit 5's parallel execution in junit-platform.properties once the tests are genuinely independent, which is a real precondition and not a formality, since shared containers and static state will surface immediately.

Measure before you cut

Ask the build for the slowest tests and the number of contexts before changing anything. Surefire and Failsafe report per-class timings, and Spring logs each context refresh, so both numbers are already available. Almost every slow Java suite is slow for one of three reasons — too many distinct Spring contexts, containers started per class where one per build would do, or business rules being exercised through the whole stack because that was the path of least resistance. Which of the three it is determines the fix, and guessing between them wastes a week.

Likely follow-ups

  • Which defects can a test running against an in-memory database never catch?
  • Why does Mockito's strict stubbing fail a test that used to pass, and is that a good thing?
  • How would you share one database container across every integration test in a build?
  • Which tests would you move out of the pull-request build entirely, and what would you run them on instead?

Related questions

Further reading

junitmockitotestcontainersspring-boot-testtest-strategy