← Home
Testing · Spring

Top 7 Senior Interview Questions

Unit · Integration · Mocking · @SpringBootTest · Testcontainers · @Transactional · TDD

Questions

  1. Unit vs Integration vs E2E — how do you balance the test pyramid?
  2. @SpringBootTest vs @WebMvcTest vs @DataJpaTest — when to use each?
  3. Mocking with Mockito — @Mock vs @MockBean, and when mocking hurts
  4. @Transactional in tests — what it does and when it lies to you
  5. Testcontainers — why and how to use real infrastructure in tests
  6. Testing @Async and event-driven code — the concurrency problem
  7. TDD in practice — how do you actually apply it on a real team?
Q1 · Strategy
Unit vs Integration vs E2E — how do you balance the test pyramid?

Direct answer

The test pyramid says: many unit tests at the base, fewer integration tests in the middle, very few E2E tests at the top. The reasoning is cost — unit tests are fast, cheap, and pinpoint failures precisely. E2E tests are slow, brittle, and when they fail you often don't know where to look.

In practice on Spring Boot services, I aim for roughly: 70% unit, 25% integration, 5% E2E. But the exact ratio matters less than making sure each layer tests the right thing.

What each layer should own

  • Unit tests — business logic in isolation. Service classes, domain objects, pure functions. No Spring context, no database, no network. Fast: should run in milliseconds.
  • Integration tests — component boundaries. Does my repository actually write/read correctly? Does my REST controller serialize the response correctly? Does my Kafka consumer handle a malformed message? Real database via Testcontainers, partial Spring context.
  • E2E tests — critical user journeys only. "Can a user place an order and receive a confirmation?" Not every endpoint — just the paths that, if broken, would cause an incident.

The anti-pattern I avoid

The inverted pyramid — teams that test almost everything through @SpringBootTest because "it's closer to production." These test suites take 20+ minutes to run, developers stop running them locally, and the feedback loop dies. I've seen this on multiple teams. The fix is always pushing logic down into pure service/domain classes that can be tested without a context.

Likely follow-up: "What about the test trophy (Kent C. Dodds)?" → It flips the pyramid — more integration tests than unit tests, because unit tests that mock everything don't test real behavior. Valid perspective for frontend or event-driven systems. For Spring services with complex domain logic, I lean pyramid. For CRUD-heavy services, trophy makes sense.
Q2 · Spring Test Slices
@SpringBootTest vs @WebMvcTest vs @DataJpaTest — when do you use each?

Direct answer

These three annotations load different slices of the Spring context. Choosing the right one is about loading only what you need — smaller context = faster startup = faster feedback.

@SpringBootTest — full context

Loads the entire application context, exactly like production. Use it for true integration tests where you need multiple layers working together — controller + service + repository + database. It's the slowest option. If you use it for everything, your test suite will crawl.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderFlowIntegrationTest {
    @Autowired TestRestTemplate restTemplate;

    @Test
    void placeOrder_fullFlow_returnsOrderId() {
        // hits real controller → service → repository → DB
    }
}

@WebMvcTest — controller slice only

Loads only the web layer: controllers, filters, Jackson config, Spring Security. No service beans, no repositories. Everything else you mock. Use it to test request mapping, input validation, serialization, and error handling — without needing a database.

@WebMvcTest(OrderController.class)
class OrderControllerTest {
    @Autowired MockMvc mockMvc;
    @MockBean OrderService orderService;  // service is mocked — not loaded

    @Test
    void createOrder_invalidBody_returns400() throws Exception {
        mockMvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{}"))
               .andExpect(status().isBadRequest())
               .andExpect(jsonPath("$.errors").isArray());
    }
}

@DataJpaTest — JPA slice only

Loads only JPA repositories, entity classes, and an in-memory H2 database by default. No web layer, no services. Use it to test custom JPQL queries, derived query methods, and entity mappings. I usually override the database with Testcontainers to avoid H2/PostgreSQL dialect differences:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // use real DB
@Import(TestcontainersConfig.class)
class OrderRepositoryTest {
    @Autowired OrderRepository repo;

    @Test
    void findByCustomerIdAndStatus_returnsMatchingOrders() {
        // test your custom query against real PostgreSQL
    }
}

Quick decision rule

What you're testingAnnotation
Full flow, multiple layers@SpringBootTest
Controller logic, validation, serialization@WebMvcTest
Repository queries, entity mapping@DataJpaTest
Business logic onlyPlain JUnit — no Spring at all
Likely follow-up: "What other slices exist?" → @JsonTest for Jackson serialization, @DataMongoTest for MongoDB, @RestClientTest for RestTemplate/WebClient. All follow the same pattern — load only the relevant slice.
Q3 · Mocking
@Mock vs @MockBean — what's the difference, and when does mocking actually hurt you?

Direct answer

@Mock is pure Mockito — it creates a mock object with no Spring involvement at all. Use it in plain unit tests where you're not loading a Spring context. @MockBean is Spring Boot's wrapper — it creates a Mockito mock and registers it as a Spring bean, replacing any real bean of that type in the application context. Use it in slice tests like @WebMvcTest where Spring needs to inject the dependency.

@Mock — plain unit test

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock OrderRepository repo;       // pure Mockito, no Spring
    @Mock PaymentClient paymentClient;

    @InjectMocks OrderService service; // Mockito injects mocks into constructor

    @Test
    void placeOrder_insufficientStock_throwsException() {
        given(repo.findAvailableStock(any())).willReturn(0);
        assertThatThrownBy(() -> service.placeOrder(order))
            .isInstanceOf(InsufficientStockException.class);
    }
}

@MockBean — Spring slice test

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mockMvc;
    @MockBean OrderService orderService; // replaces real bean in Spring context

    @Test
    void getOrder_notFound_returns404() throws Exception {
        given(orderService.getOrder(99L)).willThrow(new ResourceNotFoundException());
        mockMvc.perform(get("/orders/99"))
               .andExpect(status().isNotFound());
    }
}
Watch out: Every @MockBean forces Spring to create a new application context for that test class. If you use different @MockBean combinations across test classes, Spring can't reuse its context cache — startup time multiplies. Group tests that share the same mock setup into the same class, or use a shared base test configuration.

When mocking hurts

Mocking gives you false confidence when you mock the wrong thing. The classic trap: you mock a repository, your service test passes, but in production the query is wrong or the transaction boundary is in the wrong place. The mock didn't test any of that.

My rule: don't mock what you own at the boundary. Mock external HTTP clients, third-party SDKs, email services. Don't mock your own repositories — use Testcontainers for those. The gap between your mock and reality is where production bugs live.

Likely follow-up: "What's the difference between a mock, a stub, and a spy?" → Stub: returns canned responses, no verification. Mock: you verify it was called. Spy: wraps a real object, delegates real calls unless stubbed. In Mockito terms: mock() is a mock, @Spy is a spy. Use spies sparingly — they couple tests to implementation details.
Q4 · Transactions in Tests
@Transactional in tests — what does it do, and when does it lie to you?

Direct answer

When you annotate a test method with @Transactional, Spring wraps the entire test in a transaction and automatically rolls it back after the test completes. This keeps the database clean between tests without you having to delete data manually. It's convenient — but it has a well-known trap that I always warn junior engineers about.

The rollback mechanism

@DataJpaTest  // @Transactional is applied by default in DataJpaTest
class OrderRepositoryTest {

    @Autowired OrderRepository repo;

    @Test
    void save_thenFind_returnsOrder() {
        repo.save(new Order("ORD-001"));
        assertThat(repo.findByReference("ORD-001")).isPresent();
        // after test: entire transaction is rolled back — DB is clean
    }
}

The trap — transactional tests hide real bugs

Your production code runs in separate transactions: the controller commits, then the next request reads. In a transactional test, everything is in one transaction that never commits. This hides three categories of bugs:

  • LazyInitializationException — in the test, the session is open the whole time so lazy loading works fine. In production, the session closes after the transaction, and your code tries to access a lazy collection outside of it — exception.
  • Unique constraint violations — constraints are only checked on commit in some databases. Your test never commits, so it never sees the violation.
  • @Transactional(propagation = REQUIRES_NEW) — a method that opens a new transaction will actually commit in production. In your test, it still gets rolled back with everything else because the test transaction is the outermost one.

When to avoid @Transactional on tests

For integration tests that test transactional behaviour — like testing that a REQUIRES_NEW inner transaction commits independently, or that a constraint is enforced — don't use @Transactional on the test. Clean up manually or use Testcontainers with a fresh schema per test run instead.

@SpringBootTest
class OrderIntegrationTest {
    // No @Transactional — transactions behave exactly like production

    @Autowired OrderRepository repo;

    @AfterEach
    void cleanup() {
        repo.deleteAll(); // manual cleanup — more honest
    }
}
Likely follow-up: "What about @Commit?" → Overrides the default rollback — the test transaction actually commits. Useful for debugging what's actually in the DB, but don't leave it in CI — it leaves dirty data that breaks subsequent tests.
Q5 · Testcontainers
What is Testcontainers and why is it better than H2 for integration tests?

Direct answer

Testcontainers is a library that spins up real Docker containers — PostgreSQL, Redis, Kafka, whatever — as part of your test lifecycle. The container starts before your tests, your tests run against it, then it shuts down. You test against the exact same database engine you run in production.

H2 is an in-memory database that's fast and zero-config, but it's not the database you run in production. The SQL dialects differ, the constraint behaviour differs, the index behaviour differs. I've seen bugs that only appeared in production because H2 silently accepted SQL that PostgreSQL rejects.

Basic setup

@SpringBootTest
@Testcontainers
class OrderRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:15")
            .withDatabaseName("testdb");

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry r) {
        r.add("spring.datasource.url",      postgres::getJdbcUrl);
        r.add("spring.datasource.username",  postgres::getUsername);
        r.add("spring.datasource.password",  postgres::getPassword);
    }

    @Test
    void customQuery_worksOnRealPostgres() { ... }
}

Reusing containers across tests — the performance fix

Starting a container per test class is slow. The fix is declaring the container static (shared within a class) and using Spring's context caching so the same context — and same container — is reused across test classes that share the same configuration.

For even better performance, use the .withReuse(true) flag — Testcontainers keeps the container alive between test runs (across JVM restarts), so the first run is slow, subsequent runs are fast.

@Container
static PostgreSQLContainer<?> postgres =
    new PostgreSQLContainer<>("postgres:15")
        .withReuse(true); // container survives JVM restart

Beyond databases

Testcontainers works for any Docker image — Kafka, Redis, LocalStack (AWS), Wiremock. I use it for Kafka consumer tests especially: spin up a real broker, publish a message, assert the consumer processed it correctly. There's no reliable way to test that with mocks.

Likely follow-up: "What about CI performance?" → Testcontainers works on any machine with Docker, including CI. For speed: parallelize test classes, use container reuse, and keep your test database schema lightweight. Most CI runs with Testcontainers add 10–30 seconds — worth it for the reliability gain.
Q6 · Async & Events
How do you test @Async methods and event-driven code in Spring?

Direct answer

Async and event-driven code is where most testing strategies break down, because the code you're trying to assert runs on a different thread, at an unknown time after the test body. If you assert immediately after triggering the async work, the work often hasn't run yet — your test passes or fails non-deterministically.

Option 1 — disable async in tests

The simplest approach: override the task executor in your test configuration to use a synchronous executor. @Async still works but runs on the calling thread, so there's no timing issue.

@TestConfiguration
class SyncAsyncConfig {
    @Bean
    @Primary
    TaskExecutor taskExecutor() {
        return new SyncTaskExecutor(); // runs on calling thread — no async
    }
}

Good for unit/service-level tests. Not ideal if the async boundary itself is what you're testing.

Option 2 — Awaitility for real async assertions

When you want to test that the async work actually happens asynchronously, use Awaitility to poll until the condition is met or a timeout is reached:

import static org.awaitility.Awaitility.await;

@Test
void publishEvent_asyncListenerProcesses_withinTimeout() {
    eventPublisher.publishEvent(new OrderPlacedEvent(orderId));

    await().atMost(2, SECONDS)
           .untilAsserted(() ->
               verify(notificationService, times(1)).sendConfirmation(orderId)
           );
}

Awaitility retries the assertion block until it passes or the timeout expires. It's far better than Thread.sleep() — no arbitrary waits, no flaky tests from timing differences.

Testing Spring application events

For @EventListener and @TransactionalEventListener, test that the event was published using a captured list or a @RecordApplicationEvents annotation (Spring Boot 2.4+):

@SpringBootTest
@RecordApplicationEvents
class OrderServiceEventTest {

    @Autowired ApplicationEvents events;
    @Autowired OrderService orderService;

    @Test
    @Transactional
    void placeOrder_publishesOrderPlacedEvent() {
        orderService.placeOrder(newOrder());

        assertThat(events.stream(OrderPlacedEvent.class))
            .hasSize(1)
            .first()
            .extracting(OrderPlacedEvent::getOrderId)
            .isEqualTo("ORD-001");
    }
}
Likely follow-up: "How do you test a Kafka consumer?" → Spin up a real Kafka broker via Testcontainers, use KafkaTemplate to publish a test message, then use Awaitility to wait until your consumer has processed it and assert on the side effect (database record written, downstream service called, etc.).
Q7 · TDD
How do you actually apply TDD on a real team? What are its limits?

Direct answer

TDD in its pure form — write a failing test, write minimum code to pass, refactor — works very well for business logic with clear rules: validation, calculations, state machines. It forces you to think about the interface before the implementation, and the test suite you end up with is genuinely useful because it was written to drive design, not just for coverage.

But I'm pragmatic about it. I don't TDD everything, and I think teams that insist on strict TDD for all code usually end up with slow delivery and test suites that test implementation details rather than behaviour.

Where TDD works well

  • Complex business rules — pricing engines, discount calculators, eligibility checks, state machine transitions. The tests become the spec.
  • Bug fixes — write a failing test that reproduces the bug first, then fix it. Guarantees the bug never regresses.
  • Refactoring — if you have tests first, you can refactor aggressively and know immediately if you broke behaviour.

Where TDD slows you down

  • Exploratory work — when you don't know the design yet, writing tests first is hard. I prefer to spike, find the right shape, then write tests before merging.
  • Infrastructure glue — Spring config classes, Kafka consumer wiring, Flyway migrations. These are better covered by integration tests, not TDD unit tests.
  • UI / controller layer — the interface changes frequently in early development. TDD here creates tests that break constantly for the wrong reasons.

How I apply it in practice

I use TDD strictly for the domain/service layer where business rules live. For everything else — controllers, repositories, config — I write tests after the implementation is stable but before the PR is merged. The rule I follow: no PR without tests, but tests don't always have to come first.

The most important TDD habit that doesn't require strict red-green-refactor: write the test before you call it "done." Don't write code, ship it, and add tests later. That path leads to code that's hard to test, and tests that are retrofitted to pass rather than to verify behaviour.

Likely follow-up: "What's your target code coverage?" → Coverage is a lagging indicator, not a goal. 80% coverage with tests that assert nothing meaningful is worse than 60% coverage with tests that actually verify behaviour. I use coverage to find untested paths, not to hit a number. For critical business logic I aim for near 100%, for infrastructure glue I don't chase it.