Testing Like
a Professional
Most developers write tests to make CI green. Professional engineers write tests to encode system behaviour, catch regressions, document contracts, and enable fearless refactoring. There is a world of difference between a test suite that gives you confidence and one that gives you false confidence. This section teaches how real engineering teams test production systems — not how to pass a tutorial.
Testing Philosophy: What Are Tests For?
A test suite has one job: give you confidence that the system works correctly. The moment your tests stop giving you confidence — because they're too slow, too brittle, too coupled to implementation — they become friction rather than value.
No I/O, no Spring context
80% of your test count
Real containers (Testcontainers)
15% of your test count
Full application stack
5% of your test count
The most common testing mistake: mocking everything so that tests pass even when the real system is completely broken. If you mock the repository, mock the cache, mock the event bus, and mock the validator — you're testing that your mock configuration is correct, not that your system works. The rule: mock at the boundary of your system (external HTTP calls, email service), use real implementations for things you own.
Unit Testing with JUnit 5 & Mockito
A unit test exercises a single class in complete isolation. It runs in milliseconds, has no external dependencies, and tests business logic — not framework plumbing. The art is knowing what to test and what to skip.
Service Layer Unit Tests
// The class under test
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
private final PaymentGateway paymentGateway;
private final ApplicationEventPublisher eventPublisher;
public Order createOrder(Long userId, Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
if (product.getStock() < quantity) {
throw new InsufficientStockException(productId, quantity);
}
Money total = product.getPrice().multiply(quantity);
PaymentResult payment = paymentGateway.charge(userId, total);
if (!payment.isSuccessful()) {
throw new PaymentFailedException(payment.getErrorCode());
}
Order order = Order.create(userId, productId, quantity, total);
Order saved = orderRepository.save(order);
eventPublisher.publishEvent(new OrderCreatedEvent(saved));
return saved;
}
}
// Unit test — no Spring context, runs in <10ms
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock private OrderRepository orderRepository;
@Mock private ProductRepository productRepository;
@Mock private PaymentGateway paymentGateway;
@Mock private ApplicationEventPublisher eventPublisher;
@InjectMocks private OrderService orderService;
private Product product;
@BeforeEach
void setUp() {
product = Product.builder()
.id(1L)
.name("MacBook Pro")
.price(Money.of(2999.00, "USD"))
.stock(10)
.build();
}
@Test
@DisplayName("should create order when stock is sufficient and payment succeeds")
void createOrder_success() {
// Arrange
given(productRepository.findById(1L)).willReturn(Optional.of(product));
given(paymentGateway.charge(any(), any()))
.willReturn(PaymentResult.success("txn-123"));
given(orderRepository.save(any(Order.class)))
.willAnswer(inv -> inv.getArgument(0));
// Act
Order order = orderService.createOrder(100L, 1L, 2);
// Assert
assertThat(order.getUserId()).isEqualTo(100L);
assertThat(order.getQuantity()).isEqualTo(2);
assertThat(order.getTotal()).isEqualTo(Money.of(5998.00, "USD"));
// Verify side effects
then(orderRepository).should(times(1)).save(any(Order.class));
then(eventPublisher).should(times(1))
.publishEvent(any(OrderCreatedEvent.class));
}
@Test
@DisplayName("should throw InsufficientStockException when stock is too low")
void createOrder_insufficientStock() {
// Arrange
Product lowStockProduct = product.toBuilder().stock(1).build();
given(productRepository.findById(1L))
.willReturn(Optional.of(lowStockProduct));
// Act + Assert
assertThatThrownBy(() -> orderService.createOrder(100L, 1L, 5))
.isInstanceOf(InsufficientStockException.class)
.hasMessageContaining("product 1");
// Verify payment was never charged
then(paymentGateway).shouldHaveNoInteractions();
then(orderRepository).shouldHaveNoInteractions();
}
@Test
@DisplayName("should throw PaymentFailedException when payment is declined")
void createOrder_paymentFailed() {
given(productRepository.findById(1L)).willReturn(Optional.of(product));
given(paymentGateway.charge(any(), any()))
.willReturn(PaymentResult.failure("INSUFFICIENT_FUNDS"));
assertThatThrownBy(() -> orderService.createOrder(100L, 1L, 1))
.isInstanceOf(PaymentFailedException.class)
.hasFieldOrPropertyWithValue("errorCode", "INSUFFICIENT_FUNDS");
then(orderRepository).shouldHaveNoInteractions();
then(eventPublisher).shouldHaveNoInteractions();
}
@Test
@DisplayName("should throw ProductNotFoundException for non-existent product")
void createOrder_productNotFound() {
given(productRepository.findById(999L)).willReturn(Optional.empty());
assertThatThrownBy(() -> orderService.createOrder(100L, 999L, 1))
.isInstanceOf(ProductNotFoundException.class);
}
}
Testing Edge Cases with Parameterized Tests
@ParameterizedTest(name = "quantity={0} should be {1}")
@CsvSource({
"0, false",
"-1, false",
"1, true",
"100, true",
"101, false" // stock is 100
})
void validateQuantity(int quantity, boolean expected) {
boolean valid = orderService.isQuantityValid(productId, quantity);
assertThat(valid).isEqualTo(expected);
}
// Test with complex object inputs
@ParameterizedTest
@MethodSource("provideOrderScenarios")
void orderPricing(int quantity, BigDecimal price, BigDecimal expectedTotal) {
Product p = product.toBuilder()
.price(Money.of(price, "USD")).build();
given(productRepository.findById(any())).willReturn(Optional.of(p));
// ... assert expected total
}
static Stream<Arguments> provideOrderScenarios() {
return Stream.of(
Arguments.of(1, new BigDecimal("10.00"), new BigDecimal("10.00")),
Arguments.of(3, new BigDecimal("10.00"), new BigDecimal("30.00")),
Arguments.of(10, new BigDecimal("99.99"), new BigDecimal("999.90"))
);
}
// Testing with @Spy — partial mock (real object, some methods overridden)
@Spy
private EmailFormatter emailFormatter = new EmailFormatter();
@Test
void sendWelcomeEmail() {
// emailFormatter.format() runs real code, but we can verify it was called
doReturn("<h1>Welcome</h1>").when(emailFormatter).format(any());
emailService.sendWelcome("user@example.com");
verify(emailFormatter).format(any(WelcomeEmailTemplate.class));
}
// Testing time-dependent code with fixed clock
@Test
void tokenExpiry() {
Clock fixedClock = Clock.fixed(
Instant.parse("2025-01-01T12:00:00Z"), ZoneOffset.UTC);
JwtService jwtService = new JwtService(secret, fixedClock);
String token = jwtService.generateToken("userId-1", Duration.ofHours(1));
// Token expires at 13:00 — test at 12:30 (valid) and 13:30 (expired)
Clock at1230 = Clock.fixed(
Instant.parse("2025-01-01T12:30:00Z"), ZoneOffset.UTC);
assertThat(jwtService.isValid(token, at1230)).isTrue();
Clock at1330 = Clock.fixed(
Instant.parse("2025-01-01T13:30:00Z"), ZoneOffset.UTC);
assertThat(jwtService.isValid(token, at1330)).isFalse();
}
Unit tests are high-value for: business logic with conditional branches, calculations and transformations, exception handling logic, input validation, domain model behaviour. Unit tests are low-value for: simple getters/setters, one-liner delegation methods, constructors with no logic, Spring configuration classes, and controller mappings (test those with MockMvc instead).
MockMvc: Testing the Web Layer
MockMvc tests your controllers without starting a real HTTP server. It exercises the full Spring MVC pipeline: request mapping, argument resolution, validation, exception handling, and response serialization. This is the right way to test your API contract.
// Slice test — only loads web layer beans, mocks service layer
@WebMvcTest(OrderController.class)
@AutoConfigureMockMvc
class OrderControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@MockBean private OrderService orderService; // Spring-managed mock
@MockBean private JwtService jwtService;
@Test
@DisplayName("POST /api/v1/orders returns 201 with order on success")
void createOrder_returns201() throws Exception {
// Arrange
CreateOrderRequest request = new CreateOrderRequest(1L, 2);
Order createdOrder = Order.builder()
.id(100L).productId(1L).quantity(2)
.total(Money.of(5998.00, "USD"))
.status(OrderStatus.PENDING)
.build();
given(orderService.createOrder(any(), eq(1L), eq(2)))
.willReturn(createdOrder);
given(jwtService.extractUserId(any())).willReturn(42L);
// Act + Assert
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer test-token")
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(100))
.andExpect(jsonPath("$.status").value("PENDING"))
.andExpect(jsonPath("$.total.amount").value(5998.00))
.andDo(print()); // Print request/response in test output
}
@Test
@DisplayName("POST /api/v1/orders returns 400 when quantity is zero")
void createOrder_returns400_forZeroQuantity() throws Exception {
CreateOrderRequest invalidRequest = new CreateOrderRequest(1L, 0);
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer test-token")
.content(objectMapper.writeValueAsString(invalidRequest)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[0].field").value("quantity"))
.andExpect(jsonPath("$.errors[0].message").exists());
// Service should never be called with invalid input
then(orderService).shouldHaveNoInteractions();
}
@Test
@DisplayName("POST /api/v1/orders returns 409 when stock insufficient")
void createOrder_returns409_onInsufficientStock() throws Exception {
CreateOrderRequest request = new CreateOrderRequest(1L, 999);
given(orderService.createOrder(any(), any(), any()))
.willThrow(new InsufficientStockException(1L, 999));
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer test-token")
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.message").value(containsString("stock")));
}
@Test
@DisplayName("GET /api/v1/orders returns paginated results")
void getOrders_returnsPaginatedResults() throws Exception {
Page<Order> page = new PageImpl<>(
List.of(Order.builder().id(1L).build()),
PageRequest.of(0, 20),
1
);
given(orderService.getUserOrders(any(), any())).willReturn(page);
mockMvc.perform(get("/api/v1/orders?page=0&size=20")
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.content.length()").value(1))
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.totalPages").value(1));
}
@Test
@DisplayName("returns 401 when Authorization header is missing")
@WithAnonymousUser
void createOrder_returns401_withNoAuth() throws Exception {
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isUnauthorized());
}
}
Testing Security with @WithMockUser
@Test
@WithMockUser(username = "admin@example.com", roles = {"ADMIN"})
void adminEndpoint_allowsAdmin() throws Exception {
mockMvc.perform(delete("/api/v1/products/1"))
.andExpect(status().isNoContent());
}
@Test
@WithMockUser(username = "user@example.com", roles = {"USER"})
void adminEndpoint_forbidsRegularUser() throws Exception {
mockMvc.perform(delete("/api/v1/products/1"))
.andExpect(status().isForbidden());
}
// Custom SecurityMockMvcResultMatchers for JWT-based auth
@Test
void jwtProtectedEndpoint() throws Exception {
String token = jwtService.generateToken("user-1", Duration.ofHours(1));
mockMvc.perform(get("/api/v1/profile")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.userId").value("user-1"));
}
// ResultHandlers — print, log, save to file for documentation
mockMvc.perform(get("/api/v1/products/1"))
.andDo(print()) // print to stdout
.andDo(document("get-product", // Spring REST Docs — generates docs from tests!
pathParameters(
parameterWithName("id").description("Product ID")
),
responseFields(
fieldWithPath("id").description("Product identifier"),
fieldWithPath("name").description("Product name"),
fieldWithPath("price.amount").description("Price amount"),
fieldWithPath("price.currency").description("ISO currency code")
)
))
.andExpect(status().isOk());
Testcontainers: Real Dependencies in Tests
Testcontainers spins up real Docker containers for your tests. No more H2 in-memory databases that behave differently from PostgreSQL, no more Redis mocks that don't support Lua scripts, no more Kafka stubs that don't test serialization. Tests run against the same technology as production.
// build.gradle
testImplementation 'org.testcontainers:testcontainers:1.19.3'
testImplementation 'org.testcontainers:postgresql:1.19.3'
testImplementation 'org.testcontainers:kafka:1.19.3'
testImplementation 'org.testcontainers:junit-jupiter:1.19.3'
testImplementation 'com.redis:testcontainers-redis:2.0.1'
// Shared container configuration — reused across all tests in the suite
// Containers start once, run all tests, then stop. Huge time saving!
@TestConfiguration
public class TestContainersConfig {
// Static fields = containers are started ONCE for entire test suite
@Container
static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.withReuse(true); // Reuse across test runs (speeds CI)
@Container
static final GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379)
.withReuse(true);
@Container
static final KafkaContainer kafka =
new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.5.0"))
.withReuse(true);
// Override Spring properties with container connection details
@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.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port",
() -> redis.getMappedPort(6379));
registry.add("spring.kafka.bootstrap-servers",
kafka::getBootstrapServers);
}
}
// Base class — inherit in all integration tests
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(TestContainersConfig.class)
@Transactional // Rollback after each test
public abstract class BaseIntegrationTest {
@Autowired protected MockMvc mockMvc;
@Autowired protected ObjectMapper objectMapper;
@Autowired protected JdbcTemplate jdbcTemplate;
}
// Concrete integration test — inherits containers
class OrderIntegrationTest extends BaseIntegrationTest {
@Autowired private OrderRepository orderRepository;
@Test
@Sql("/test-data/products.sql") // Load test fixtures
void createOrder_persistsToDatabase() throws Exception {
CreateOrderRequest request = new CreateOrderRequest(1L, 2);
mockMvc.perform(post("/api/v1/orders")
.header("Authorization", bearerToken("user-1"))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNumber());
// Verify it actually hit the database
List<Order> orders = orderRepository.findByUserId("user-1");
assertThat(orders).hasSize(1);
assertThat(orders.get(0).getProductId()).isEqualTo(1L);
}
}
// Test with real Redis
class CacheIntegrationTest extends BaseIntegrationTest {
@Autowired private ProductCacheService cacheService;
@Autowired private StringRedisTemplate redisTemplate;
@Test
void getProduct_cachesMissResultInRedis() {
// First call — cache miss, loads from DB
Product product = cacheService.getProduct(1L);
assertThat(product).isNotNull();
// Verify Redis has the entry
assertThat(redisTemplate.hasKey("product:1")).isTrue();
// Second call — must come from cache (verify DB not hit)
Product cached = cacheService.getProduct(1L);
assertThat(cached.getId()).isEqualTo(product.getId());
}
@Test
void updateProduct_evictsCachedEntry() {
cacheService.getProduct(1L); // Populate cache
assertThat(redisTemplate.hasKey("product:1")).isTrue();
cacheService.updateProduct(1L, new ProductUpdateRequest("New Name"));
// Cache entry must be gone after update
assertThat(redisTemplate.hasKey("product:1")).isFalse();
}
}
By default, Testcontainers stops and removes containers after each test class. With .withReuse(true) (requires testcontainers.reuse.enable=true in ~/.testcontainers.properties), the same container instance is reused across multiple test runs in the same machine session. Your integration test suite drops from 45 seconds to 8 seconds — the 37 seconds you were spending on container startup is reclaimed. Always clear test data between tests with @Transactional rollback or explicit SQL cleanup, since the container state persists.
Repository Layer Testing
Repository tests verify that your queries work correctly against a real database. Custom JPQL, native SQL, and derived query methods all need testing — they fail at runtime in ways you'll never catch with mocks.
// @DataJpaTest loads only JPA layer — much faster than @SpringBootTest
// Replaces datasource with the Testcontainers one via @AutoConfigureTestDatabase
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(TestContainersConfig.class)
class OrderRepositoryTest {
@Autowired private OrderRepository orderRepository;
@Autowired private TestEntityManager entityManager;
@Test
void findByUserIdAndStatus_returnsMatchingOrders() {
// Arrange — persist test data directly via EntityManager
Order o1 = entityManager.persist(Order.builder()
.userId(1L).status(OrderStatus.PENDING).build());
Order o2 = entityManager.persist(Order.builder()
.userId(1L).status(OrderStatus.COMPLETED).build());
Order o3 = entityManager.persist(Order.builder()
.userId(2L).status(OrderStatus.PENDING).build());
entityManager.flush(); // Flush to DB before querying
// Act
List<Order> result = orderRepository
.findByUserIdAndStatus(1L, OrderStatus.PENDING);
// Assert
assertThat(result).hasSize(1);
assertThat(result.get(0).getId()).isEqualTo(o1.getId());
}
@Test
void findTopSpenders_returnsOrderedByTotal() {
// Test native SQL query — pagination must work
Page<SpenderProjection> topSpenders = orderRepository
.findTopSpenders(PageRequest.of(0, 5));
assertThat(topSpenders.getContent()).isSortedAccordingTo(
Comparator.comparingDouble(SpenderProjection::getTotalSpent)
.reversed()
);
}
@Test
void countByCreatedAtBetween_returnsCorrectCount() {
// Test time-range queries — very common bug source
LocalDateTime start = LocalDateTime.of(2025, 1, 1, 0, 0);
LocalDateTime end = LocalDateTime.of(2025, 1, 31, 23, 59);
entityManager.persist(Order.builder()
.createdAt(LocalDateTime.of(2025, 1, 15, 10, 0)).build());
entityManager.persist(Order.builder()
.createdAt(LocalDateTime.of(2025, 2, 1, 10, 0)).build()); // Outside range
entityManager.flush();
long count = orderRepository.countByCreatedAtBetween(start, end);
assertThat(count).isEqualTo(1);
}
}
Testing Kafka Producers & Consumers
Kafka integration tests are tricky because they involve async behaviour — you produce a message and need to wait for the consumer to process it. Testcontainers with an embedded Kafka makes this possible without mocking the broker.
@SpringBootTest
@EmbeddedKafka(partitions = 1,
brokerProperties = {"listeners=PLAINTEXT://localhost:9092"},
topics = {"order-events", "order-events-dlq"})
class OrderEventTest {
@Autowired private KafkaTemplate<String, OrderEvent> kafkaTemplate;
@Autowired private OrderEventConsumer consumer;
// Latch to synchronize async processing in tests
private static final CountDownLatch latch = new CountDownLatch(1);
private static OrderEvent receivedEvent;
@Test
void orderCreatedEvent_isConsumedSuccessfully() throws Exception {
OrderEvent event = new OrderEvent("ORDER_CREATED", 42L, 100L);
kafkaTemplate.send("order-events", event).get(); // Wait for ack
// Wait up to 10s for consumer to process
boolean processed = latch.await(10, TimeUnit.SECONDS);
assertThat(processed).as("Event was not consumed within 10 seconds").isTrue();
assertThat(receivedEvent.getOrderId()).isEqualTo(42L);
}
// Mock consumer listener that signals the latch
@Component
static class TestConsumerHelper {
@KafkaListener(topics = "order-events")
void onEvent(OrderEvent event) {
receivedEvent = event;
latch.countDown();
}
}
}
// Testing consumer error handling — messages sent to DLQ
@Test
void malformedMessage_sentToDeadLetterTopic() throws Exception {
// Use raw template to send invalid JSON
KafkaTemplate<String, String> rawTemplate = ...;
rawTemplate.send("order-events", "not-valid-json").get();
CountDownLatch dlqLatch = new CountDownLatch(1);
// Listen to DLQ
consumer = new Consumer<ConsumerRecord<?, ?>>() {
public void accept(ConsumerRecord<?, ?> record) {
dlqLatch.countDown();
}
};
boolean dlqReceived = dlqLatch.await(15, TimeUnit.SECONDS);
assertThat(dlqReceived).isTrue();
}
Contract Testing with Spring Cloud Contract
In a microservices architecture, Service A calls Service B. How do you know their API contract hasn't broken? Integration tests catch this but are slow, expensive, and require both services to be running. Contract testing is a faster, cheaper alternative that can run in CI without inter-service dependencies.
// File: src/test/resources/contracts/products/get_product.groovy
// Written by the CONSUMER team (Order Service)
Contract.make {
description "should return product by id"
request {
method GET()
url "/api/v1/products/1"
headers {
contentType(applicationJson())
}
}
response {
status OK()
headers {
contentType(applicationJson())
}
body([
id: 1,
name: "MacBook Pro",
price: [
amount: $(consumer(1499.00), producer(1499.00)),
currency: "USD"
],
stock: $(consumer(anyPositiveInt()), producer(10))
])
}
}
// Consumer side: test using generated stub
@SpringBootTest
@AutoConfigureStubRunner(
ids = "com.example:product-service:+:stubs:8090",
stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
class OrderServiceContractTest {
@Autowired private OrderService orderService;
@Test
void createOrder_callsProductServiceContract() {
// This calls the stub at localhost:8090
// The stub was generated from the contract above
Order order = orderService.createOrder(1L, 1L, 2);
assertThat(order.getTotal().getAmount())
.isEqualByComparingTo(new BigDecimal("2998.00")); // 2 × 1499.00
}
}
// Provider side: auto-generated test (provider doesn't write this!)
// Spring Cloud Contract generates this from the contract file
@SpringBootTest
public class ProductContractVerificationTest extends HttpVerifier {
@LocalServerPort
private int port;
@BeforeEach
void setUp() {
RestAssured.baseURI = "http://localhost:" + port;
}
// Generated test for get_product.groovy contract:
@Test
public void validate_get_product() throws Exception {
Response response = given()
.header("Content-Type", "application/json")
.get("/api/v1/products/1");
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.jsonPath().getString("name"))
.isEqualTo("MacBook Pro");
// All fields from contract are verified automatically
}
}
Spring Boot Test Slices
Loading the full Spring context for every test is slow. Spring Boot's test slices load only the beans relevant to a specific layer, making tests 5–10x faster while still exercising real Spring infrastructure.
Excludes: Services, Repositories, DB
Use: Test API contract, validation, auth
Excludes: Web layer, Services
Use: Query correctness, entity mapping
Excludes: Everything else
Use: JSON serialization/deserialization
Excludes: Web, JPA, Services
Use: Redis operations, cache logic
Excludes: Web layer, DB
Use: HTTP client behaviour, retry logic
Excludes: Nothing
Use: Full integration tests, E2E tests
// @JsonTest — test serialization without any Spring context overhead
@JsonTest
class ProductDtoJsonTest {
@Autowired private JacksonTester<ProductDto> json;
@Test
void serialize_includesAllRequiredFields() throws Exception {
ProductDto dto = ProductDto.builder()
.id(1L).name("MacBook Pro")
.price(new MoneyDto(new BigDecimal("1499.00"), "USD"))
.build();
JsonContent<ProductDto> written = json.write(dto);
assertThat(written).hasJsonPathNumberValue("$.id", 1);
assertThat(written).hasJsonPathStringValue("$.name", "MacBook Pro");
assertThat(written).hasJsonPathNumberValue("$.price.amount", 1499.00);
assertThat(written).hasJsonPathStringValue("$.price.currency", "USD");
// Verify no internal fields leak into the API response
assertThat(written).doesNotHaveJsonPath("$.internalCost");
assertThat(written).doesNotHaveJsonPath("$.supplierId");
}
@Test
void deserialize_parsesDateFieldCorrectly() throws Exception {
String json = """
{
"id": 1,
"createdAt": "2025-01-15T10:30:00Z"
}
""";
ProductDto dto = this.json.parse(json).getObject();
assertThat(dto.getCreatedAt())
.isEqualTo(Instant.parse("2025-01-15T10:30:00Z"));
}
}
// @RestClientTest — test HTTP clients and retry behaviour
@RestClientTest(ProductServiceClient.class)
class ProductServiceClientTest {
@Autowired private ProductServiceClient client;
@Autowired private MockRestServiceServer server;
@Test
void getProduct_parsesResponseCorrectly() {
server.expect(requestTo("/api/v1/products/1"))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess(
"{\"id\":1,\"name\":\"MacBook Pro\"}",
MediaType.APPLICATION_JSON));
Product product = client.getProduct(1L);
assertThat(product.getName()).isEqualTo("MacBook Pro");
server.verify();
}
@Test
void getProduct_retries_onServerError() {
// First two calls return 500, third returns 200
server.expect(ExpectedCount.times(3), requestTo("/api/v1/products/1"))
.andRespond(
withServerError(),
withServerError(),
withSuccess("{\"id\":1}", MediaType.APPLICATION_JSON));
Product product = client.getProduct(1L); // Should retry twice
assertThat(product).isNotNull();
server.verify();
}
}
Test Data Management
Poorly managed test data is the primary cause of flaky tests. Tests that depend on each other's data, tests that leave state behind, tests that assume specific IDs exist — all of these cause false failures that erode trust in the test suite.
// Pattern 1: @Sql annotations for test data
@Test
@Sql(scripts = "/sql/products.sql",
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "/sql/cleanup.sql",
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
void test_withSqlFixtures() { ... }
// Pattern 2: Test Data Builder (preferred for complex objects)
// Fluent builder that creates valid defaults, override only what matters
public class ProductTestDataBuilder {
private Long id = null; // null = auto-generated
private String name = "Test Product";
private BigDecimal price = new BigDecimal("9.99");
private String currency = "USD";
private int stock = 100;
private ProductStatus status = ProductStatus.ACTIVE;
public static ProductTestDataBuilder aProduct() {
return new ProductTestDataBuilder();
}
public ProductTestDataBuilder withName(String name) {
this.name = name; return this;
}
public ProductTestDataBuilder withPrice(double price) {
this.price = new BigDecimal(price); return this;
}
public ProductTestDataBuilder outOfStock() {
this.stock = 0; return this;
}
public ProductTestDataBuilder discontinued() {
this.status = ProductStatus.DISCONTINUED; return this;
}
public Product build() {
return Product.builder()
.id(id).name(name)
.price(Money.of(price, currency))
.stock(stock).status(status).build();
}
public Product persist(ProductRepository repo) {
return repo.save(build());
}
}
// Tests read cleanly — intent is obvious
@Test
void discontinuedProduct_cannotBeOrdered() {
Product product = aProduct().discontinued().persist(productRepository);
assertThatThrownBy(() -> orderService.createOrder(1L, product.getId(), 1))
.isInstanceOf(ProductNotAvailableException.class);
}
// Pattern 3: @Transactional rollback (cleanest for JPA tests)
@SpringBootTest
@Transactional // Everything in this test class rolls back automatically
class OrderServiceIntegrationTest {
// Each test gets a clean database state — no teardown needed
}
// Pattern 4: Custom TestEntityManager wrapper
@Component
public class TestFixtures {
@Autowired private UserRepository userRepo;
@Autowired private ProductRepository productRepo;
public User createUser(String email) {
return userRepo.save(User.builder()
.email(email).role(Role.USER).build());
}
public Product createProduct(String name, double price) {
return productRepo.save(Product.builder()
.name(name).price(Money.of(price, "USD")).stock(100).build());
}
}
Tests using UUID.randomUUID() or Faker.randomEmail() for every field are non-deterministic — they pass today and fail tomorrow with a different value that triggers a bug. Use fixed, meaningful test data. If you need multiple unique values, use a counter (user1@test.com, user2@test.com) or a fixed set of enum values. Randomness in test data hides assumptions about valid value ranges.
Security Testing
Security tests verify that authorization is correctly applied. These tests should be non-negotiable for any API endpoint. A missing authorization test is an authorization bug waiting to be exploited.
// Comprehensive security test matrix
@WebMvcTest(AdminController.class)
class AdminControllerSecurityTest {
@Autowired private MockMvc mockMvc;
@MockBean private AdminService adminService;
// Test every endpoint with every relevant role
@ParameterizedTest
@MethodSource("adminEndpointAccessMatrix")
void adminEndpoint_accessControl(
String method, String url,
String role, int expectedStatus) throws Exception {
MockHttpServletRequestBuilder request = switch (method) {
case "GET" -> get(url);
case "POST" -> post(url).contentType(APPLICATION_JSON).content("{}");
case "DELETE" -> delete(url);
default -> throw new IllegalArgumentException(method);
};
if (role != null) {
request = request.with(user("test").roles(role));
}
mockMvc.perform(request)
.andExpect(status().is(expectedStatus));
}
static Stream<Arguments> adminEndpointAccessMatrix() {
return Stream.of(
// (method, url, role, expectedStatus)
Arguments.of("GET", "/admin/users", "ADMIN", 200),
Arguments.of("GET", "/admin/users", "USER", 403),
Arguments.of("GET", "/admin/users", null, 401), // no auth
Arguments.of("DELETE", "/admin/users/1", "ADMIN", 204),
Arguments.of("DELETE", "/admin/users/1", "USER", 403),
Arguments.of("DELETE", "/admin/users/1", null, 401),
Arguments.of("POST", "/admin/users", "ADMIN", 201),
Arguments.of("POST", "/admin/users", "USER", 403)
);
}
}
// Test for IDOR (Insecure Direct Object Reference)
@Test
@WithMockUser(username = "user-1")
void getOrder_forbids_accessingOtherUsersOrder() throws Exception {
// Order belongs to user-2, not user-1
given(orderService.getOrder(anyLong()))
.willReturn(Order.builder().userId(2L).build());
mockMvc.perform(get("/api/v1/orders/999"))
.andExpect(status().isForbidden());
}
// Test for SQL injection in query params
@Test
void searchProducts_sanitizesQueryParam() throws Exception {
// SQL injection attempt in search param
mockMvc.perform(get("/api/v1/products/search")
.param("q", "'; DROP TABLE products; --"))
.andExpect(status().isOk()) // Should not throw/crash
.andExpect(jsonPath("$.content").isArray());
// Never returns 500 — that would confirm DB error from injection
}
// Test rate limiting
@Test
void rateLimitedEndpoint_returns429_afterThreshold() throws Exception {
for (int i = 0; i < 100; i++) {
mockMvc.perform(post("/api/v1/auth/login")
.contentType(APPLICATION_JSON)
.content("{\"email\":\"brute@force.com\",\"password\":\"wrong\"}"));
}
mockMvc.perform(post("/api/v1/auth/login")
.contentType(APPLICATION_JSON)
.content("{\"email\":\"brute@force.com\",\"password\":\"wrong\"}"))
.andExpect(status().isTooManyRequests());
}
Mutation Testing: Do Your Tests Actually Test Anything?
Code coverage tells you which lines were executed. It does not tell you whether your tests would catch a bug. Mutation testing does. It introduces small defects (mutations) into your code and checks whether your tests catch them. If they don't, your tests are insufficient.
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.15.0</version>
<dependencies>
<dependency>
<groupId>org.pitest</groupId>
<artifactId>pitest-junit5-plugin</artifactId>
<version>1.2.1</version>
</dependency>
</dependencies>
<configuration>
<targetClasses>
<param>com.example.service.*</param>
<param>com.example.domain.*</param>
</targetClasses>
<targetTests>
<param>com.example.*Test</param>
</targetTests>
<mutationThreshold>80</mutationThreshold> <!-- fail if < 80% killed -->
<outputFormats>HTML</outputFormats>
</configuration>
</plugin>
<!-- Run with: mvn test-compile org.pitest:pitest-maven:mutationCoverage -->
// PIT introduces mutations like:
// - Change > to >= (boundary condition)
// - Change + to - (arithmetic)
// - Remove method call (side effect removal)
// - Negate boolean (conditional negation)
// - Remove null check (null guard removal)
// Example: this code has 100% line coverage but survives mutation
public boolean canShip(Order order) {
return order.getStatus() == OrderStatus.READY; // Mutant: != instead of ==
}
@Test
void canShip_returnsTrue_forReadyOrder() {
Order order = Order.builder().status(OrderStatus.READY).build();
assertThat(orderService.canShip(order)).isTrue();
// This test passes with BOTH == and != mutations!
}
// Mutation-killing tests cover BOTH the positive AND negative cases:
@Test
void canShip_returnsTrue_forReadyOrder() {
assertThat(orderService.canShip(
Order.builder().status(OrderStatus.READY).build())).isTrue();
}
@Test
void canShip_returnsFalse_forPendingOrder() {
assertThat(orderService.canShip(
Order.builder().status(OrderStatus.PENDING).build())).isFalse();
// NOW the != mutation is caught — this test would FAIL with !=
}
Flaky Tests: Finding & Fixing Them
A flaky test is one that sometimes passes and sometimes fails without any code change. Flaky tests are worse than no tests — they erode trust so that engineers start ignoring failures, including real ones.
Fix: Use Awaitility with polling + timeout
await().atMost(10, SECONDS).until(...)Fix: @Transactional rollback, @AfterEach cleanup
Never share mutable state between tests
Fix: Each test must set up its own data
Use @TestMethodOrder(Random.class) to detect
Fix: Use RANDOM_PORT in @SpringBootTest
Testcontainers auto-assigns random ports
Fix: Inject Clock, use fixed Clock in tests
Fix: Increase pool size for tests, or limit parallelism
// Bad: Fragile sleep-based async test
@Test
void processOrderAsync_badTest() throws Exception {
orderService.processAsync(orderId);
Thread.sleep(500); // Fails on slow CI, wastes 500ms on fast CI
assertThat(orderRepository.findById(orderId).get().getStatus())
.isEqualTo(OrderStatus.PROCESSED);
}
// Good: Awaitility — polls with backoff until condition met or timeout
@Test
void processOrderAsync_goodTest() {
orderService.processAsync(orderId);
await()
.pollInterval(Duration.ofMillis(100))
.atMost(Duration.ofSeconds(10))
.untilAsserted(() -> {
Order order = orderRepository.findById(orderId).orElseThrow();
assertThat(order.getStatus()).isEqualTo(OrderStatus.PROCESSED);
});
}
// Detecting test order issues: randomize test execution order
@TestMethodOrder(MethodOrderer.Random.class)
class OrderServiceTest {
// If any test fails when order is randomized,
// you have a test order dependency — fix it!
}
// Detecting flaky tests: @RepeatedTest to stress-test a suspicious test
@RepeatedTest(50)
void suspiciousTest() {
// If this fails even once in 50 runs, it's flaky
assertThat(someAsyncOperation()).isNotNull();
}
Testing Anti-Patterns to Avoid
Verifying that repository.findById() was called once tightly couples your test to the implementation. If you refactor to use a cache first, the test breaks — even though behaviour is identical. Test outputs and side effects, not internal method calls.
"One assertion per test" is advice to keep tests focused, not a rule to have exactly one assertThat(). Splitting a logical scenario into 10 micro-tests creates 10x the setup code and makes it impossible to see what a test is verifying. Group related assertions for the same scenario.
90% of production bugs live in error handling code. Every method that can throw an exception needs a test for the thrown exception. Every null return needs a test. Every boundary value needs a test. Most tutorial tests only cover the happy path — don't make the same mistake.
Loading the full Spring context for a unit test adds 5–15 seconds of overhead. A simple service test with @SpringBootTest takes 12 seconds; with @ExtendWith(MockitoExtension.class) it takes 20ms. Over 500 tests, that's 90 minutes vs 10 seconds of total test time.
If you find yourself mocking the very class you're testing, stop. You've inverted the test. @Spy on the class under test is occasionally valid for partial mocking, but usually signals that the class has too many responsibilities and should be split.
Once you introduce @Disabled or ./mvnw test -Dskip.tests to unblock a release, it becomes a habit. Disabled tests are technical debt with interest — they accumulate, nobody fixes them, and eventually the test suite is useless. Fix the test or delete it, never disable it.
Interview Preparation
Testing is a senior-level differentiator. These questions are asked in system design and engineering culture rounds to determine whether you write production-grade code or tutorial-grade code.
await().atMost(10, SECONDS).untilAsserted(() -> assertThat(...)). (3) @EmbeddedKafka or embedded broker — for messaging tests, use Spring's embedded broker and synchronously wait for consumer acknowledgement. Never use Thread.sleep() for async tests — it's both fragile (fails on slow CI) and slow (wastes time on fast hardware). Also: design your async code to be testable — inject an Executor that can be swapped for a SynchronousTaskExecutor in tests, making async code run synchronously in the test thread.