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.
Interview drill-down — expect these follow-ups:
L1 → "What about the test trophy (Kent C. Dodds) — it says integration tests should be the majority?"
The trophy inverts the pyramid: more integration tests than unit tests, because unit tests that mock everything don't test real behavior — they test your implementation's assumptions. Valid perspective, especially for frontend (React Testing Library) or event-driven systems where the integration points are the interesting parts. For Spring services with complex domain logic (pricing engines, state machines), the pyramid still applies — business rules deserve precise, fast unit tests. For CRUD-heavy services with thin logic, the trophy makes more sense: the interesting behavior is in the DB queries and HTTP contracts, not in business logic that barely exists.
L2 → "You have a test suite that takes 45 minutes to run in CI. How do you fix it?"
First, profile where the time goes: split into unit, integration, E2E and time each. Usually 90% of the time is in a small percentage of tests. Common causes: (1) @SpringBootTest context not being cached — every @MockBean change creates a new context. Fix: consolidate MockBean usage into shared base test classes. (2) Testcontainers starting a new container per test class — use static containers with .withReuse(true). (3) Too many @SpringBootTest tests for things that should be @WebMvcTest or plain unit tests. (4) Sequential test execution — enable parallel test execution in JUnit. Each fix typically cuts 30–50% of the problem.
L3 → "A critical payment service has 95% code coverage but has had 3 production incidents in the last quarter. What's wrong with the test strategy?"
Coverage measures code paths executed, not behavior verified. The tests likely verify that methods run without exceptions (or trivially test happy paths) but miss: (1) edge cases in business logic (off-by-one in fee calculations, race conditions in concurrent updates), (2) integration failures (Kafka message format mismatch, DB constraint violations that only appear on commit), (3) operational scenarios (circuit breaker behavior, timeout handling, retry exhaustion). The fix: shift from coverage-oriented testing to behavior-oriented testing — review each test and ask "what production failure would this catch?" If the answer is "none", it's a coverage-padding test.
Trap answer to avoid: "We have 85% code coverage so our codebase is well-tested." — Coverage percentage tells you which lines were executed during tests, not whether the assertions were meaningful. A test that calls every method but never asserts anything achieves 100% coverage. The real question is: what production failures would your test suite catch before they reach users? Coverage is a floor, not a ceiling.
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 testing | Annotation |
| Full flow, multiple layers | @SpringBootTest |
| Controller logic, validation, serialization | @WebMvcTest |
| Repository queries, entity mapping | @DataJpaTest |
| Business logic only | Plain JUnit — no Spring at all |
Interview drill-down — expect these follow-ups:
L1 → "What other Spring test slices exist beyond @WebMvcTest and @DataJpaTest?"
@JsonTest: loads only Jackson configuration for testing serialization/deserialization — validates your @JsonProperty, custom serializers, and date formats without spinning up any web layer. @RestClientTest: tests RestTemplate/WebClient-based HTTP clients against a MockRestServiceServer. @DataMongoTest: MongoDB repository slice. @DataR2dbcTest: reactive R2DBC repositories. @WebFluxTest: reactive web layer equivalent of @WebMvcTest. All follow the same pattern: load only the relevant slice, mock everything else. The key rule: each slice annotation tells you exactly what it auto-configures — read the source or docs to understand what's included.
L2 → "@MockBean causes a new Spring context per test class. Your project has 50 test classes each with different @MockBean combinations. Context startup takes 8 seconds — 50 × 8 = 400 seconds. How do you fix this?"
Spring caches contexts keyed by their configuration. Tests with identical configuration share the same context. Fix: create 2-3 shared base test classes that define a standard MockBean set: class BaseControllerTest (@WebMvcTest with @MockBean OrderService, @MockBean PaymentService), class BaseServiceTest (no Spring). All controller tests extend BaseControllerTest — they share one context. This reduces 50 contexts to 2-3. Secondary fix: use @SpyBean sparingly and prefer @MockBean (same caching rules). Audit with -Dspring.test.context.cache.maxSize and look for context creation logs during test runs.
L3 → "You have a complex integration test that needs @SpringBootTest but 90% of the Spring context is irrelevant. How do you limit what gets loaded?"
Several approaches: (1) @SpringBootTest(classes = {OrderService.class, OrderRepository.class, TestDBConfig.class}) — specify exactly which classes to load instead of the full application. (2) Use @ContextConfiguration with a custom test configuration class that only @Import the beans you need. (3) Use @ActiveProfiles("test") with a test profile that excludes unnecessary auto-configurations via spring.autoconfigure.exclude. (4) For services without a web layer, use SpringBootTest.WebEnvironment.NONE — skips web context creation. The goal: load the smallest context that still tests what you need. Fewer beans = faster startup = faster feedback loop.
Trap answer to avoid: "I use @SpringBootTest for all tests — it's the most accurate because it loads the full context." — @SpringBootTest on every test multiplies context startup times and makes the test suite slow enough that developers stop running it locally. The purpose of test slices is to give you accuracy where it matters (testing the controller with its real MVC mapping) while staying fast. @SpringBootTest is accurate but expensive — use it only for true end-to-end integration tests, not for unit or component tests.
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.
Interview drill-down — expect these follow-ups:
L1 → "What's the difference between a mock, a stub, and a spy?"
Stub: returns canned responses, you don't verify it was called — you just need it to return a value. when(repo.find(1L)).thenReturn(order). Mock: you set up expectations AND verify they were met — the test fails if the mock wasn't called as expected. verify(emailService, times(1)).send(any()). Spy: wraps a real object and delegates to it by default — you only stub the specific methods you need to override. Useful when you want 95% real behavior but need to stub one method. In Mockito: mock() creates a mock (all methods return null/default), @Spy creates a spy (all methods call the real implementation unless stubbed). Use spies sparingly — they couple tests to implementation details and break when internal behavior changes.
L2 → "When does mocking give you false confidence — name a specific production bug pattern it misses?"
The transaction boundary bug: your OrderService test mocks the repository, the service logic passes. In production, the service calls an inner method with @Transactional(propagation=REQUIRES_NEW) to save an audit log, then the outer transaction rolls back due to an exception — but the audit log was already committed (REQUIRES_NEW bypasses the outer transaction rollback). The mock test didn't test this because it mocked the repository and bypassed all transaction behavior. This category of bug is only caught by integration tests that exercise real transaction boundaries against a real database.
L3 → "Your team uses @MockBean extensively. Test suite runs in 12 minutes. Each MockBean combination creates a new Spring context taking 8 seconds. You have 60 test classes. You want to get to under 3 minutes. What's your strategy?"
Audit context cache misses: run with -Dlogging.level.org.springframework.test.context=DEBUG and count "Creating new ApplicationContext" vs "Found cached context" log entries. Goal: 3-5 contexts total for 60 classes. Strategy: (1) Create 3-4 abstract base test classes (BaseWebTest, BaseServiceTest, BaseRepoTest) each with a standardized @MockBean set. All concrete tests extend these — same config = same cached context. (2) Convert any @SpringBootTest that only tests one layer to the appropriate slice test — slice tests start faster. (3) Extract remaining @SpringBootTest tests that genuinely need a full context into one or two "integration test" base classes. Final check: verify context cache hit rate is above 90%.
Trap answer to avoid: "I mock all my dependencies to isolate the unit under test — that's best practice." — Mocking all dependencies tests that your code calls its collaborators, not that the system works. If you mock your own repositories in service tests, you're only verifying that the service calls repo.save() — not that the save actually works, that the query returns the right data, or that the transaction boundary is correct. Mock external HTTP clients and third-party SDKs; use Testcontainers for your own infrastructure. The gap between your mock and reality is where production bugs live.
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
}
}
Interview drill-down — expect these follow-ups:
L1 → "What about @Commit — when would you use it?"
@Commit overrides the default rollback — the test transaction actually commits to the database. Useful for: (1) debugging — you want to inspect what's actually in the DB after a test runs, use @Commit on a single test and check with a DB tool. (2) Verifying constraints — some DB constraints (deferred constraints, unique index violations) only fire on COMMIT, not during the transaction. @Commit forces the commit and reveals these errors. Never leave @Commit in a CI test — it pollutes the database and causes subsequent tests to fail on dirty data. Treat it as a debug annotation: add it locally, investigate, remove before pushing.
L2 → "Your test has @Transactional and @TransactionalEventListener(AFTER_COMMIT) in the service. The listener never fires in the test. Why?"
Because the test's @Transactional wraps everything and the transaction never actually commits — it rolls back at the end of the test. @TransactionalEventListener(AFTER_COMMIT) fires only on real commits, not rollbacks. The listener is correct behavior — it's the test that's misleading you. Fix: either (1) remove @Transactional from the test so the service transaction commits naturally (and clean up manually in @AfterEach), or (2) use @TransactionalEventListener(BEFORE_COMMIT) in the test to observe the event before the rollback discards it, or (3) spy on the listener bean and use verify() to check it was called rather than checking side effects.
L3 → "You need to test that a @Transactional(propagation=REQUIRES_NEW) audit log service commits even when the outer transaction rolls back. How do you write this test?"
Do NOT use @Transactional on the test — that would suppress both transactions into one rollback scope. Use @SpringBootTest without @Transactional and clean up manually. Test structure: (1) In @BeforeEach, ensure the audit log table is empty. (2) Call the outer service method that intentionally throws after calling the audit service. (3) In @AfterEach, delete all test data. (4) Assert: the main operation data does NOT exist (rolled back), but the audit log DOES exist (committed via REQUIRES_NEW). This is the only honest test of REQUIRES_NEW — it must verify that commits survive outer rollbacks, which @Transactional on the test would hide.
Trap answer to avoid: "@Transactional on tests is always safe — Spring will clean up automatically." — @Transactional cleans up by rolling back, which hides bugs related to commit behavior. If your production code depends on commits (constraints enforcing, REQUIRES_NEW propagation, @TransactionalEventListener(AFTER_COMMIT)), @Transactional on the test will make these work differently than in production. The "automatic cleanup" is convenient but it changes the semantics of what you're testing. Use it consciously, not by default.
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.
Interview drill-down — expect these follow-ups:
L1 → "What about CI performance with Testcontainers — doesn't starting Docker containers add minutes to the build?"
In practice, 10–30 seconds total with proper setup. Key optimizations: (1) Declare the container static (shared across all tests in the class) — it starts once per test class, not per test method. (2) Use Spring context caching — if multiple test classes share the same @DynamicPropertySource config, they reuse the same container. (3) Use .withReuse(true) — the container persists between JVM restarts, so only the first CI run is slow; subsequent runs reuse it. (4) Use a lightweight Docker image (postgres:15-alpine). With these in place, Testcontainers overhead is comparable to H2 startup, with dramatically better accuracy.
L2 → "You're testing a Kafka consumer that processes payments. How do you write a reliable integration test with Testcontainers?"
Spin up a Kafka container (confluentinc/cp-kafka or native Kafka image). In the test: (1) Use KafkaTemplate to publish a test PaymentEvent to the test topic. (2) Use Awaitility to wait until the side effect is observable — e.g., await().atMost(10, SECONDS).untilAsserted(() -> assertThat(paymentRepo.findById(paymentId)).isPresent()). (3) Never use Thread.sleep() — it's either too short (flaky) or too long (slow). The test is: publish → wait for observable side effect → assert. This tests the full consumer stack: deserialization, error handling, DB write, idempotency — none of which a mock can verify.
L3 → "Testcontainers requires Docker. Your CI runs in a Docker container itself (Docker-in-Docker). What are the limitations and alternatives?"
Docker-in-Docker (DinD) works but adds complexity: the inner Docker daemon needs privileged mode or socket mounting. Most CI platforms (GitHub Actions, GitLab CI) support Docker-in-Docker natively. Testcontainers auto-detects the Docker socket and works transparently. If DinD is unavailable (some secure environments prohibit privileged containers): (1) Testcontainers Cloud — offloads container management to a remote service, no local Docker needed. (2) Pre-provisioned test infrastructure — start a long-running PostgreSQL in CI infrastructure and inject the connection string via environment variables, bypassing Testcontainers entirely. This is less hermetic but works in locked-down environments.
Trap answer to avoid: "I use H2 in-memory database for all tests — it's fast and portable." — H2 is not PostgreSQL. Silent differences: H2 accepts SQL that PostgreSQL rejects (case-insensitive column names by default, different NULL handling, different function support). Custom JPQL with PostgreSQL-specific functions will fail in production but pass on H2. Partial index syntax doesn't work in H2. The "speed" advantage of H2 disappears once you use .withReuse(true) on Testcontainers. Test against the database you run in production — H2 is a false economy.
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");
}
}
Interview drill-down — expect these follow-ups:
L1 → "How do you test a Kafka consumer with Spring?"
Spin up a real Kafka broker via Testcontainers (confluentinc/cp-kafka image). In the test: (1) Use @EmbeddedKafka for unit/slice tests where you don't need a real broker — it's faster and simpler for basic consumer tests. (2) For true integration tests, use Testcontainers Kafka. Publish a message using KafkaTemplate. Use Awaitility to wait until the consumer has processed it: await().atMost(10, SECONDS).untilAsserted(() -> verify(orderService).processPayment(any())). Never assert immediately after publish — the consumer is asynchronous. The test validates: deserialization worked, listener was triggered, error handler fired (for malformed messages), downstream logic executed.
L2 → "You test an @Async method with SyncTaskExecutor. The test passes. But in production the @Async behavior causes a different transaction context and the code fails. How do you catch this?"
SyncTaskExecutor removes the async boundary, so transaction propagation works differently — the async method runs in the same transaction context as the caller. To catch this, write a separate integration test without SyncTaskExecutor that uses the real executor. Use Awaitility to wait for the result. The test is slower but tests the actual production behavior: new thread, new transaction context, SecurityContext not propagated unless you configure SecurityContextHolder.MODE_INHERITABLETHREADLOCAL. This is the "honest" async test — the sync one is a shortcut for testing the method's logic, not its threading behavior.
L3 → "@RecordApplicationEvents captures events published during a test. But your @TransactionalEventListener(AFTER_COMMIT) fires in a new thread after the test transaction commits. ApplicationEvents doesn't capture it. How do you verify it fired?"
@RecordApplicationEvents captures events published via ApplicationEventPublisher within the test's transaction context. AFTER_COMMIT listeners fire after commit in a potentially different execution context. Solutions: (1) Use @SpyBean on the listener class and verify() that the listener method was called — Mockito's spy intercepted the real call. (2) Use Awaitility to poll for the side effect that the listener produces (e.g., an email was queued, a DB record was written). (3) For AFTER_COMMIT testing specifically, remove @Transactional from the test so the service transaction actually commits and the listener fires naturally, then use Awaitility to assert the downstream effect.
Trap answer to avoid: "I use Thread.sleep(500) to wait for async code in tests." — Thread.sleep is deterministic-looking but flaky in practice. On a loaded CI server, 500ms may not be enough; on a fast developer machine, it wastes time. Awaitility polls until the condition is met with a configurable timeout — fast when the work completes quickly, tolerant when it's slow, and fails with a clear message when the timeout is exceeded. Thread.sleep is the wrong tool for async assertions.
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.
Interview drill-down — expect these follow-ups:
L1 → "What's your target code coverage and how do you enforce it?"
Coverage is a lagging indicator, not a goal. 80% coverage with tests that assert nothing meaningful is worse than 60% with tests that verify real behavior. I use coverage to find untested paths, not to hit a number. For critical business logic (pricing engine, eligibility rules, financial calculations) I aim for near 100% — every branch tested. For infrastructure glue (Spring config, Kafka wiring, Flyway migrations), I don't chase coverage. Enforcement: configure JaCoCo with a floor (e.g., 70% overall) as a CI gate to prevent regressions. But also add mutation testing (Pitest) to catch tests that don't actually assert anything — mutation score is a better quality indicator than line coverage.
L2 → "You're practicing TDD on a new feature. You write the test first, it fails. You write the minimum code to pass. Now the code is ugly — how much refactoring do you do in the 'refactor' phase?"
The refactor phase is specifically for improving code structure without changing behavior — the test suite is your safety net. Refactor until the code is clean, but stop before adding new behavior. Common refactors: extract method for repeated logic, rename for clarity, remove duplication between the new code and existing code. The discipline is: (1) don't add new functionality during refactor (that's a new red-green-refactor cycle), (2) the tests must still pass after refactor. If you're tempted to add "while I'm here" features, add them to the backlog and handle them in a new cycle. The refactor phase is bounded by "the tests still pass" + "I'm satisfied with the design."
L3 → "Your team resists TDD, saying 'we don't have time to write tests first.' The codebase has a 40-minute test suite and frequent production bugs. How do you change this?"
Don't argue for TDD — demonstrate that the current approach is more expensive. Instrument the team's time: how many hours per week are spent on production bugs and debugging? Compare to estimated time for a test-first approach on those same features. Data wins the argument. Tactical approach: (1) Start with the bug fix workflow — every bug fix must begin with a reproducing test. This is low-resistance and immediately reduces regressions. (2) Define "done" to include tests — PRs without tests for new business logic don't get merged. (3) Improve test speed first (see Q2 drill-down) — "no time for tests" is often "tests are too slow to be useful in our workflow." Once tests run in under 5 minutes, the resistance usually drops significantly.
Trap answer to avoid: "TDD means you write tests before all code, always, no exceptions." — Strict TDD is valuable for business logic but impractical for exploratory work, prototypes, and infrastructure wiring. The productive interpretation: write tests before a feature is considered done — not necessarily before every single line. The most important habit is "test before merge, not test after ship." Teams that interpret TDD as a rigid ceremony often write mechanical tests that technically precede the code but don't drive the design, defeating the purpose.
Q8 · Contract Testing
What is consumer-driven contract testing? How does it differ from integration testing, and when do you use Pact vs Spring Cloud Contract?
🎙 Model answer (60 sec)
"Contract testing solves a specific microservices problem: when I change my API, how do I know I haven't broken all my consumers without spinning up the entire ecosystem? In consumer-driven contract testing, each consumer publishes a contract — 'I send this request and expect this response shape.' The producer's test suite verifies it satisfies all registered contracts. The key insight is that you isolate the API contract verification from both the consumer's full integration test and the producer's internal test. Pact is the cross-language standard for this; Spring Cloud Contract is the Spring-native alternative that generates WireMock stubs and @SpringBootTest-compatible tests automatically."
The problem: integration test sprawl in microservices
In a microservices architecture, verifying that Service A's client correctly calls Service B's API has two naive approaches — both painful:
- Full integration tests: spin up Service B (plus its DB, Kafka, etc.) in your CI. Slow, brittle, hard to maintain.
- Mocking: mock the HTTP call in Service A's tests. Fast, but the mock can diverge from Service B's actual behavior — you get passing tests and broken production.
Contract testing gives you a third option: verify the contract independently on each side, with minimal infrastructure, and break the build at the producer if a change violates any consumer's contract.
Pact — the consumer-driven flow
// CONSUMER SIDE — defines what it expects from the producer
@ExtendWith(PactConsumerTestExt.class)
class UserClientPactTest {
@Pact(consumer = "order-service", provider = "user-service")
public RequestResponsePact getUserPact(PactDslWithProvider builder) {
return builder
.given("user 42 exists")
.uponReceiving("a request for user 42")
.path("/users/42")
.method("GET")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.numberType("id", 42)
.stringType("name", "Alice")
.stringType("email")) // type matching, not exact values
.toPact();
}
@Test
@PactTestFor(pactMethod = "getUserPact")
void testGetUser(MockServer mockServer) {
// Pact starts a real HTTP mock server from the contract definition
UserDto user = userClient.getUser(42);
assertThat(user.id()).isEqualTo(42);
// Pact verifies your client correctly sends the expected request
// AND correctly parses the response
}
// After running, Pact writes a .json contract file — publish to Pact Broker
}
// PRODUCER SIDE — verifies it satisfies the consumer's contract
@Provider("user-service")
@PactBroker(url = "https://pact-broker.internal")
@SpringBootTest(webEnvironment = RANDOM_PORT)
class UserServicePactVerificationTest {
@State("user 42 exists")
void userExists() {
// Set up test data matching the consumer's "given" state
userRepo.save(new User(42L, "Alice", "alice@example.com"));
}
// Pact fetches all contracts from the broker, replays each request,
// and asserts the response matches what the consumer declared.
// If user-service drops the "email" field, this test fails.
}
Spring Cloud Contract — the Spring-native alternative
Spring Cloud Contract (SCC) is producer-driven: the producer defines the contracts, and SCC generates both the producer's test stubs and WireMock JSON files that consumers use. The flow is inverted compared to Pact.
// Contract defined by the PRODUCER (Groovy DSL or YAML)
// src/test/resources/contracts/getUserById.groovy
Contract.make {
request {
method GET()
url '/users/42'
}
response {
status 200
body(id: 42, name: 'Alice', email: regex('.+@.+'))
headers { contentType applicationJson() }
}
}
# SCC generates from this contract:
# 1. A @SpringBootTest-based producer test that hits the real endpoint
# 2. A WireMock stub JSON that consumers use in their tests:
# { "request": { "method": "GET", "url": "/users/42" },
# "response": { "status": 200, "body": "{\"id\":42, ...}" } }
# Consumer uses the published stub (no real server needed)
@AutoConfigureStubRunner(
ids = "com.example:user-service:+:stubs:8090",
stubsMode = StubRunnerProperties.StubsMode.REMOTE
)
Pact vs Spring Cloud Contract — when to use each
| Aspect | Pact | Spring Cloud Contract |
| Driven by | Consumer (consumer defines expectations) | Producer (producer defines contracts) |
| Language support | Multi-language (Java, JS, Python, Go…) | JVM only |
| Infrastructure | Pact Broker (hosted or PactFlow SaaS) | Artifact repository (Maven/Nexus) for stubs |
| Best fit | Polyglot microservices, external consumers | All-Spring shop, internal services |
| Test style | Consumer writes the contract in test code | Producer writes the contract in DSL file |
| Verification | Producer CI pulls contracts from broker | Consumer CI pulls stubs from artifact repo |
Interview drill-down — expect these follow-ups:
L1 → "Where does contract testing fit in the test pyramid — does it replace integration tests?"
Contract tests sit between unit tests and full integration tests — often called the "service layer" or "component layer." They replace the need for full end-to-end integration tests that verify API compatibility, but they don't replace integration tests that verify a service's own behavior against its real database. The split: contract tests verify "does this API call work between these two services?" — integration tests (Testcontainers) verify "does this service correctly persist data to its database?" Both are needed; they test different things. Contract testing reduces the blast radius of integration test suites by removing the need to spin up multiple services for API contract verification.
L2 → "A consumer adds a new required field to the contract. The producer hasn't deployed the new field yet. How do Pact's can-i-deploy checks handle this?"
The Pact Broker's can-i-deploy CLI tool checks whether the combination of consumer@version X and producer@version Y is safe to deploy — based on whether the producer satisfies the consumer's contract at those specific versions. If the consumer published a contract requiring a new field but the producer hasn't implemented it yet, can-i-deploy returns "no" for the consumer deployment, blocking it from reaching an environment where the old producer is running. This is how Pact gates deployments: both sides must satisfy each other's contracts at the versions being deployed. The workflow: consumer deploys after producer satisfies the contract; or both deploy together if the contract version cross-validates correctly.
L3 → "You have 12 microservices all communicating via REST. Implementing Pact on all pairs is 66 contract pairs. How do you prioritize and roll this out?"
Don't try to instrument all pairs at once — start where the pain is. (1) Identify the three most brittle integrations: typically the highest-traffic API calls where a breaking change has caused an incident. (2) Start with consumer-driven Pact on those three pairs — quick wins that demonstrate value. (3) Make contract test publication mandatory for new consumer clients: any new @HttpExchange or Feign client must have a Pact consumer test before merging. (4) Add producer verification to CI for services that have existing contracts — this is self-reinforcing: as more consumers publish contracts, producers automatically get broader coverage. (5) For the remaining pairs, accept that full integration tests (perhaps with Testcontainers) are good enough until a pain point justifies adding Pact. Pragmatic rollout beats perfect coverage.
Trap answer to avoid: "Contract tests replace end-to-end tests." — Contract tests verify that the API interface is compatible. They don't verify that the business logic produces the correct result end-to-end, that latency is acceptable under load, or that authentication/authorization works across the full stack. A service can satisfy all its contracts perfectly and still have a broken business workflow. Contract tests + integration tests + a small number of E2E smoke tests is the right combination.