Testing Stack — Interview Questions
Stack context: This system uses JUnit 5, Testcontainers 1.19.7, RestAssured 5.4.0, Awaitility, WireMock (
wiremock-spring-boot:3.10.6), jjwt 0.12.5, JaCoCo (≥80% instruction coverage), Spring Cloud Contract (consumer-driven contracts incommonmodule), and Spring Test slices. Integration tests use real Docker containers for PostgreSQL, Kafka, and Redis.
Q1 — What is the test pyramid and how does it apply to microservices? junior
Answer: The test pyramid describes the ideal ratio of test types: many unit tests at the base, fewer integration tests in the middle, fewest E2E tests at the top.
▲
/E\ E2E (Selenium, RestAssured full stack) — slow, brittle, few
/---\
/ Int \ Integration (Testcontainers, @SpringBootTest) — moderate count
/-------\
/ Unit \ Unit tests (JUnit 5, Mockito) — fast, many, no I/O
/___________\
This system's testing strategy:
- Unit tests: Business logic, domain validation, utility classes — no Spring context, no I/O.
- Integration tests per service:
@SpringBootTest+ Testcontainers (PostgreSQL + Kafka + Redis) — test full service behavior with real infrastructure. - Contract tests (Spring Cloud Contract): Verify API contracts between consumer (api-gateway) and producer (user-service, order-service).
- E2E tests (
e2e-tests/module): RestAssured against all running services (DockerComposeContainer).
Anti-pattern: Testing everything through E2E tests — slow CI feedback loop, unreliable (flaky due to network), hard to debug.
Q2 — What are the key JUnit 5 annotations? junior
Answer: JUnit 5 (JUnit Jupiter) is a complete rewrite of JUnit 4 with a new extension model.
@Test // marks a test method
@BeforeEach // runs before each test method
@AfterEach // runs after each test method
@BeforeAll // runs once before all tests (must be static, or @TestInstance(PER_CLASS))
@AfterAll // runs once after all tests (same requirement)
@DisplayName("...") // human-readable test name
@Disabled("reason") // skip test
@ParameterizedTest // run test with multiple inputs
@ValueSource(strings = {"GET", "POST", "PUT"})
void testHttpMethods(String method) { ... }
@CsvSource({"PENDING,false", "CONFIRMED,true"})
void testOrderStatus(String status, boolean expectedActive) { ... }
@MethodSource("provideOrders") // use a factory method as source
@Nested // nested test class for grouping related tests
@Tag("integration") // categorize tests — run with -Dgroups=integration
@ExtendWith(MockitoExtension.class) // JUnit 5 extension (replaces JUnit 4 @RunWith)
@ExtendWith(SpringExtension.class)
Lifecycle control:
@TestInstance(TestInstance.Lifecycle.PER_CLASS) // one instance for all tests
// Allows @BeforeAll and @AfterAll to be non-static (useful with @Testcontainers)
Q3 — What is Testcontainers and how does it work? junior
Answer: Testcontainers is a Java library that starts real Docker containers during tests and provides connection details via dynamic properties. Containers are automatically stopped after tests complete.
Basic usage:
@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {
@Container // managed by Testcontainers — started before tests, stopped after
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("orderdb")
.withUsername("test")
.withPassword("test");
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
Why Testcontainers over embedded alternatives:
- Real PostgreSQL — Flyway migrations work perfectly, JSONB, procedures, triggers.
- Real Kafka — actual partition/consumer group behavior.
- Real Redis — RedisTemplate, Lua scripts, SCAN commands.
- Tests catch issues that H2/in-memory alternatives miss.
Q4 — How do you optimize Testcontainers startup time? senior
Answer: Starting containers for every test class is slow. Several strategies reduce overhead:
1. Static containers (shared across all tests in a class):
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
// Static = one container per test class (not per test method)
2. Shared containers across classes (singleton pattern):
public abstract class AbstractIntegrationTest {
static final PostgreSQLContainer<?> POSTGRES;
static final KafkaContainer KAFKA;
static {
POSTGRES = new PostgreSQLContainer<>("postgres:16").withReuse(true);
KAFKA = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1")).withReuse(true);
POSTGRES.start();
KAFKA.start();
}
}
@SpringBootTest
class OrderServiceTest extends AbstractIntegrationTest { ... }
@SpringBootTest
class PaymentServiceTest extends AbstractIntegrationTest { ... }
3. Testcontainers reuse mode (via .testcontainers.properties):
testcontainers.reuse.enable=true
Containers with .withReuse(true) are NOT stopped after tests — reused across test runs (fast local development).
4. Pre-pulled images: Run docker pull postgres:16 before tests to avoid pull latency in CI.
5. @SpringBootTest context caching: Spring caches the ApplicationContext across test classes with the same config — one JVM startup, many test classes.
Q5 — What is RestAssured and how do you use it for API testing? junior
Answer: RestAssured is a Java DSL for testing RESTful APIs. It follows a given-when-then (BDD) syntax.
@SpringBootTest(webEnvironment = RANDOM_PORT)
class UserControllerTest {
@LocalServerPort
private int port;
@BeforeEach
void setUp() {
RestAssured.port = port;
RestAssured.basePath = "/api/v1";
}
@Test
void shouldCreateUser() {
given()
.contentType(ContentType.JSON)
.body("""
{
"email": "user@example.com",
"password": "SecurePass123!",
"fullName": "John Doe"
}
""")
.when()
.post("/users")
.then()
.statusCode(201)
.body("email", equalTo("user@example.com"))
.body("id", notNullValue())
.header("Location", containsString("/users/"));
}
@Test
void shouldReturn401WhenNotAuthenticated() {
given()
.get("/users/profile")
.then()
.statusCode(401);
}
}
RequestSpecification (reuse common setup):
RequestSpecification authSpec = new RequestSpecBuilder()
.setBaseUri("http://localhost:" + port)
.addHeader("Authorization", "Bearer " + jwt)
.setContentType(ContentType.JSON)
.build();
given(authSpec).get("/orders").then().statusCode(200);
Q6 — What is WireMock and how is it used in gateway tests? senior
Answer: WireMock is an HTTP server that simulates external services by returning configured stub responses. It allows testing in isolation without starting real downstream services.
wiremock-spring-boot integration:
@SpringBootTest
@EnableWireMock
class ApiGatewayIntegrationTest {
@InjectWireMock
WireMockServer wireMock;
@Test
void shouldProxyOrderRequestToOrderService() {
// Stub order-service response
wireMock.stubFor(get(urlPathEqualTo("/api/v1/orders"))
.withHeader("X-User-Id", equalTo("user-123"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
[{"id": "order-1", "status": "CONFIRMED"}]
""")));
// Call through the gateway
given()
.header("Authorization", "Bearer " + generateJwt("user-123"))
.when()
.get("/orders")
.then()
.statusCode(200)
.body("[0].status", equalTo("CONFIRMED"));
// Verify stub was called
wireMock.verify(getRequestedFor(urlPathEqualTo("/api/v1/orders"))
.withHeader("X-User-Id", equalTo("user-123")));
}
Response templating (dynamic responses):
wireMock.stubFor(get(urlPathMatching("/api/v1/orders/(.*)"))
.willReturn(aResponse()
.withBody("{ \"id\": \"{{request.pathSegments.[3]}}\" }") // dynamic ID
.withTransformers("response-template")));
Q7 — What is Awaitility and when do you need it? junior
Answer:
Awaitility is a library for writing asynchronous assertions. Instead of Thread.sleep(), you poll until a condition is met or timeout.
Problem without Awaitility:
// WRONG — fragile timing
orderService.placeOrder(request);
Thread.sleep(2000); // hope Kafka consumer processed by now
assertThat(orderRepo.findById(orderId).getStatus()).isEqualTo(CONFIRMED);
With Awaitility:
@Test
void shouldProcessOrderViaKafka() {
// Act: place order (publishes to Kafka)
UUID orderId = orderService.placeOrder(createOrderRequest());
// Assert: wait until Kafka consumer processes and updates order status
await()
.atMost(Duration.ofSeconds(10))
.pollInterval(Duration.ofMillis(250))
.untilAsserted(() -> {
Order order = orderRepo.findById(orderId).orElseThrow();
assertThat(order.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
});
}
Common patterns:
// Wait for a condition
await().atMost(5, SECONDS).until(() -> emailService.getEmailCount() == 1);
// Wait for assertion (exception-based — retries until no assertion error)
await().atMost(10, SECONDS).untilAsserted(() ->
assertThat(notificationRepo.findAll()).hasSize(1)
);
// Fail fast: ignore exceptions (service starting up) then assert
await().atMost(30, SECONDS)
.ignoreExceptions()
.until(() -> restTemplate.getForEntity(url, String.class).getStatusCode().is2xxSuccessful());
Q8 — What are Spring Boot test slices and when do you use them? senior
Answer: Test slices load only a specific slice of the Spring context (not the full context), making tests faster and more focused.
| Annotation | Loads | Use for |
|---|---|---|
@SpringBootTest |
Full Spring context | Full integration test |
@WebMvcTest |
Web layer (controllers, filters, security) | Controller unit tests with MockMvc |
@DataJpaTest |
JPA layer (entities, repositories, DataSource) | Repository tests with embedded DB |
@DataRedisTest |
Redis layer (RedisTemplate, repositories) | Redis integration tests |
@JsonTest |
Jackson serialization only | JSON serialization/deserialization tests |
@WebFluxTest |
WebFlux layer (reactive controllers) | Reactive controller tests |
// @WebMvcTest — only loads web layer, mocks @Service
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService; // mock service layer
@Test
void shouldReturn404WhenOrderNotFound() throws Exception {
when(orderService.getOrder(any())).thenThrow(new OrderNotFoundException("not found"));
mockMvc.perform(get("/api/v1/orders/{id}", UUID.randomUUID())
.header("X-User-Id", "user-123"))
.andExpect(status().isNotFound());
}
}
// @DataJpaTest — loads JPA only, uses H2 by default (or real DB via @AutoConfigureTestDatabase)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // use real DB
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired OrderRepository orderRepo;
@Test
void shouldFindActiveOrdersByCustomer() { ... }
}
Q9 — What is JaCoCo and how do you enforce coverage thresholds? junior
Answer: JaCoCo (Java Code Coverage) measures what percentage of code is exercised by tests. It instruments bytecode and generates coverage reports.
Maven configuration:
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<executions>
<!-- Instrument classes before tests run -->
<execution>
<id>prepare-agent</id>
<goals><goal>prepare-agent</goal></goals>
</execution>
<!-- Check coverage thresholds after tests -->
<execution>
<id>check</id>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum> <!-- 80% instruction coverage -->
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum> <!-- 70% branch coverage -->
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
Excluding classes from coverage:
<configuration>
<excludes>
<exclude>**/dto/**</exclude> <!-- DTOs/records — no logic -->
<exclude>**/config/**</exclude> <!-- Spring config classes -->
<exclude>**/*Application.class</exclude>
</excludes>
</configuration>
Q10 — What is Spring Cloud Contract and how does consumer-driven contract testing work? senior
Answer: Consumer-driven contract testing verifies that a service (producer) satisfies the contracts defined by its consumers, preventing integration breaks.
Workflow:
- Consumer (api-gateway) defines the contract: "I expect GET /users/{id} to return this JSON structure."
- Contract is stored in the producer (user-service) or a shared
commonmodule. - Producer auto-generates tests from contracts and verifies its implementation satisfies them.
- Stubs are generated from contracts and published to a repository.
- Consumer uses the stubs in integration tests to test against a realistic mock.
Contract definition (Groovy DSL in common module):
// src/test/resources/contracts/user/shouldReturnUserById.groovy
Contract.make {
description "should return user by ID"
request {
method GET()
url $(consumer(regex('/api/v1/users/[0-9a-f\\-]+')),
producer(url('/api/v1/users/123e4567-e89b-12d3-a456-426614174000')))
headers { header(Authorization, $(consumer(regex('Bearer .+')),
producer('Bearer valid-token'))) }
}
response {
status OK()
body([id: $(producer(regex('[0-9a-f\\-]+')), consumer('123e4567-e89b-12d3-a456-426614174000')),
email: $(producer(regex('.+@.+')), consumer('user@example.com'))])
headers { contentType(applicationJson()) }
}
}
Consumer test using stub:
@SpringBootTest
@AutoConfigureStubRunner(ids = "com.example:common:+:stubs:8080",
stubsMode = StubRunnerProperties.StubsMode.LOCAL)
class GatewayToUserServiceTest {
@Test
void shouldCallUserServiceAndReturnUser() {
// Calls WireMock stub auto-generated from contract
var user = restTemplate.getForObject("http://localhost:8080/api/v1/users/{id}", User.class, userId);
assertThat(user.email()).isNotNull();
}
}
Q11 — What is the difference between @SpringBootTest and @WebMvcTest? junior
Answer:
@SpringBootTest |
@WebMvcTest |
|
|---|---|---|
| Spring context | Full (all beans) | Web layer only |
| Services | Real beans (or @MockBean) |
Must mock (@MockBean) |
| Repositories | Real (need DB) | Not loaded |
| Speed | Slow (full context) | Fast |
| Use case | Full integration test | Controller logic, request mapping, validation |
| Port | RANDOM_PORT or DEFINED_PORT |
No real server (MockMvc) |
// @SpringBootTest — full stack
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Testcontainers
class OrderIntegrationTest {
@LocalServerPort int port;
// All beans loaded — need Testcontainers for DB, Kafka, Redis
}
// @WebMvcTest — controller slice
@WebMvcTest(OrderController.class)
class OrderControllerUnitTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService; // service layer mocked
// No DB/Kafka needed — fast test
}
Rule of thumb: Use @WebMvcTest for controller input validation, request mapping, security, and error handling. Use @SpringBootTest for end-to-end flows through all layers.
Q12 — How do you test Kafka consumers and producers with EmbeddedKafka? senior
Answer:
@EmbeddedKafka starts an in-memory Kafka broker for unit/lightweight integration tests — faster than Testcontainers Kafka (no Docker required).
@SpringBootTest
@EmbeddedKafka(partitions = 1,
topics = {"order.created", "payment.processed"},
bootstrapServersProperty = "spring.kafka.bootstrap-servers")
class OrderKafkaConsumerTest {
@Autowired
KafkaTemplate<String, OrderCreatedEvent> kafkaTemplate;
@Autowired
OrderRepository orderRepository;
@Test
void shouldProcessPaymentEventAndUpdateOrderStatus() throws Exception {
// Arrange: create a pending order
Order order = orderRepository.save(Order.pending(UUID.randomUUID()));
// Act: send payment processed event to embedded Kafka
PaymentProcessedEvent event = new PaymentProcessedEvent(order.getId(), "SUCCESS");
kafkaTemplate.send("payment.processed", order.getId().toString(), event).get();
// Assert: wait for consumer to process and update order
await().atMost(10, SECONDS).untilAsserted(() -> {
Order updated = orderRepository.findById(order.getId()).orElseThrow();
assertThat(updated.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
});
}
}
EmbeddedKafka vs Testcontainers Kafka:
| EmbeddedKafka | Testcontainers Kafka | |
|---|---|---|
| Startup time | ~1 second | ~10 seconds |
| Fidelity | In-memory, slightly different | Real Kafka |
| Schema Registry | Not supported | Supported |
| Avro | Cannot test easily | Full Avro support |
This system: Uses Testcontainers Kafka for integration tests (Schema Registry + Avro support needed).
Q13 — How does @Transactional work in Spring tests? junior
Answer:
When a test method is annotated with @Transactional, Spring wraps the test in a transaction that is automatically rolled back after the test — leaving the database clean for the next test.
@SpringBootTest
@Transactional // each test method runs in a transaction, rolled back after
class OrderServiceTest {
@Autowired OrderService orderService;
@Autowired OrderRepository orderRepo;
@Test
void shouldCreateOrder() {
// This insert is rolled back after the test
Order order = orderService.createOrder(new CreateOrderRequest(...));
assertThat(order.getId()).isNotNull();
assertThat(orderRepo.findById(order.getId())).isPresent();
} // <-- ROLLBACK here — no cleanup needed in @AfterEach
}
Caveat: @Transactional on tests does NOT work for:
- Async code (
@Async, Kafka consumers running in another thread) — they have their own transactions. - Tests that use
REQUIRES_NEWpropagation (new transaction commits independently). @SpringBootTest(webEnvironment = RANDOM_PORT)— the server runs in a separate thread.
For async tests: Use Testcontainers + Awaitility and clean up in @AfterEach:
@AfterEach
void cleanup() {
orderRepo.deleteAll();
outboxRepo.deleteAll();
}
Q14 — What is test isolation and how do you achieve it in integration tests? senior
Answer: Test isolation ensures that tests don't affect each other (no shared state, no ordering dependency). Without isolation, tests that pass individually may fail when run together.
Sources of shared state in integration tests:
- Database rows from previous tests.
- Redis keys from previous tests.
- Kafka topic offsets.
- Spring context state (though Spring caches contexts — usually safe).
Strategies:
1. @Transactional rollback (see Q13 — easiest for DB):
@Transactional // auto-rollback
class OrderRepoTest { ... }
2. @BeforeEach cleanup:
@BeforeEach
void setUp() {
orderRepo.deleteAll();
redisTemplate.delete("session:*"); // or use FLUSHDB for test Redis instance
}
3. Unique test data (no cleanup needed):
String uniqueEmail = "user-" + UUID.randomUUID() + "@test.com"; // unique per test
4. Separate Testcontainers per class (full isolation, slow): Each test class gets its own containers — guaranteed clean state.
5. Database transactions with TRUNCATE (fast for large datasets):
@BeforeEach
@Sql(scripts = "/sql/truncate-tables.sql") // TRUNCATE all tables before each test
void setUp() { }
Q15 — How do you test @RetryableTopic and DLT behavior? senior
Answer: Testing retry and DLT behavior requires verifying that failed messages are retried N times and then routed to the DLT topic.
@SpringBootTest
@Testcontainers
class OrderKafkaDLTTest {
@Container
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));
@Autowired KafkaTemplate<String, String> kafkaTemplate;
@SpyBean // spy on real bean to simulate failures
OrderCreatedEventConsumer orderConsumer;
@Test
void shouldRouteToDLTAfterMaxRetries() {
// Arrange: make consumer throw on first 4 attempts
AtomicInteger attempts = new AtomicInteger(0);
doAnswer(inv -> {
if (attempts.incrementAndGet() <= 4) throw new RuntimeException("transient failure");
return null;
}).when(orderConsumer).consume(any());
// Act: send message
kafkaTemplate.send("order.created", "key", "payload");
// Assert: DLT receives the message after 4 failures
Consumer<String, String> dltConsumer = buildConsumer("test-group");
dltConsumer.subscribe(List.of("order.created.DLT"));
await().atMost(30, SECONDS).untilAsserted(() -> {
ConsumerRecords<String, String> records = dltConsumer.poll(Duration.ofSeconds(1));
assertThat(records.count()).isGreaterThan(0);
});
}
}
Verify retry attempts counter: Use @SpyBean on the consumer and verify(consumer, times(4)).consume(any()).
Q16 — What is MockMvc and how does it differ from RestAssured? junior
Answer:
| MockMvc | RestAssured | |
|---|---|---|
| Server | No real server (mock servlet) | Real HTTP server (RANDOM_PORT) |
| Speed | Fast (no network) | Slower (HTTP stack) |
| Use with | @WebMvcTest, @SpringBootTest |
@SpringBootTest(webEnvironment=RANDOM_PORT) |
| Assertion style | Hamcrest matchers (andExpect) |
BDD (given/when/then) |
| WebFlux | Use WebTestClient | Use RestAssured |
MockMvc:
@Test
void shouldReturnOrderById() throws Exception {
mockMvc.perform(get("/api/v1/orders/{id}", orderId)
.header("X-User-Id", "user-123")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.status").value("CONFIRMED"))
.andDo(print()); // print request/response for debugging
}
RestAssured (see Q5 for full example).
WebTestClient (Spring WebFlux / api-gateway):
@Test
void shouldProxyToOrderService() {
webTestClient.get().uri("/orders")
.header("Authorization", "Bearer " + jwt)
.exchange()
.expectStatus().isOk()
.expectBodyList(OrderResponse.class)
.hasSize(2);
}
Q17 — How do you generate a JWT for integration tests? senior
Answer: Integration tests for protected endpoints need a valid JWT that passes the gateway's validation.
Using jjwt (same library as production user-service):
public class TestJwtHelper {
// Use same secret as in test application.properties
private static final String TEST_SECRET = "test-secret-key-at-least-256-bits-long";
public static String generateToken(String userId, String email, String role) {
SecretKey key = Keys.hmacShaKeyFor(TEST_SECRET.getBytes(StandardCharsets.UTF_8));
return Jwts.builder()
.subject(userId)
.claim("email", email)
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 3_600_000)) // 1 hour
.signWith(key, Jwts.SIG.HS256)
.compact();
}
}
// Usage in test
@Test
void shouldReturnOrdersForAuthenticatedUser() {
String jwt = TestJwtHelper.generateToken("user-123", "user@test.com", "USER");
given()
.header("Authorization", "Bearer " + jwt)
.when()
.get("/orders")
.then()
.statusCode(200);
}
For Redis session tests: Pre-populate Redis with a session entry matching the JWT:
@BeforeEach
void setUp() {
String sessionKey = "session:" + jwt;
redisTemplate.opsForHash().put(sessionKey, "userId", "user-123");
redisTemplate.opsForHash().put(sessionKey, "email", "user@test.com");
redisTemplate.opsForHash().put(sessionKey, "role", "USER");
redisTemplate.expire(sessionKey, Duration.ofMinutes(30));
}
Q18 — What is DockerComposeContainer in Testcontainers and how is it used for E2E tests? senior
Answer:
DockerComposeContainer starts the entire system using docker-compose.yml, enabling full end-to-end testing against all services.
// e2e-tests module
@Testcontainers
class FullOrderFlowE2ETest {
@Container
static DockerComposeContainer<?> compose =
new DockerComposeContainer<>(new File("../docker-compose.yml"))
.withExposedService("api-gateway", 8080,
Wait.forHttp("/actuator/health").forStatusCode(200)
.withStartupTimeout(Duration.ofMinutes(3)))
.withExposedService("order-service", 8083,
Wait.forHttp("/actuator/health").forStatusCode(200))
.withLocalCompose(true); // use local docker compose binary
@Test
void shouldCompleteFullOrderFlow() {
int gatewayPort = compose.getServicePort("api-gateway", 8080);
String baseUri = "http://localhost:" + gatewayPort;
// Register user
String jwt = given().baseUri(baseUri)
.body(new RegisterRequest("e2e@test.com", "Password123!"))
.post("/auth/register")
.then().statusCode(201).extract().path("token");
// Create order
String orderId = given().baseUri(baseUri)
.header("Authorization", "Bearer " + jwt)
.body(new CreateOrderRequest(...))
.post("/orders")
.then().statusCode(201).extract().path("id");
// Wait for Kafka payment processing
await().atMost(30, SECONDS).untilAsserted(() ->
given().baseUri(baseUri).header("Authorization", "Bearer " + jwt)
.get("/orders/" + orderId)
.then().body("status", equalTo("CONFIRMED")));
}
}
Q19 — What are @ParameterizedTest and data sources in JUnit 5? junior
Answer:
@ParameterizedTest runs the same test multiple times with different input values, reducing code duplication.
@ParameterizedTest(name = "status={0} should be terminal={1}")
@CsvSource({
"PENDING, false",
"CONFIRMED, false",
"CANCELLED, true",
"DELIVERED, true"
})
void shouldDetermineIfOrderIsTerminal(String status, boolean expected) {
assertThat(OrderStatus.valueOf(status).isTerminal()).isEqualTo(expected);
}
// @ValueSource for single parameter
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t", "\n"})
void shouldRejectBlankOrderNotes(String notes) {
assertThatThrownBy(() -> new CreateOrderRequest(UUID.randomUUID(), List.of(), notes))
.isInstanceOf(IllegalArgumentException.class);
}
// @MethodSource for complex objects
@ParameterizedTest
@MethodSource("provideInvalidOrders")
void shouldRejectInvalidOrders(CreateOrderRequest request, String expectedError) {
assertThatThrownBy(() -> orderService.create(request))
.hasMessageContaining(expectedError);
}
static Stream<Arguments> provideInvalidOrders() {
return Stream.of(
Arguments.of(new CreateOrderRequest(null, List.of()), "customerId is required"),
Arguments.of(new CreateOrderRequest(UUID.randomUUID(), List.of()), "items cannot be empty")
);
}
Q20 — What is the test data builder pattern and why is it useful? junior
Answer: Test data builders create test objects with sensible defaults, allowing tests to only specify the fields they care about.
Without builder (verbose, fragile):
Order order = new Order(
UUID.randomUUID(), UUID.randomUUID(), "customer@example.com",
OrderStatus.PENDING, new ArrayList<>(), BigDecimal.TEN,
"123 Main St", null, Instant.now(), null, null
);
With builder:
public class OrderTestBuilder {
private UUID id = UUID.randomUUID();
private UUID customerId = UUID.randomUUID();
private OrderStatus status = OrderStatus.PENDING;
private BigDecimal totalAmount = new BigDecimal("99.99");
private List<OrderItem> items = List.of(OrderItemTestBuilder.anItem().build());
public static OrderTestBuilder anOrder() { return new OrderTestBuilder(); }
public OrderTestBuilder withStatus(OrderStatus status) { this.status = status; return this; }
public OrderTestBuilder withCustomerId(UUID id) { this.customerId = id; return this; }
public OrderTestBuilder withTotalAmount(BigDecimal amount) { this.totalAmount = amount; return this; }
public Order build() { return new Order(id, customerId, status, totalAmount, items); }
public Order buildAndSave(OrderRepository repo) { return repo.save(build()); }
}
// Test using builder — only specify what matters
Order confirmedOrder = anOrder().withStatus(CONFIRMED).build();
Order userOrder = anOrder().withCustomerId(testUserId).build();
Q21 — How do you test Redis cache behavior in Spring Boot? senior
Answer:
Use @DataRedisTest or @SpringBootTest with a Testcontainers Redis instance to test real Redis behavior.
@SpringBootTest
@Testcontainers
class ProductCacheTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void config(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
@Autowired ProductService productService;
@Autowired StringRedisTemplate redisTemplate;
@Test
void shouldReturnCacheHitOnSecondCall() {
UUID productId = createTestProduct();
// First call — cache miss, loads from DB
productService.getProduct(productId);
assertThat(redisTemplate.hasKey("product:" + productId)).isTrue();
// Second call — cache hit (no DB query)
productService.getProduct(productId);
// Verify cache HIT header in HTTP response
given().get("/api/v1/products/" + productId)
.then().header("X-Cache", "HIT");
}
@Test
void shouldEvictCacheOnUpdate() {
UUID productId = createTestProduct();
productService.getProduct(productId); // populate cache
productService.updateProduct(productId, new UpdateProductRequest("New Name", null, null));
assertThat(redisTemplate.hasKey("product:" + productId)).isFalse(); // evicted
}
}
Q22 — What is @MockBean vs @SpyBean in Spring Boot tests? junior
Answer:
@MockBean |
@SpyBean |
|
|---|---|---|
| Creates | Full mock (all methods return defaults) | Spy on real bean (delegates to real impl) |
| Default behavior | Returns null/0/false/empty | Calls real method |
| Override | All methods stubbed | Selectively override specific methods |
| Use case | Replace a dependency entirely | Partially mock a real bean |
// @MockBean — replace entire service with mock
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@MockBean
OrderService orderService; // completely mocked
@Test
void testGetOrder() {
when(orderService.getOrder(any())).thenReturn(new OrderDto(...));
mockMvc.perform(get("/orders/{id}", orderId)).andExpect(status().isOk());
}
}
// @SpyBean — wrap real bean, only override specific method
@SpringBootTest
class OutboxPublisherTest {
@SpyBean
OutboxEventPublisher publisher; // real bean, but spied on
@Test
void shouldRetryOnKafkaFailure() {
// Override just the Kafka send — real DB operations still run
doThrow(new KafkaException("timeout")).doCallRealMethod()
.when(publisher).publishToKafka(any());
publisher.publishPendingEvents();
verify(publisher, times(2)).publishToKafka(any()); // first throw, second success
}
}
Q23 — How do you test outbox event publishing? senior
Answer: The outbox pattern requires testing that: (1) business operation + outbox insert are in the same transaction, and (2) the poller picks up events and publishes to Kafka.
@SpringBootTest
@Testcontainers
class OutboxIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));
@Autowired OrderService orderService;
@Autowired OutboxRepository outboxRepo;
@Autowired KafkaConsumer<String, String> testConsumer;
@Test
void shouldWriteOutboxEventInSameTransactionAsOrder() {
// Act
Order order = orderService.createOrder(validCreateRequest());
// Assert: outbox event created in same transaction
List<OutboxEvent> events = outboxRepo.findByAggregateId(order.getId());
assertThat(events).hasSize(1);
assertThat(events.get(0).getStatus()).isEqualTo("PENDING");
assertThat(events.get(0).getTopic()).isEqualTo("order.created");
}
@Test
void shouldPublishOutboxEventToKafka() {
Order order = orderService.createOrder(validCreateRequest());
testConsumer.subscribe(List.of("order.created"));
// Outbox poller runs on schedule — trigger manually or wait
await().atMost(10, SECONDS).untilAsserted(() -> {
ConsumerRecords<String, String> records = testConsumer.poll(Duration.ofSeconds(1));
assertThat(records.count()).isGreaterThan(0);
// Outbox event marked as PUBLISHED
OutboxEvent event = outboxRepo.findByAggregateId(order.getId()).get(0);
assertThat(event.getStatus()).isEqualTo("PUBLISHED");
});
}
@Test
void shouldRollbackOutboxEventOnOrderFailure() {
// If order creation fails, outbox should also NOT be saved (same transaction)
assertThatThrownBy(() -> orderService.createOrder(invalidRequest()));
assertThat(outboxRepo.findAll()).isEmpty();
}
}
Q24 — What is a @Tag annotation and how do you use it to filter tests? junior
Answer:
@Tag categorizes tests. CI/CD pipelines can selectively run or exclude tagged test groups.
// Apply tag to test class or method
@Tag("integration")
@SpringBootTest
class OrderIntegrationTest { ... }
@Tag("unit")
class OrderServiceUnitTest { ... }
@Test
@Tag("slow")
void shouldProcessLargeOrderBatch() { ... }
Maven Surefire configuration (JUnit 5 groups):
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- Include only unit tests in normal build -->
<groups>unit</groups>
<!-- Exclude slow tests -->
<excludedGroups>slow,performance</excludedGroups>
</configuration>
</plugin>
Run integration tests separately in CI:
<execution>
<id>integration-tests</id>
<phase>integration-test</phase>
<configuration>
<groups>integration</groups>
</configuration>
</execution>
Maven CLI:
mvn test -Dgroups=unit # unit tests only
mvn test -Dgroups=integration # integration tests only
mvn test -DexcludedGroups=slow # skip slow tests
Q25 — How do you test Spring Security and JWT authentication? senior
Answer: Testing security requires verifying: unauthenticated requests are rejected, authenticated requests pass, and authorization rules (roles) are enforced.
@WebMvcTest(OrderController.class)
class OrderControllerSecurityTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService;
// Test 1: No auth → 401
@Test
void shouldReturn401WhenNoToken() throws Exception {
mockMvc.perform(get("/api/v1/orders"))
.andExpect(status().isUnauthorized());
}
// Test 2: Admin endpoint with USER role → 403
@Test
@WithMockUser(roles = "USER")
void shouldReturn403WhenUserAccessesAdminEndpoint() throws Exception {
mockMvc.perform(get("/api/v1/admin/orders"))
.andExpect(status().isForbidden());
}
// Test 3: Custom JWT headers (this system's pattern)
@Test
void shouldReturnOrdersWhenXUserIdHeaderPresent() throws Exception {
when(orderService.getOrders(any())).thenReturn(List.of());
mockMvc.perform(get("/api/v1/orders")
.header("X-User-Id", "user-123")
.header("X-User-Role", "USER"))
.andExpect(status().isOk());
}
}
Integration test with real JWT:
@SpringBootTest(webEnvironment = RANDOM_PORT)
class AuthFlowIntegrationTest {
@Test
void shouldLoginAndAccessProtectedEndpoint() {
// Login
String token = given().body(new LoginRequest("user@test.com", "password"))
.post("/auth/login").then().statusCode(200)
.extract().path("token");
// Access protected endpoint
given().header("Authorization", "Bearer " + token)
.get("/orders")
.then().statusCode(200);
}
}
Q26 — What is @DynamicPropertySource and why is it needed with Testcontainers? junior
Answer:
@DynamicPropertySource overrides Spring's Environment properties at test startup with values that are only known at runtime (e.g., Testcontainers' dynamically assigned ports).
Problem: Testcontainers starts containers on a random port (to avoid conflicts). Spring configuration is typically evaluated before tests start — so localhost:5432 would be wrong.
Solution:
@SpringBootTest
@Testcontainers
class ServiceTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7")
.withExposedPorts(6379);
@DynamicPropertySource
static void overrideProperties(DynamicPropertyRegistry registry) {
// postgres.getJdbcUrl() returns something like:
// jdbc:postgresql://localhost:49823/test
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
// Redis on a random port
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
}
Alternative: Use ApplicationContextInitializer or Testcontainers' @ServiceConnection (Spring Boot 3.1+):
@Container
@ServiceConnection // auto-wires Spring Boot datasource properties
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
Q27 — How do you test rate limiting behavior in the API gateway? senior
Answer: Rate limiting (token bucket) needs to be tested by sending enough requests to exhaust the bucket and verifying that subsequent requests are rejected with 429.
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Testcontainers
class RateLimitingIntegrationTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void config(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
@Test
void shouldReturn429AfterRateLimitExceeded() {
String jwt = TestJwtHelper.generateToken("user-rate-test", "test@test.com", "USER");
String auth = "Bearer " + jwt;
// Send requests up to the rate limit (e.g., 10 per second)
for (int i = 0; i < 10; i++) {
given().header("Authorization", auth)
.get("/api/v1/products")
.then().statusCode(200);
}
// Next request should be rate limited
given().header("Authorization", auth)
.get("/api/v1/products")
.then()
.statusCode(429)
.header("X-RateLimit-Remaining", "0");
}
}
Q28 — What is the difference between unit tests and integration tests? junior
Answer:
| Unit Test | Integration Test | |
|---|---|---|
| Scope | Single class or function | Multiple components together |
| Dependencies | Mocked (Mockito) | Real (Testcontainers) |
| Speed | Milliseconds | Seconds to minutes |
| Infrastructure | None | DB, Kafka, Redis |
| Isolation | Perfect — only tests one thing | Tests component interactions |
| Debugging | Easy — small scope | Harder — many components involved |
// Unit test — mock all dependencies
class OrderServiceUnitTest {
@Mock OrderRepository orderRepository;
@Mock KafkaTemplate<String, OrderCreatedEvent> kafkaTemplate;
@InjectMocks OrderService orderService;
@Test
void shouldThrowWhenCustomerNotFound() {
when(orderRepository.findByCustomerId(any())).thenReturn(Optional.empty());
assertThatThrownBy(() -> orderService.getCustomerOrders(UUID.randomUUID()))
.isInstanceOf(CustomerNotFoundException.class);
}
}
// Integration test — real database
@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {
@Autowired OrderService orderService;
@Autowired OrderRepository orderRepository;
@Test
void shouldPersistOrderWithItems() {
Order created = orderService.createOrder(validRequest());
Order loaded = orderRepository.findByIdWithItems(created.getId()).orElseThrow();
assertThat(loaded.getItems()).hasSize(2);
}
}
Q29 — How do you measure and improve test execution speed? senior
Answer: Slow tests discourage running them frequently. Optimization reduces the feedback loop.
Profiling:
# Maven Surefire report (surefire-reports/*.txt)
mvn test -Dsurefire.reportFormat=brief
# Shows time per test class
# JUnit 5 built-in timing
@ExtendWith(TimingExtension.class)
Optimization strategies:
1. Parallelize test classes (Maven Surefire):
<configuration>
<forkCount>2</forkCount> <!-- 2 JVM forks -->
<reuseForks>true</reuseForks>
<parallel>classes</parallel> <!-- parallel execution within fork -->
<useUnlimitedThreads>true</useUnlimitedThreads>
</configuration>
2. Spring context caching: Tests with the same @SpringBootTest config share the Spring context. Minimize @MockBean usage (each different set of mocks = new context).
3. Testcontainers reuse (see Q4): Avoid starting/stopping containers per test class.
4. Test slices (see Q8): @WebMvcTest is 3–10× faster than @SpringBootTest.
5. Avoid @BeforeAll heavy setup in every class: Use abstract base class with static shared containers.
6. Run unit tests separately from integration tests (see Q24): Fast unit tests run on every commit; slow integration tests run on PR merge.
Q30 — What is the jacoco-report aggregate module and how does it combine coverage? senior
Answer:
In a multi-module Maven project, each module produces its own JaCoCo report. An aggregate report module (jacoco-report/) collects all module reports into a single combined report.
jacoco-report/pom.xml:
<project>
<artifactId>jacoco-report</artifactId>
<packaging>pom</packaging>
<dependencies>
<!-- Reference all modules whose coverage to aggregate -->
<dependency><artifactId>order-service</artifactId>...</dependency>
<dependency><artifactId>payment-service</artifactId>...</dependency>
<dependency><artifactId>user-service</artifactId>...</dependency>
<!-- ... all services -->
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<executions>
<execution>
<id>aggregate-report</id>
<phase>verify</phase>
<goals>
<goal>report-aggregate</goal> <!-- combines all module reports -->
</goals>
<configuration>
<dataFileIncludes>
<!-- Collect .exec files from all modules -->
<dataFileInclude>**/jacoco.exec</dataFileInclude>
</dataFileIncludes>
<outputDirectory>${project.reporting.outputDirectory}/jacoco-aggregate</outputDirectory>
</configuration>
</execution>
<execution>
<id>check-aggregate</id>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum> <!-- 80% across ALL modules -->
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Running:
mvn verify # runs all tests + JaCoCo check
mvn jacoco:report-aggregate # generate HTML report only
# Report: jacoco-report/target/site/jacoco-aggregate/index.html
Why 80% instruction coverage? Instruction coverage is more meaningful than line coverage — it measures every bytecode instruction, including implicit branching from ternary operators, null checks, etc. The common module and DTOs are typically excluded.