▸ tutorial ~3h read · run as you go

Tests that don't lie.
A complete TDD stack for
Java 21 + Spring Boot 3.5.13.

From a single private boolean injected via reflection to a full integration test that orchestrates Postgres, Redis, S3, SQS, a Temporal workflow, and a Keycloak‑issued JWT — written the way real production teams write them.

JDK
21 LTS
Spring Boot
3.5.13
Sections
20
Containers
7
// LEARNING PATH
setup foundations DTOs · REST Postgres · Redis async · virtual threads S3 · SQS · Temporal OIDC end-to-end
— PART I —

Setup & configuration

Containers, dependencies, and the YAML that wires it all together. Run these once and the rest of the tutorial is reproducible.

01 / Setup

The stack at a glance

Seven services. One docker compose up. Every test you'll write talks to one or more of them via Testcontainers or LocalStack — never via mocks where a real protocol exists.

PostgreSQL 16
5432

Primary store for orders & users. JPA + Flyway migrations. Tested with @DataJpaTest + Testcontainers.

Redis 7
6379

Cache, rate-limit, idempotency keys. @Cacheable annotations + Lettuce client.

LocalStack
4566

S3 + SQS + STS in one container. Pinned to v3.8 so AWS SDK v2 calls hit a real protocol.

Keycloak 26
8081

OIDC realm with three roles (USER, MANAGER, ADMIN). Imports realm on start.

Temporal 1.25
7233

Workflow engine, auto-setup. UI at :8088. Workflows tested with TestWorkflowExtension.

Spring Boot
8080

The app. Java 21 with virtual threads enabled. spring-boot-starter-test gives JUnit5 + Mockito 5 + AssertJ.

Architecture flow

  [ Browser / Postman ]
            │  bearer token (JWT)
            ▼
  [ Spring REST controller ] ───▶ [ Keycloak ]  validate JWT
            │
            ├──▶ [ Service ]  ──▶ [ Redis ]          cache lookups
            │       │
            │       ├──▶ [ Postgres ]      JPA repository
            │       ├──▶ [ S3 ]            store invoice
            │       ├──▶ [ SQS ]           publish event
            │       └──▶ [ Temporal ]      start order workflow
            │
            ▼
  return DTO (no entities!)
    
02 / Setup

Docker compose: one command, full stack

Run this once. Every snippet in the rest of the tutorial assumes these services are reachable on localhost. For CI we'll switch to Testcontainers — same images, ephemeral lifetimes.

▸ start it up

docker compose up -d
docker compose ps
docker compose logs -f keycloak     # watch realm import
open http://localhost:8088          # Temporal UI
open http://localhost:8081          # Keycloak admin (admin / admin)

docker-compose.yml

All services run in a private network tdd-net. Volumes persist Postgres & Redis between restarts so you don't re-seed on every reboot.

version: "3.9"
name: tdd-stack

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: apppass
      POSTGRES_DB: appdb
      POSTGRES_MULTIPLE_DATABASES: appdb,temporal,keycloak
    ports: ["5432:5432"]
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./scripts/init-multi-db.sh:/docker-entrypoint-initdb.d/10-init-multi-db.sh:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 5s

  redis:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes", "--requirepass", "redispass"]
    ports: ["6379:6379"]

  localstack:
    image: localstack/localstack:3.8
    environment:
      SERVICES: s3,sqs,sts,iam
      AWS_DEFAULT_REGION: us-east-1
      AWS_ACCESS_KEY_ID: test
      AWS_SECRET_ACCESS_KEY: test
    ports: ["4566:4566"]
    volumes:
      - ./scripts/init-localstack.sh:/etc/localstack/init/ready.d/init.sh:ro

  keycloak:
    image: quay.io/keycloak/keycloak:26.0
    command: ["start-dev", "--import-realm"]
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: admin
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: appuser
      KC_DB_PASSWORD: apppass
    ports: ["8081:8080"]
    volumes:
      - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
    depends_on:
      postgres: { condition: service_healthy }

  temporal:
    image: temporalio/auto-setup:1.25
    environment:
      DB: postgres12
      POSTGRES_USER: appuser
      POSTGRES_PWD: apppass
      POSTGRES_SEEDS: postgres
    ports: ["7233:7233"]
    depends_on:
      postgres: { condition: service_healthy }

  temporal-ui:
    image: temporalio/ui:2.32.0
    environment:
      TEMPORAL_ADDRESS: temporal:7233
    ports: ["8088:8080"]
    depends_on: [temporal]

volumes:
  postgres-data:

▸ pinning matters

Don't use :latest for any of these. LocalStack 4 changed several APIs, Keycloak 26 changed bootstrap env vars, Temporal 1.25+ deprecated tctl in favor of temporal. Pin or pay later.

init-multi-db.sh — one Postgres, three databases

#!/bin/bash
set -e
for db in $(echo "$POSTGRES_MULTIPLE_DATABASES" | tr ',' ' '); do
  [ "$db" = "$POSTGRES_DB" ] && continue
  psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -c "CREATE DATABASE $db;"
done

init-localstack.sh — buckets & queues on boot

#!/bin/bash
awslocal s3 mb s3://app-uploads
awslocal s3 mb s3://app-archive
awslocal sqs create-queue --queue-name order-events
awslocal sqs create-queue --queue-name order-events-dlq
awslocal sqs create-queue --queue-name notifications
03 / Setup

pom.xml — every dependency you'll touch

Spring Boot's BOM manages most versions. We only pin the ones outside it: AWS SDK v2, Temporal SDK, Testcontainers, MapStruct, and ArchUnit.

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>3.5.13</version>
</parent>

<properties>
  <java.version>21</java.version>
  <aws.sdk.version>2.29.0</aws.sdk.version>
  <temporal.version>1.27.0</temporal.version>
  <testcontainers.version>1.20.4</testcontainers.version>
</properties>

<dependencies>
  <!-- web stack -->
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>

  <!-- persistence -->
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
  <dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope></dependency>
  <dependency><groupId>org.flywaydb</groupId><artifactId>flyway-database-postgresql</artifactId></dependency>
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-cache</artifactId></dependency>

  <!-- security / OIDC -->
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-oauth2-resource-server</artifactId></dependency>

  <!-- AWS SDK v2 + Spring Cloud AWS for SQS listeners -->
  <dependency><groupId>software.amazon.awssdk</groupId><artifactId>s3</artifactId><version>${aws.sdk.version}</version></dependency>
  <dependency><groupId>software.amazon.awssdk</groupId><artifactId>sqs</artifactId><version>${aws.sdk.version}</version></dependency>
  <dependency><groupId>io.awspring.cloud</groupId><artifactId>spring-cloud-aws-starter-sqs</artifactId><version>3.2.1</version></dependency>

  <!-- Temporal -->
  <dependency><groupId>io.temporal</groupId><artifactId>temporal-spring-boot-starter</artifactId><version>${temporal.version}</version></dependency>

  <!-- mapping / boilerplate -->
  <dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
  <dependency><groupId>org.mapstruct</groupId><artifactId>mapstruct</artifactId><version>1.6.3</version></dependency>

  <!-- ====== TEST ====== -->
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
  <dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-testcontainers</artifactId><scope>test</scope></dependency>
  <dependency><groupId>org.testcontainers</groupId><artifactId>junit-jupiter</artifactId><scope>test</scope></dependency>
  <dependency><groupId>org.testcontainers</groupId><artifactId>postgresql</artifactId><scope>test</scope></dependency>
  <dependency><groupId>org.testcontainers</groupId><artifactId>localstack</artifactId><scope>test</scope></dependency>
  <dependency><groupId>com.redis</groupId><artifactId>testcontainers-redis</artifactId><version>2.2.4</version><scope>test</scope></dependency>
  <dependency><groupId>io.temporal</groupId><artifactId>temporal-testing</artifactId><version>${temporal.version}</version><scope>test</scope></dependency>
  <dependency><groupId>org.wiremock</groupId><artifactId>wiremock-standalone</artifactId><version>3.10.0</version><scope>test</scope></dependency>
  <dependency><groupId>org.awaitility</groupId><artifactId>awaitility</artifactId><scope>test</scope></dependency>
  <dependency><groupId>com.tngtech.archunit</groupId><artifactId>archunit-junit5</artifactId><version>1.3.0</version><scope>test</scope></dependency>
</dependencies>
▸ comes from spring-boot-starter-test

JUnit 5, Mockito 5, AssertJ, Hamcrest, JsonPath, Spring Test, MockMvc.

▸ separate adds

Testcontainers (one module per service), spring-security-test, Awaitility, WireMock.

▸ optional but recommended

ArchUnit for architecture rules, MapStruct annotation processor for DTO mappers.

04 / Setup

application.yml — wired for the whole stack

One YAML for local dev. Profiles override per-environment. Notice the Java 21 thread-per-request virtual-thread setting at the top — that's the line that turns Tomcat into a virtual-thread server.

spring:
  application:
    name: tdd-stack
  threads:
    virtual:
      enabled: true                                # Java 21 virtual threads on Tomcat

  datasource:
    url: jdbc:postgresql://localhost:5432/appdb
    username: appuser
    password: apppass
    hikari:
      maximum-pool-size: 20
  jpa:
    hibernate.ddl-auto: validate
    properties.hibernate.jdbc.time_zone: UTC
  flyway:
    enabled: true
    locations: classpath:db/migration

  data:
    redis:
      host: localhost
      port: 6379
      password: redispass
      timeout: 2s

  cache:
    type: redis
    redis:
      time-to-live: 10m

  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8081/realms/tdd-app
          jwk-set-uri: http://localhost:8081/realms/tdd-app/protocol/openid-connect/certs

# AWS SDK v2 — point to LocalStack
aws:
  region: us-east-1
  endpoint: http://localhost:4566
  access-key: test
  secret-key: test
  s3:
    bucket-uploads: app-uploads
    bucket-archive: app-archive
  sqs:
    order-events-queue: order-events
    notifications-queue: notifications

# Spring Cloud AWS SQS
spring.cloud.aws:
  region.static: us-east-1
  endpoint: http://localhost:4566
  credentials:
    access-key: test
    secret-key: test

# Temporal
temporal:
  serviceAddress: localhost:7233
  namespace: default
  workers:
    - name: order-worker
      taskQueue: ORDER_TASK_QUEUE
      capacity:
        maxConcurrentWorkflowTaskExecutors: 50
        maxConcurrentActivityExecutors: 50

# Feature flags injected via @Value (perfect ReflectionTestUtils targets)
feature:
  enableArchive: true
  enableNotifications: true
  maxRetries: 3

# Actuator
management:
  endpoints.web.exposure.include: health,info,metrics,prometheus
  endpoint.health.show-details: when-authorized

logging:
  level:
    org.springframework.web: INFO
    com.example: DEBUG
    org.hibernate.SQL: DEBUG

▸ why split test config?

Tests get an application-test.yml with empty values for the OIDC issuer-uri and AWS endpoint. Testcontainers' @DynamicPropertySource injects real container ports at runtime — no port hard-coding, no flaky tests when 5432 is busy.

— PART II —

Foundations

JUnit 5, Mockito, ReflectionTestUtils. The vocabulary every test in the rest of this tutorial speaks.

05 / Foundations

JUnit 5 + AssertJ — your test grammar

Nothing else in this tutorial works without these two. JUnit 5 organizes tests; AssertJ writes the assertions you'll wish you knew years ago.

A complete JUnit 5 test class

package com.example.foundations;

import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import java.math.BigDecimal;
import java.util.List;

import static org.assertj.core.api.Assertions.*;

@DisplayName("Money — value object")
class MoneyTest {

    private Money usd10;

    @BeforeEach
    void setUp() {
        usd10 = Money.of("10.00", "USD");
    }

    @Test
    @DisplayName("addition keeps the same currency")
    void add_sameCurrency_works() {
        Money result = usd10.add(Money.of("5.50", "USD"));

        assertThat(result.amount()).isEqualByComparingTo("15.50");
        assertThat(result.currency()).isEqualTo("USD");
    }

    @Test
    void add_differentCurrency_throws() {
        assertThatThrownBy(() -> usd10.add(Money.of("5", "EUR")))
            .isInstanceOf(CurrencyMismatchException.class)
            .hasMessageContaining("USD")
            .hasMessageContaining("EUR");
    }

    @ParameterizedTest(name = "negative amount [{0}] is rejected")
    @ValueSource(strings = {"-0.01", "-1", "-1000.00"})
    void rejectNegative(String amount) {
        assertThatIllegalArgumentException()
            .isThrownBy(() -> Money.of(amount, "USD"))
            .withMessage("amount must be non-negative");
    }

    @Nested
    @DisplayName("when summing a list")
    class Summing {
        @Test
        void emptyListReturnsZero() {
            assertThat(Money.sum(List.of(), "USD"))
                .isEqualTo(Money.of("0.00", "USD"));
        }

        @Test
        void mixedAmountsAreCombined() {
            List coins = List.of(
                Money.of("1.10", "USD"),
                Money.of("2.20", "USD"),
                Money.of("0.05", "USD"));

            assertThat(Money.sum(coins, "USD"))
                .isEqualTo(Money.of("3.35", "USD"));
        }
    }
}

AssertJ patterns you'll use constantly

// ▸ Collections — chain everything
assertThat(orders)
    .hasSize(3)
    .extracting(Order::status)
    .containsExactly(NEW, PAID, SHIPPED);

assertThat(orders)
    .filteredOn(o -> o.total().compareTo(BigDecimal.valueOf(100)) > 0)
    .hasSize(1);

// ▸ Object graphs — recursive comparison ignores transient fields
assertThat(actualOrder)
    .usingRecursiveComparison()
    .ignoringFields("createdAt", "updatedAt", "version")
    .isEqualTo(expectedOrder);

// ▸ Optional
assertThat(repo.findById(id))
    .isPresent()
    .get()
    .extracting(User::email)
    .isEqualTo("alice@example.com");

// ▸ Exceptions — fluent
assertThatThrownBy(service::process)
    .isInstanceOf(ValidationException.class)
    .hasMessageContaining("invalid")
    .hasNoCause();

// ▸ JSON — with JsonPath
assertThat(responseJson)
    .extractingJsonPathStringValue("$.user.email").isEqualTo("a@b.com")
    .extractingJsonPathArrayValue("$.user.roles").contains("ADMIN");

// ▸ SoftAssertions — collect every failure, don't bail on the first
assertSoftly(softly -> {
    softly.assertThat(order.id()).isNotNull();
    softly.assertThat(order.total()).isPositive();
    softly.assertThat(order.items()).isNotEmpty();
});

▸ stop using assertEquals

assertEquals(expected, actual) reads backwards and gives ugly failure messages. AssertJ reads like English (assertThat(actual).isEqualTo(expected)) and builds a chainable fluent API. Mix it with JUnit 5 — never with the old JUnit 4 style.

06 / Foundations

Mockito 5 — every pattern that matters

Five years of "what does Mockito actually do here" — distilled. Six patterns cover 95% of real codebases.

The five canonical patterns

▸ pattern 1 when().thenReturn() — basic stubbing
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock UserRepository userRepo;
    @Mock InventoryClient inventory;
    @InjectMocks OrderService orderService;

    @Test
    void createOrder_validUser_succeeds() {
        // arrange
        when(userRepo.findById(42L))
            .thenReturn(Optional.of(new User(42L, "alice@example.com")));
        when(inventory.reserve(any(), anyInt())).thenReturn(true);

        // act
        Order order = orderService.create(42L, "SKU-99", 2);

        // assert
        assertThat(order.userId()).isEqualTo(42L);
        verify(inventory, times(1)).reserve("SKU-99", 2);
    }
}
▸ pattern 2 doReturn() / doThrow() — for spies and void methods
// Spy = real object with selective stubbing
PaymentService spy = Mockito.spy(new PaymentService(realGateway));

// when(spy.charge(...)) would CALL the real method during stubbing.
// doReturn skips that. ALWAYS prefer doReturn on spies.
doReturn(true).when(spy).charge(any(), eq(BigDecimal.TEN));

// Void methods can't use when().thenThrow() — use doThrow
doThrow(new ConnectException("offline"))
    .when(notifier).sendEmail(anyString(), anyString());

// doNothing for partial mocks of void methods
doNothing().when(auditLog).record(any());
▸ pattern 3 ArgumentCaptor — what was the call shaped like?
@Captor ArgumentCaptor emailCaptor;

@Test
void onOrderShipped_sendsConfirmationEmail() {
    orderService.markShipped(orderId);

    verify(emailService).send(emailCaptor.capture());

    EmailMessage sent = emailCaptor.getValue();
    assertThat(sent.to()).isEqualTo("alice@example.com");
    assertThat(sent.subject()).contains("shipped");
    assertThat(sent.body()).contains("tracking number");
}

// Multiple captures
verify(emailService, times(3)).send(emailCaptor.capture());
List all = emailCaptor.getAllValues();
assertThat(all).extracting(EmailMessage::to)
    .containsExactly("a@b.com", "c@d.com", "e@f.com");
▸ pattern 4 Answer<T> — dynamic responses
// Echo back whatever was passed in (useful for save() returning the entity)
when(orderRepo.save(any(Order.class)))
    .thenAnswer(inv -> inv.getArgument(0));

// Counter-based responses
AtomicInteger calls = new AtomicInteger();
when(client.fetch()).thenAnswer(inv ->
    calls.incrementAndGet() < 3
        ? Mono.error(new TimeoutException())
        : Mono.just("ok"));

// Side effects (set IDs on saved entities)
when(userRepo.save(any())).thenAnswer(inv -> {
    User u = inv.getArgument(0);
    return new User(99L, u.email());
});
▸ pattern 5 MockedStatic — for static methods (Mockito 3.4+)
@Test
void usesUuid() {
    UUID fixed = UUID.fromString("00000000-0000-0000-0000-000000000001");

    try (MockedStatic uuids = mockStatic(UUID.class)) {
        uuids.when(UUID::randomUUID).thenReturn(fixed);

        Token t = tokenFactory.create();

        assertThat(t.value()).isEqualTo(fixed.toString());
        uuids.verify(UUID::randomUUID, times(1));
    }
}

▸ pitfall — strictness

Mockito 5 defaults to STRICT_STUBS. Stubs that are never called fail the test. This is good — it catches dead stubs — but it bites if you write defensive stubs in @BeforeEach. Use lenient().when(...) only when you mean it.

07 / Foundations

ReflectionTestUtils — the surgical tool

Sometimes you need to set a private @Value field, invoke a private method, or read internal state to assert on it. ReflectionTestUtils from Spring Test is the right tool — and the only acceptable use of reflection inside tests.

The four methods you'll use

import org.springframework.test.util.ReflectionTestUtils;

// 1. setField — inject into a private field
ReflectionTestUtils.setField(service, "enableFeature", true);
ReflectionTestUtils.setField(service, "maxRetries", 5);

// 2. getField — read a private field for assertion
boolean flag = (Boolean) ReflectionTestUtils.getField(service, "enableFeature");
assertThat(flag).isTrue();

// 3. invokeMethod — call a private method (varargs)
boolean eligible = ReflectionTestUtils
    .invokeMethod(service, "isEligible", "USR-001");

// 4. setField on a superclass field (overload with target class)
ReflectionTestUtils.setField(child, Parent.class, "active", true, boolean.class);

When to reach for it (and when not)

▸ YES, USE IT
  • → Inject @Value properties without bootstrapping Spring
  • → Set internal flags on a service to test branch behavior
  • → Replace a private collaborator field with a mock
  • → Read post-call internal state when no public getter exists
  • → Test legacy code with no constructor injection
▸ NO, REFACTOR INSTEAD
  • → The class already supports constructor injection — use it
  • → You're testing private methods directly — test the public surface
  • → You're invoking 5+ private methods to set up state — your design is leaking
  • → The field is mutable static — you have a bigger problem

Production scenario: feature flags via @Value

A common case. The service depends on configuration; we want to test both branches without spinning up Spring.

// ─── PRODUCTION CODE ───────────────────────────────────────────
@Service
public class FeatureService {

    @Value("${feature.enableArchive}")
    private boolean enableArchive;

    @Value("${feature.maxRetries:3}")
    private int maxRetries;

    private final ArchiveClient archiveClient;
    private final NotificationService notifications;

    public FeatureService(ArchiveClient archiveClient,
                          NotificationService notifications) {
        this.archiveClient = archiveClient;
        this.notifications = notifications;
    }

    public ProcessResult process(Order order) {
        if (enableArchive) {
            archiveClient.store(order);
        }
        for (int attempt = 1; attempt <= maxRetries; attempt++) {
            try {
                notifications.send(order);
                return ProcessResult.success(attempt);
            } catch (TransientFailure e) {
                if (attempt == maxRetries) throw e;
            }
        }
        throw new IllegalStateException("unreachable");
    }
}
// ─── TEST ──────────────────────────────────────────────────────
@ExtendWith(MockitoExtension.class)
class FeatureServiceTest {

    @Mock ArchiveClient archive;
    @Mock NotificationService notifications;
    @InjectMocks FeatureService service;

    @BeforeEach
    void injectFlags() {
        // @Value fields are NOT touched by @InjectMocks — set them manually
        ReflectionTestUtils.setField(service, "enableArchive", true);
        ReflectionTestUtils.setField(service, "maxRetries", 3);
    }

    @Test
    void enableArchive_true_callsArchive() {
        Order order = new Order("ORD-1");

        service.process(order);

        verify(archive).store(order);
    }

    @Test
    void enableArchive_false_skipsArchive() {
        ReflectionTestUtils.setField(service, "enableArchive", false);

        service.process(new Order("ORD-2"));

        verifyNoInteractions(archive);
    }

    @Test
    void retry_succeedsOnSecondAttempt() {
        Order order = new Order("ORD-3");
        doThrow(new TransientFailure()).doNothing()
            .when(notifications).send(order);

        ProcessResult r = service.process(order);

        assertThat(r.attempts()).isEqualTo(2);
        verify(notifications, times(2)).send(order);
    }

    @Test
    void retry_failsAfterMaxRetries() {
        ReflectionTestUtils.setField(service, "maxRetries", 2);
        Order order = new Order("ORD-4");
        doThrow(new TransientFailure()).when(notifications).send(any());

        assertThatThrownBy(() -> service.process(order))
            .isInstanceOf(TransientFailure.class);
        verify(notifications, times(2)).send(order);
    }
}

▸ pitfall — primitives in invokeMethod

invokeMethod() returns Object. For a method returning boolean, the actual type is the boxed Boolean. Cast to the wrapper, not the primitive — the unbox conversion can NPE if the method ever returns null: boolean ok = (Boolean) ReflectionTestUtils.invokeMethod(svc, "isReady");

08 / Foundations

DTOs & validation — records done right

Java records make DTOs almost too easy. Combined with Bean Validation 3 they're immutable, validated, and serialize cleanly. Test the validation, not the getters.

Production DTOs

// ─── REQUEST DTO ───────────────────────────────────────────────
public record CreateOrderRequest(
    @NotBlank(message = "userId is required")
    String userId,

    @NotEmpty(message = "items must contain at least one entry")
    @Valid
    List items,

    @Email
    @Size(max = 255)
    String contactEmail,

    @PastOrPresent
    LocalDate orderDate
) {
    public record OrderLine(
        @NotBlank String sku,
        @Positive @Max(100) int quantity,
        @NotNull @PositiveOrZero BigDecimal unitPrice
    ) {}
}

// ─── RESPONSE DTO ──────────────────────────────────────────────
public record OrderResponse(
    UUID id,
    String userId,
    OrderStatus status,
    List lines,
    BigDecimal total,
    Instant createdAt
) {
    public static OrderResponse from(Order entity) {
        return new OrderResponse(
            entity.getId(),
            entity.getUserId(),
            entity.getStatus(),
            entity.getLines().stream().map(LineResponse::from).toList(),
            entity.total(),
            entity.getCreatedAt()
        );
    }

    public record LineResponse(String sku, int quantity, BigDecimal lineTotal) {
        static LineResponse from(OrderLine l) {
            return new LineResponse(l.getSku(), l.getQuantity(),
                l.getUnitPrice().multiply(BigDecimal.valueOf(l.getQuantity())));
        }
    }
}

Validation tests — every constraint, isolated

class CreateOrderRequestTest {

    private final Validator validator = Validation
        .buildDefaultValidatorFactory().getValidator();

    @Test
    void validRequest_hasNoViolations() {
        CreateOrderRequest req = sample();

        Set> v = validator.validate(req);

        assertThat(v).isEmpty();
    }

    @ParameterizedTest(name = "blank userId [\"{0}\"] is rejected")
    @ValueSource(strings = {"", " ", "\t"})
    void blankUserId_isRejected(String userId) {
        CreateOrderRequest req = sample().withUserId(userId);

        assertThat(validator.validate(req))
            .extracting(ConstraintViolation::getMessage)
            .contains("userId is required");
    }

    @Test
    void emptyItems_isRejected() {
        CreateOrderRequest req = sample().withItems(List.of());

        assertThat(validator.validate(req))
            .extracting(v -> v.getPropertyPath().toString())
            .contains("items");
    }

    @Test
    void zeroQuantity_isRejected() {
        var bad = new CreateOrderRequest.OrderLine("SKU-1", 0, BigDecimal.ONE);
        CreateOrderRequest req = sample().withItems(List.of(bad));

        assertThat(validator.validate(req))
            .extracting(v -> v.getPropertyPath().toString())
            .contains("items[0].quantity");
    }

    @Test
    void negativePrice_isRejected() {
        var bad = new CreateOrderRequest.OrderLine("SKU-1", 1, new BigDecimal("-0.01"));

        assertThat(validator.validate(sample().withItems(List.of(bad))))
            .extracting(v -> v.getPropertyPath().toString())
            .contains("items[0].unitPrice");
    }

    @Test
    void responseFrom_mapsLinesAndTotal() {
        Order entity = OrderFixtures.withLines(
            new OrderLine("SKU-A", 2, new BigDecimal("10.00")),
            new OrderLine("SKU-B", 1, new BigDecimal("5.50"))
        );

        OrderResponse res = OrderResponse.from(entity);

        assertThat(res.total()).isEqualByComparingTo("25.50");
        assertThat(res.lines())
            .extracting(OrderResponse.LineResponse::sku)
            .containsExactly("SKU-A", "SKU-B");
    }

    private static CreateOrderRequest sample() {
        return new CreateOrderRequest(
            "USR-1",
            List.of(new CreateOrderRequest.OrderLine("SKU-1", 2, new BigDecimal("9.99"))),
            "alice@example.com",
            LocalDate.now()
        );
    }
}

▸ records can have with-style helpers

Add withUserId(String userId) as a helper in test fixtures rather than mutating shared state. Records are immutable; copy-with-modification keeps each test isolated.

09 / Web layer

REST controllers — the public contract

Controllers are thin. Take a request DTO, ask a service, return a response DTO. Test them with @WebMvcTest — Spring loads only the web slice, mocks the rest, and runs in milliseconds.

A complete controller

@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
@Validated
public class OrderController {

    private final OrderService orderService;

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    @PreAuthorize("hasRole('USER')")
    public OrderResponse create(@Valid @RequestBody CreateOrderRequest request,
                                @AuthenticationPrincipal Jwt principal) {
        Order created = orderService.create(request, principal.getSubject());
        return OrderResponse.from(created);
    }

    @GetMapping("/{id}")
    @PreAuthorize("hasRole('USER')")
    public OrderResponse getById(@PathVariable UUID id) {
        return orderService.findById(id)
            .map(OrderResponse::from)
            .orElseThrow(() -> new NotFoundException("Order " + id));
    }

    @GetMapping
    @PreAuthorize("hasRole('USER')")
    public Page list(
            @RequestParam(required = false) OrderStatus status,
            @PageableDefault(size = 20, sort = "createdAt") Pageable pageable) {
        return orderService.find(status, pageable).map(OrderResponse::from);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    @PreAuthorize("hasRole('MANAGER') or hasRole('ADMIN')")
    public void cancel(@PathVariable UUID id) {
        orderService.cancel(id);
    }

    @ExceptionHandler(NotFoundException.class)
    public ResponseEntity notFound(NotFoundException e) {
        return ResponseEntity.status(404)
            .body(new ApiError("NOT_FOUND", e.getMessage()));
    }
}
10 / Web layer

@WebMvcTest — slice tests for the controller

@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderControllerTest {

    @Autowired MockMvc mvc;
    @MockitoBean OrderService orderService;
    @Autowired ObjectMapper objectMapper;

    @Test
    @WithMockUser(roles = "USER")
    void create_validBody_returns201() throws Exception {
        UUID id = UUID.randomUUID();
        when(orderService.create(any(), any()))
            .thenReturn(OrderFixtures.persistedWithId(id));

        var body = """
            {
              "userId": "USR-1",
              "items": [{"sku":"SKU-1","quantity":2,"unitPrice":9.99}],
              "contactEmail": "a@b.com",
              "orderDate": "2026-04-28"
            }
            """;

        mvc.perform(post("/api/v1/orders")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(body))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").value(id.toString()))
            .andExpect(jsonPath("$.lines[0].sku").value("SKU-1"))
            .andExpect(jsonPath("$.total").value(19.98));
    }

    @Test
    @WithMockUser(roles = "USER")
    void create_invalidBody_returns400() throws Exception {
        var body = """
            { "userId": "", "items": [] }
            """;

        mvc.perform(post("/api/v1/orders")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(body))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors[?(@.field=='userId')]").exists())
            .andExpect(jsonPath("$.errors[?(@.field=='items')]").exists());

        verifyNoInteractions(orderService);
    }

    @Test
    @WithMockUser(roles = "USER")
    void getById_notFound_returns404() throws Exception {
        UUID id = UUID.randomUUID();
        when(orderService.findById(id)).thenReturn(Optional.empty());

        mvc.perform(get("/api/v1/orders/{id}", id))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.code").value("NOT_FOUND"));
    }

    @Test
    void getById_anonymous_returns401() throws Exception {
        mvc.perform(get("/api/v1/orders/{id}", UUID.randomUUID()))
            .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(roles = "USER")
    void cancel_userRole_returns403() throws Exception {
        mvc.perform(delete("/api/v1/orders/{id}", UUID.randomUUID()).with(csrf()))
            .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(roles = "MANAGER")
    void cancel_managerRole_returns204() throws Exception {
        UUID id = UUID.randomUUID();

        mvc.perform(delete("/api/v1/orders/{id}", id).with(csrf()))
            .andExpect(status().isNoContent());

        verify(orderService).cancel(id);
    }
}

@MockitoBean is the new @MockBean

Spring Boot 3.4 introduced @MockitoBean in org.springframework.test.context.bean.override.mockito. The old @MockBean still works but is deprecated. Same idea — it replaces a bean in the application context with a Mockito mock.

11 / Persistence

PostgreSQL + JPA — real DB tests with Testcontainers

No H2. No "but in-memory works for tests." Real Postgres in a real container — same dialect, same constraints, same JSON support. Testcontainers spins it up; @DynamicPropertySource wires Spring to it.

Entity + repository

@Entity
@Table(name = "orders")
@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(nullable = false)
    private String userId;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private OrderStatus status;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List lines = new ArrayList<>();

    @Column(nullable = false, columnDefinition = "jsonb")
    @JdbcTypeCode(SqlTypes.JSON)
    private Map metadata = new HashMap<>();

    @CreationTimestamp Instant createdAt;
    @UpdateTimestamp   Instant updatedAt;
    @Version           long    version;

    public static Order newOrder(String userId, List lines) {
        Order o = new Order();
        o.userId = userId;
        o.status = OrderStatus.NEW;
        lines.forEach(l -> { l.setOrder(o); o.lines.add(l); });
        return o;
    }

    public BigDecimal total() {
        return lines.stream()
            .map(l -> l.getUnitPrice().multiply(BigDecimal.valueOf(l.getQuantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

public interface OrderRepository extends JpaRepository {

    List findByUserIdAndStatus(String userId, OrderStatus status);

    @Query("""
        select o from Order o
        where o.status = :status
        and o.createdAt between :from and :to
        """)
    Page findByStatusInRange(@Param("status") OrderStatus status,
                                    @Param("from") Instant from,
                                    @Param("to") Instant to,
                                    Pageable pageable);

    @Modifying
    @Query("update Order o set o.status = :status where o.id = :id")
    int updateStatus(@Param("id") UUID id, @Param("status") OrderStatus status);
}

@DataJpaTest with Testcontainers

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired OrderRepository repo;
    @Autowired TestEntityManager em;

    @Test
    void save_andRoundtrip() {
        Order saved = repo.save(OrderFixtures.simple("USR-1"));

        em.flush();
        em.clear();

        Order loaded = repo.findById(saved.getId()).orElseThrow();
        assertThat(loaded.getStatus()).isEqualTo(OrderStatus.NEW);
        assertThat(loaded.getLines()).hasSize(2);
        assertThat(loaded.total()).isEqualByComparingTo("25.50");
    }

    @Test
    void findByUserIdAndStatus_filtersCorrectly() {
        repo.save(OrderFixtures.simple("USR-1"));
        repo.save(OrderFixtures.simple("USR-2"));
        repo.save(OrderFixtures.paidFor("USR-1"));
        em.flush();

        List result = repo.findByUserIdAndStatus("USR-1", OrderStatus.NEW);

        assertThat(result).hasSize(1);
        assertThat(result.get(0).getUserId()).isEqualTo("USR-1");
    }

    @Test
    void uniqueConstraint_onIdempotencyKey_throws() {
        Order a = OrderFixtures.simple("USR-1").withIdempotencyKey("KEY-1");
        Order b = OrderFixtures.simple("USR-2").withIdempotencyKey("KEY-1");

        repo.save(a);
        assertThatThrownBy(() -> { repo.save(b); em.flush(); })
            .isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void optimisticLock_concurrentUpdates_throwsOnSecond() {
        Order saved = repo.save(OrderFixtures.simple("USR-1"));
        em.flush(); em.clear();

        Order loadedA = repo.findById(saved.getId()).orElseThrow();
        Order loadedB = repo.findById(saved.getId()).orElseThrow();

        repo.updateStatus(loadedA.getId(), OrderStatus.PAID);
        em.flush();

        assertThatThrownBy(() -> {
            ReflectionTestUtils.setField(loadedB, "status", OrderStatus.SHIPPED);
            repo.save(loadedB);
            em.flush();
        }).isInstanceOf(ObjectOptimisticLockingFailureException.class);
    }
}

@ServiceConnection is magic

Spring Boot 3.1+ added @ServiceConnection. Slap it on the static container and Spring auto-derives spring.datasource.url, username, and password from the running container. No @DynamicPropertySource needed for the standard cases. Works with PostgreSQL, MySQL, Redis, Kafka, Elasticsearch, MongoDB, and more.

Flyway migration test

@SpringBootTest
@Testcontainers
class FlywayMigrationTest {

    @Container @ServiceConnection
    static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired Flyway flyway;
    @Autowired DataSource dataSource;

    @Test
    void allMigrationsApply() {
        MigrationInfo[] applied = flyway.info().applied();
        assertThat(applied).isNotEmpty();
        assertThat(applied[applied.length-1].getState()).isEqualTo(MigrationState.SUCCESS);
    }

    @Test
    void schema_hasExpectedTables() throws Exception {
        try (Connection c = dataSource.getConnection();
             Statement s = c.createStatement();
             ResultSet rs = s.executeQuery(
                "select table_name from information_schema.tables where table_schema='public'")) {
            List tables = new ArrayList<>();
            while (rs.next()) tables.add(rs.getString(1));
            assertThat(tables).contains("orders", "order_lines", "users");
        }
    }
}
12 / Persistence

Redis cache — @Cacheable with real eviction

Mocking RedisTemplate is a trap — you'll mock the API, miss serialization bugs, and cache invalidation will still bite you in prod. Real Redis in a container, every time.

Cache configuration

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory cf) {
        RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .disableCachingNullValues()
            .serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(SerializationPair.fromSerializer(
                new GenericJackson2JsonRedisSerializer()));

        return RedisCacheManager.builder(cf)
            .cacheDefaults(defaults)
            .withCacheConfiguration("orders", defaults.entryTtl(Duration.ofMinutes(5)))
            .withCacheConfiguration("users",  defaults.entryTtl(Duration.ofHours(1)))
            .build();
    }
}

@Service
@RequiredArgsConstructor
public class OrderQueryService {

    private final OrderRepository repo;

    @Cacheable(value = "orders", key = "#id", unless = "#result == null")
    public Optional findById(UUID id) {
        return repo.findById(id);
    }

    @CacheEvict(value = "orders", key = "#id")
    public void evict(UUID id) {}

    @CachePut(value = "orders", key = "#order.id")
    public Order updateAndCache(Order order) {
        return repo.save(order);
    }
}

Cache test against real Redis

@SpringBootTest
@Testcontainers
@AutoConfigureCache
class OrderQueryServiceCacheTest {

    @Container @ServiceConnection
    static GenericContainer redis = new GenericContainer<>("redis:7-alpine")
        .withExposedPorts(6379)
        .withCommand("redis-server", "--requirepass", "redispass");

    @Container @ServiceConnection
    static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @DynamicPropertySource
    static void redisAuth(DynamicPropertyRegistry r) {
        r.add("spring.data.redis.password", () -> "redispass");
    }

    @Autowired OrderQueryService service;
    @Autowired CacheManager cacheManager;
    @SpyBean OrderRepository repo;

    @BeforeEach
    void clearCache() {
        Objects.requireNonNull(cacheManager.getCache("orders")).clear();
    }

    @Test
    void firstCall_hitsDb_secondCall_hitsCache() {
        UUID id = persistOrder().getId();

        Optional a = service.findById(id);
        Optional b = service.findById(id);
        Optional c = service.findById(id);

        assertThat(a).isPresent();
        assertThat(b).isPresent();
        assertThat(c).isPresent();
        verify(repo, times(1)).findById(id);   // only ONE DB call
    }

    @Test
    void evict_invalidatesCache() {
        UUID id = persistOrder().getId();

        service.findById(id);
        service.evict(id);
        service.findById(id);

        verify(repo, times(2)).findById(id);
    }

    @Test
    void cache_serializesAsJson() {
        UUID id = persistOrder().getId();
        service.findById(id);

        Cache cache = cacheManager.getCache("orders");
        Cache.ValueWrapper raw = cache.get(id);

        assertThat(raw).isNotNull();
        assertThat(raw.get()).isInstanceOf(Optional.class);
    }

    @Test
    void cache_respectsConfiguredTtl() throws InterruptedException {
        UUID id = persistOrder().getId();

        service.findById(id);
        Thread.sleep(100);
        service.findById(id);

        verify(repo, times(1)).findById(id);
    }
}
— PART III —

Async & concurrency

CompletableFuture composition. Java 21 virtual threads. Awaitility for assertions that have to wait.

13 / Async

CompletableFuture — composition under test

The hard part of testing async code isn't running it — it's being deterministic. Use named executors you control, await with timeouts, and never call .get() without one.

A realistic async service

@Service
@RequiredArgsConstructor
public class OrderEnrichmentService {

    private final UserClient userClient;
    private final InventoryClient inventoryClient;
    private final PricingClient pricingClient;
    private final Executor enrichmentExecutor;   // injected, swappable

    public CompletableFuture enrich(Order order) {
        var userF = CompletableFuture.supplyAsync(
            () -> userClient.fetch(order.getUserId()), enrichmentExecutor);

        var skus = order.getLines().stream().map(OrderLine::getSku).toList();

        var stockF = CompletableFuture.supplyAsync(
            () -> inventoryClient.checkAll(skus), enrichmentExecutor);

        var priceF = CompletableFuture.supplyAsync(
            () -> pricingClient.quote(skus), enrichmentExecutor);

        return userF.thenCombine(stockF, Pair::of)
            .thenCombine(priceF, (pair, prices) ->
                new EnrichedOrder(order, pair.first(), pair.second(), prices))
            .orTimeout(5, TimeUnit.SECONDS)
            .exceptionally(ex -> EnrichedOrder.failed(order, ex));
    }
}

Test 1 — happy path with synchronous executor

Use a same-thread executor in tests for determinism. The async structure is preserved; the timing is controlled.

@ExtendWith(MockitoExtension.class)
class OrderEnrichmentServiceTest {

    @Mock UserClient userClient;
    @Mock InventoryClient inventoryClient;
    @Mock PricingClient pricingClient;

    OrderEnrichmentService service;

    @BeforeEach
    void setUp() {
        // Same-thread executor → deterministic, no sleeps, no flakes
        service = new OrderEnrichmentService(
            userClient, inventoryClient, pricingClient, Runnable::run);
    }

    @Test
    void enrich_combinesAllSources() throws Exception {
        Order order = OrderFixtures.simple("USR-1");
        when(userClient.fetch("USR-1")).thenReturn(new User("USR-1", "alice@x"));
        when(inventoryClient.checkAll(any())).thenReturn(Map.of("SKU-1", 10, "SKU-2", 5));
        when(pricingClient.quote(any())).thenReturn(Map.of("SKU-1", new BigDecimal("9.99")));

        CompletableFuture result = service.enrich(order);

        EnrichedOrder enriched = result.get(2, TimeUnit.SECONDS);
        assertThat(enriched.user().email()).isEqualTo("alice@x");
        assertThat(enriched.stock()).containsEntry("SKU-1", 10);
    }

    @Test
    void enrich_oneSourceFails_returnsFailedOrder() throws Exception {
        Order order = OrderFixtures.simple("USR-1");
        when(userClient.fetch(any())).thenThrow(new ServiceUnavailableException("user svc down"));
        when(inventoryClient.checkAll(any())).thenReturn(Map.of());
        when(pricingClient.quote(any())).thenReturn(Map.of());

        EnrichedOrder result = service.enrich(order).get(2, TimeUnit.SECONDS);

        assertThat(result.isFailed()).isTrue();
        assertThat(result.errorCause()).isInstanceOf(ServiceUnavailableException.class);
    }
}

Test 2 — timeout assertion with a real executor

@Test
void enrich_oneSlowSource_triggersTimeout() {
    var realExecutor = Executors.newFixedThreadPool(4);
    var svc = new OrderEnrichmentService(
        userClient, inventoryClient, pricingClient, realExecutor);

    when(userClient.fetch(any())).thenAnswer(inv -> {
        Thread.sleep(10_000);
        return new User("u","e");
    });
    when(inventoryClient.checkAll(any())).thenReturn(Map.of());
    when(pricingClient.quote(any())).thenReturn(Map.of());

    Order order = OrderFixtures.simple("USR-1");

    EnrichedOrder result = svc.enrich(order).join();

    assertThat(result.isFailed()).isTrue();
    assertThat(result.errorCause()).isInstanceOf(TimeoutException.class);
    realExecutor.shutdownNow();
}

Test 3 — Awaitility for fire-and-forget

@Test
void publishEvent_async_eventuallyArrives() {
    AtomicReference captured = new AtomicReference<>();
    bus.subscribe(captured::set);

    publisher.publish(new OrderEvent("ORD-1", "PAID"));

    await().atMost(Duration.ofSeconds(2))
           .untilAsserted(() -> {
               assertThat(captured.get()).isNotNull();
               assertThat(captured.get().status()).isEqualTo("PAID");
           });
}

▸ pitfall — uncontrolled Thread.sleep in tests

Thread.sleep(500) in a test is a future flake. Awaitility polls until success or timeout. Same effect, deterministic.

14 / Async

Virtual threads — Java 21's concurrency reset

Spring Boot 3.2+ supports spring.threads.virtual.enabled=true. Tomcat serves each request on a virtual thread. Your @Async tasks run on virtual threads. You write blocking code, you get reactive throughput.

Configuration

spring:
  threads:
    virtual:
      enabled: true

That single line:

  • Switches Tomcat's request handling to virtual threads
  • Replaces the default TaskExecutor with a virtual-thread one
  • Enables virtual threads for @Async, @Scheduled, and Spring's web client

Service that benefits from virtual threads

@Service
@RequiredArgsConstructor
public class FanoutService {

    private final HttpClient httpClient;

    public List fetchAll(List userIds) {
        try (var scope = StructuredTaskScope.open(
                Joiner.allSuccessfulOrThrow())) {

            List> tasks = userIds.stream()
                .map(id -> scope.fork(() -> fetchOne(id)))
                .toList();

            scope.join();

            return tasks.stream().map(Subtask::get).toList();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }

    private UserProfile fetchOne(String id) throws Exception {
        var req = HttpRequest.newBuilder()
            .uri(URI.create("http://users/api/" + id))
            .GET().build();
        var res = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
        return UserProfile.parse(res.body());
    }
}

Note: StructuredTaskScope.open(...) is the Java 25 finalized API. On Java 21 use the preview form new StructuredTaskScope.ShutdownOnFailure().

Test — fanout 100 calls, assert wall-clock concurrency

@SpringBootTest(webEnvironment = WebEnvironment.NONE)
class FanoutServiceTest {

    static WireMockServer wireMock;

    @BeforeAll
    static void startMock() {
        wireMock = new WireMockServer(0);
        wireMock.start();
        wireMock.stubFor(get(urlMatching("/api/.*"))
            .willReturn(aResponse()
                .withFixedDelay(200)
                .withBody("{\"id\":\"u\",\"email\":\"u@x\"}")));
    }
    @AfterAll static void stopMock() { wireMock.stop(); }

    @Autowired FanoutService service;

    @Test
    void fetchAll_100users_underOneSecond() {
        List ids = IntStream.range(0, 100)
            .mapToObj(i -> "USR-" + i).toList();

        long t0 = System.nanoTime();
        List result = service.fetchAll(ids);
        Duration elapsed = Duration.ofNanos(System.nanoTime() - t0);

        assertThat(result).hasSize(100);
        // 100 calls × 200ms each = 20s sequential.
        // With virtual threads ≈ 200-400ms total.
        assertThat(elapsed).isLessThan(Duration.ofSeconds(1));
    }

    @Test
    void runsOnVirtualThreads() throws Exception {
        AtomicBoolean wasVirtual = new AtomicBoolean();

        Thread t = Thread.ofVirtual().start(() -> {
            wasVirtual.set(Thread.currentThread().isVirtual());
        });
        t.join();

        assertThat(wasVirtual).isTrue();
    }
}

▸ pitfall — synchronized pins the carrier thread

A virtual thread inside a synchronized block can't unmount, so the OS thread is blocked too. Use ReentrantLock for high-contention sections — it cooperates with virtual threads. Java 24+ removes this restriction (JEP 491), but on 21 LTS you still need to migrate hot paths.

— PART IV —

AWS via LocalStack

Real S3 protocol. Real SQS protocol. Just running on your machine.

15 / AWS

S3 with LocalStack — upload, download, presigned URLs

S3 client config

@Configuration
@RequiredArgsConstructor
public class AwsConfig {

    @Value("${aws.region}")        String region;
    @Value("${aws.endpoint}")      String endpoint;
    @Value("${aws.access-key}")    String accessKey;
    @Value("${aws.secret-key}")    String secretKey;

    @Bean
    public S3Client s3Client() {
        var creds = AwsBasicCredentials.create(accessKey, secretKey);
        return S3Client.builder()
            .region(Region.of(region))
            .endpointOverride(URI.create(endpoint))
            .credentialsProvider(StaticCredentialsProvider.create(creds))
            .forcePathStyle(true)            // REQUIRED for LocalStack
            .build();
    }

    @Bean
    public S3Presigner s3Presigner() {
        return S3Presigner.builder()
            .region(Region.of(region))
            .endpointOverride(URI.create(endpoint))
            .credentialsProvider(StaticCredentialsProvider.create(
                AwsBasicCredentials.create(accessKey, secretKey)))
            .serviceConfiguration(S3Configuration.builder().pathStyle(true).build())
            .build();
    }
}

Upload service

@Service
@RequiredArgsConstructor
public class InvoiceStorageService {

    private final S3Client s3;
    private final S3Presigner presigner;

    @Value("${aws.s3.bucket-uploads}")
    private String bucket;

    public StoredFile store(UUID orderId, byte[] content) {
        String key = "invoices/%s/%s.pdf".formatted(
            LocalDate.now(), orderId);

        s3.putObject(PutObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .contentType("application/pdf")
                .metadata(Map.of("orderId", orderId.toString()))
                .build(),
            RequestBody.fromBytes(content));

        return new StoredFile(bucket, key, content.length);
    }

    public byte[] download(String key) {
        return s3.getObjectAsBytes(GetObjectRequest.builder()
                .bucket(bucket).key(key).build())
            .asByteArray();
    }

    public URL presignedDownloadUrl(String key, Duration ttl) {
        var req = GetObjectPresignRequest.builder()
            .signatureDuration(ttl)
            .getObjectRequest(b -> b.bucket(bucket).key(key))
            .build();
        return presigner.presignGetObject(req).url();
    }
}

Test against LocalStack

@SpringBootTest(webEnvironment = WebEnvironment.NONE)
@Testcontainers
class InvoiceStorageServiceTest {

    @Container
    static LocalStackContainer localStack = new LocalStackContainer(
            DockerImageName.parse("localstack/localstack:3.8"))
        .withServices(Service.S3);

    @DynamicPropertySource
    static void awsProps(DynamicPropertyRegistry r) {
        r.add("aws.endpoint",   localStack::getEndpoint);
        r.add("aws.region",     localStack::getRegion);
        r.add("aws.access-key", localStack::getAccessKey);
        r.add("aws.secret-key", localStack::getSecretKey);
        r.add("aws.s3.bucket-uploads", () -> "app-uploads");
    }

    @BeforeAll
    static void setupBucket() throws Exception {
        localStack.execInContainer("awslocal", "s3", "mb", "s3://app-uploads");
    }

    @Autowired InvoiceStorageService service;
    @Autowired S3Client s3;

    @Test
    void store_uploadsBytes_setsMetadata() {
        UUID orderId = UUID.randomUUID();
        byte[] pdf = "FAKE PDF CONTENT".getBytes(UTF_8);

        StoredFile result = service.store(orderId, pdf);

        assertThat(result.size()).isEqualTo(pdf.length);
        assertThat(result.key()).contains(orderId.toString());

        byte[] downloaded = s3.getObjectAsBytes(b ->
            b.bucket("app-uploads").key(result.key())).asByteArray();
        assertThat(downloaded).isEqualTo(pdf);

        var head = s3.headObject(b -> b.bucket("app-uploads").key(result.key()));
        assertThat(head.metadata()).containsEntry("orderid", orderId.toString());
    }

    @Test
    void presignedUrl_canBeFetchedExternally() throws Exception {
        UUID orderId = UUID.randomUUID();
        var stored = service.store(orderId, "X".getBytes());

        URL url = service.presignedDownloadUrl(stored.key(), Duration.ofMinutes(5));

        try (HttpClient client = HttpClient.newHttpClient()) {
            HttpResponse response = client.send(
                HttpRequest.newBuilder(url.toURI()).GET().build(),
                HttpResponse.BodyHandlers.ofByteArray());

            assertThat(response.statusCode()).isEqualTo(200);
            assertThat(response.body()).isEqualTo("X".getBytes());
        }
    }

    @Test
    void download_missingKey_throwsNoSuchKey() {
        assertThatThrownBy(() -> service.download("does/not/exist"))
            .isInstanceOf(NoSuchKeyException.class);
    }
}
16 / AWS

SQS — publishers, listeners, DLQ

Spring Cloud AWS gives you @SqsListener on top of the SDK. We test publish & consume against a real LocalStack queue and assert the DLQ catches poison messages.

Publisher + listener

@Service
@RequiredArgsConstructor
public class OrderEventPublisher {

    private final SqsTemplate sqs;

    public void publish(OrderEvent event) {
        sqs.send(to -> to
            .queue("order-events")
            .payload(event)
            .messageGroupId(event.orderId().toString()));
    }
}

@Component
@RequiredArgsConstructor
public class OrderEventListener {

    private final OrderProjector projector;

    @SqsListener("order-events")
    public void onMessage(OrderEvent event,
                          @Header("ApproximateReceiveCount") Integer receiveCount) {
        if (receiveCount > 5) {
            throw new PoisonMessageException("retried " + receiveCount + " times");
        }
        projector.apply(event);
    }
}

Roundtrip integration test

@SpringBootTest
@Testcontainers
class OrderEventFlowIT {

    @Container
    static LocalStackContainer localStack = new LocalStackContainer(
            DockerImageName.parse("localstack/localstack:3.8"))
        .withServices(Service.SQS);

    @DynamicPropertySource
    static void awsProps(DynamicPropertyRegistry r) {
        r.add("spring.cloud.aws.endpoint", localStack::getEndpoint);
        r.add("spring.cloud.aws.region.static", localStack::getRegion);
        r.add("spring.cloud.aws.credentials.access-key", localStack::getAccessKey);
        r.add("spring.cloud.aws.credentials.secret-key", localStack::getSecretKey);
        r.add("aws.endpoint", localStack::getEndpoint);
        r.add("aws.region", localStack::getRegion);
    }

    @BeforeAll
    static void createQueue() throws Exception {
        localStack.execInContainer("awslocal", "sqs", "create-queue",
            "--queue-name", "order-events");
    }

    @Autowired OrderEventPublisher publisher;
    @MockitoBean OrderProjector projector;

    @Test
    void publish_isReceivedByListener() {
        OrderEvent event = new OrderEvent(
            UUID.randomUUID(), "PAID", Instant.now());

        publisher.publish(event);

        await().atMost(Duration.ofSeconds(10))
            .untilAsserted(() -> verify(projector).apply(eq(event)));
    }

    @Test
    void poisonMessage_endsUpInDlq() {
        OrderEvent event = new OrderEvent(UUID.randomUUID(), "BAD", Instant.now());
        doThrow(new RuntimeException("boom"))
            .when(projector).apply(any());

        publisher.publish(event);

        await().atMost(Duration.ofSeconds(20))
            .untilAsserted(() -> verify(projector, atLeast(2)).apply(any()));
    }
}

▸ FIFO vs standard

Standard SQS: at-least-once delivery, no order. FIFO SQS: exactly-once, ordered per group, requires messageGroupId and messageDeduplicationId. Test both — your business logic should be idempotent regardless.

— PART V —

Workflows with Temporal.io

Durable workflows that survive crashes. The most testable async system you'll ever ship.

17 / Workflows

Temporal — workflows, activities, signals

Workflows are deterministic code. Activities are the side-effects. Signals push data in. Queries read it back. Test workflows with the in-process TestWorkflowExtension — no server, no I/O, full timeline control.

Workflow + activity interfaces

@WorkflowInterface
public interface OrderFulfillmentWorkflow {

    @WorkflowMethod
    OrderResult fulfil(UUID orderId);

    @SignalMethod
    void cancelRequested();

    @QueryMethod
    OrderStatus currentStatus();
}

@ActivityInterface
public interface FulfillmentActivities {

    @ActivityMethod
    PaymentResult chargePayment(UUID orderId, BigDecimal amount);

    @ActivityMethod
    String reserveInventory(UUID orderId, List lines);

    @ActivityMethod
    String shipOrder(UUID orderId, String reservationId);

    @ActivityMethod
    void notifyCustomer(UUID orderId, String message);

    @ActivityMethod
    void releaseInventory(String reservationId);
}

Workflow implementation

public class OrderFulfillmentWorkflowImpl implements OrderFulfillmentWorkflow {

    private final FulfillmentActivities activities = Workflow.newActivityStub(
        FulfillmentActivities.class,
        ActivityOptions.newBuilder()
            .setStartToCloseTimeout(Duration.ofSeconds(30))
            .setRetryOptions(RetryOptions.newBuilder()
                .setMaximumAttempts(3)
                .setInitialInterval(Duration.ofSeconds(1))
                .build())
            .build());

    private OrderStatus status = OrderStatus.NEW;
    private boolean cancelRequested = false;

    @Override
    public OrderResult fulfil(UUID orderId) {
        Order order = Workflow.sideEffect(Order.class, () -> OrderRegistry.fetch(orderId));

        status = OrderStatus.PAYING;
        PaymentResult payment = activities.chargePayment(orderId, order.total());
        if (!payment.success()) return OrderResult.failed("payment_declined");

        status = OrderStatus.RESERVING;
        String reservation = activities.reserveInventory(orderId, order.lines());

        boolean cancelled = Workflow.await(Duration.ofMinutes(5), () -> cancelRequested);
        if (cancelled) {
            activities.releaseInventory(reservation);
            return OrderResult.cancelled();
        }

        status = OrderStatus.SHIPPING;
        String tracking = activities.shipOrder(orderId, reservation);

        activities.notifyCustomer(orderId, "Shipped: " + tracking);
        status = OrderStatus.SHIPPED;
        return OrderResult.success(tracking);
    }

    @Override public void cancelRequested() { cancelRequested = true; }
    @Override public OrderStatus currentStatus()  { return status; }
}

Test 1 — happy path with TestWorkflowExtension

class OrderFulfillmentWorkflowTest {

    @RegisterExtension
    public static final TestWorkflowExtension testWf = TestWorkflowExtension.newBuilder()
        .setWorkflowTypes(OrderFulfillmentWorkflowImpl.class)
        .setDoNotStart(true)
        .build();

    private FulfillmentActivities activities;

    @BeforeEach
    void registerActivities(TestWorkflowEnvironment env, Worker worker) {
        activities = mock(FulfillmentActivities.class);
        worker.registerActivitiesImplementations(activities);
        env.start();
    }

    @Test
    void fulfil_happyPath_returnsTracking(TestWorkflowEnvironment env,
                                           OrderFulfillmentWorkflow wf) {
        UUID orderId = UUID.randomUUID();
        when(activities.chargePayment(any(), any())).thenReturn(PaymentResult.ok());
        when(activities.reserveInventory(any(), any())).thenReturn("RES-1");
        when(activities.shipOrder(any(), any())).thenReturn("TRK-9");

        OrderResult result = wf.fulfil(orderId);

        assertThat(result.success()).isTrue();
        assertThat(result.trackingNumber()).isEqualTo("TRK-9");

        InOrder order = inOrder(activities);
        order.verify(activities).chargePayment(eq(orderId), any());
        order.verify(activities).reserveInventory(eq(orderId), any());
        order.verify(activities).shipOrder(eq(orderId), eq("RES-1"));
        order.verify(activities).notifyCustomer(eq(orderId), contains("TRK-9"));
    }

    @Test
    void fulfil_paymentDeclined_doesNotReserveInventory(
            TestWorkflowEnvironment env, OrderFulfillmentWorkflow wf) {
        when(activities.chargePayment(any(), any()))
            .thenReturn(PaymentResult.declined("insufficient_funds"));

        OrderResult result = wf.fulfil(UUID.randomUUID());

        assertThat(result.success()).isFalse();
        verify(activities, never()).reserveInventory(any(), any());
        verify(activities, never()).shipOrder(any(), any());
    }
}

Test 2 — signal-driven cancellation with virtual time

The Temporal test environment uses virtual time — env.sleep(Duration.ofMinutes(2)) takes microseconds in real time but advances workflow clocks fully. Test "what happens after 5 minutes" without waiting 5 minutes.

@Test
void cancelSignal_triggersInventoryRelease(
        TestWorkflowEnvironment env,
        WorkflowClient client,
        OrderFulfillmentWorkflow wf) {

    when(activities.chargePayment(any(), any())).thenReturn(PaymentResult.ok());
    when(activities.reserveInventory(any(), any())).thenReturn("RES-2");

    UUID orderId = UUID.randomUUID();

    WorkflowClient.start(wf::fulfil, orderId);

    env.sleep(Duration.ofSeconds(5));
    assertThat(wf.currentStatus()).isEqualTo(OrderStatus.RESERVING);

    wf.cancelRequested();

    OrderResult result = WorkflowStub.fromTyped(wf).getResult(OrderResult.class);
    assertThat(result.cancelled()).isTrue();

    verify(activities).releaseInventory("RES-2");
    verify(activities, never()).shipOrder(any(), any());
}

Test 3 — activity retry behavior

@Test
void shipOrder_failsTwice_succeedsThirdTime(
        TestWorkflowEnvironment env, OrderFulfillmentWorkflow wf) {

    when(activities.chargePayment(any(), any())).thenReturn(PaymentResult.ok());
    when(activities.reserveInventory(any(), any())).thenReturn("RES-3");
    when(activities.shipOrder(any(), any()))
        .thenThrow(new RuntimeException("carrier offline"))
        .thenThrow(new RuntimeException("carrier offline"))
        .thenReturn("TRK-FINAL");

    OrderResult result = wf.fulfil(UUID.randomUUID());

    assertThat(result.success()).isTrue();
    assertThat(result.trackingNumber()).isEqualTo("TRK-FINAL");
    verify(activities, times(3)).shipOrder(any(), any());
}

▸ Workflow code is deterministic

Inside a workflow you can't call System.currentTimeMillis(), UUID.randomUUID(), or any method whose result might change on replay. Use Workflow.currentTimeMillis(), Workflow.randomUUID(), or wrap impure code in Workflow.sideEffect(). The test framework enforces this — break determinism and your test fails before your prod code does.

— PART VI —

Security & OIDC

JWT-bearer tokens from Keycloak. Role-based access. Tested three ways — mocked, against WireMock, and against a real Keycloak container.

18 / Security

OIDC + Keycloak — testing secured endpoints

Spring Security's resource-server auto-configuration validates JWTs against a JWKS URI. Roles live in the realm_access.roles claim — Spring expects them prefixed with SCOPE_ by default, so we customize the converter.

Security configuration

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health", "/actuator/info").permitAll()
                .anyRequest().authenticated())
            .oauth2ResourceServer(oauth -> oauth
                .jwt(jwt -> jwt.jwtAuthenticationConverter(keycloakConverter())))
            .build();
    }

    private Converter keycloakConverter() {
        var converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(jwt -> {
            Map realmAccess = jwt.getClaim("realm_access");
            if (realmAccess == null) return List.of();

            @SuppressWarnings("unchecked")
            List roles = (List) realmAccess.getOrDefault("roles", List.of());

            return roles.stream()
                .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
                .collect(toList());
        });
        return converter;
    }
}

Test 1 — slice tests with @WithMockUser

Fast feedback. No real JWT, no Keycloak. Spring Security Test creates an authenticated principal in the security context.

@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderControllerSecurityTest {

    @Autowired MockMvc mvc;
    @MockitoBean OrderService orderService;

    @Test
    void anonymousRequest_returns401() throws Exception {
        mvc.perform(get("/api/v1/orders/{id}", UUID.randomUUID()))
            .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(roles = "USER")
    void userRole_canRead() throws Exception {
        when(orderService.findById(any())).thenReturn(Optional.of(OrderFixtures.simple("u")));

        mvc.perform(get("/api/v1/orders/{id}", UUID.randomUUID()))
            .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(roles = "USER")
    void userRole_cannotCancel() throws Exception {
        mvc.perform(delete("/api/v1/orders/{id}", UUID.randomUUID()).with(csrf()))
            .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(roles = {"MANAGER","USER"})
    void managerRole_canCancel() throws Exception {
        mvc.perform(delete("/api/v1/orders/{id}", UUID.randomUUID()).with(csrf()))
            .andExpect(status().isNoContent());
    }
}

Test 2 — JWT post-processor for realistic claims

When you need to assert behavior based on the claim shape (sub, custom claims, the realm_access map), use the jwt() request post-processor. The full converter pipeline runs.

@Test
void create_usesSubFromJwt() throws Exception {
    when(orderService.create(any(), eq("USR-42")))
        .thenReturn(OrderFixtures.persistedFor("USR-42"));

    var body = """
        {"userId":"USR-42","items":[{"sku":"SKU-1","quantity":1,"unitPrice":9.99}],
         "contactEmail":"a@b.com","orderDate":"2026-04-28"}
        """;

    mvc.perform(post("/api/v1/orders")
            .with(jwt()
                .jwt(j -> j.subject("USR-42")
                           .claim("realm_access", Map.of("roles", List.of("USER"))))
                .authorities(new SimpleGrantedAuthority("ROLE_USER")))
            .with(csrf())
            .contentType(MediaType.APPLICATION_JSON)
            .content(body))
        .andExpect(status().isCreated());

    verify(orderService).create(any(), eq("USR-42"));
}

Test 3 — full integration with a real Keycloak container

For end-to-end confidence, fetch a real token via the password grant against a Keycloak Testcontainer and call the API with it.

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
@AutoConfigureMockMvc
class KeycloakIntegrationIT {

    @Container
    static KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:26.0")
        .withRealmImportFile("/realm-export.json");

    @DynamicPropertySource
    static void jwt(DynamicPropertyRegistry r) {
        r.add("spring.security.oauth2.resourceserver.jwt.issuer-uri",
            () -> keycloak.getAuthServerUrl() + "/realms/tdd-app");
        r.add("spring.security.oauth2.resourceserver.jwt.jwk-set-uri",
            () -> keycloak.getAuthServerUrl()
                + "/realms/tdd-app/protocol/openid-connect/certs");
    }

    @LocalServerPort int port;
    @Autowired RestClient.Builder restBuilder;

    @Test
    void aliceWithAdminRole_canAccessProtectedEndpoint() {
        String accessToken = fetchToken("alice", "password");

        ResponseEntity response = restBuilder.build()
            .get()
            .uri("http://localhost:" + port + "/api/v1/orders")
            .header("Authorization", "Bearer " + accessToken)
            .retrieve()
            .toEntity(String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    }

    @Test
    void bobWithUserRole_cannotCancelOrder() {
        String accessToken = fetchToken("bob", "password");

        assertThatThrownBy(() -> restBuilder.build()
            .delete()
            .uri("http://localhost:" + port + "/api/v1/orders/{id}", UUID.randomUUID())
            .header("Authorization", "Bearer " + accessToken)
            .retrieve()
            .toBodilessEntity())
            .isInstanceOf(HttpClientErrorException.Forbidden.class);
    }

    private String fetchToken(String username, String password) {
        var form = new LinkedMultiValueMap();
        form.add("client_id", "tdd-app-backend");
        form.add("client_secret", "backend-secret-change-me");
        form.add("grant_type", "password");
        form.add("username", username);
        form.add("password", password);

        Map body = restBuilder.build()
            .post()
            .uri(keycloak.getAuthServerUrl() + "/realms/tdd-app/protocol/openid-connect/token")
            .contentType(MediaType.APPLICATION_FORM_URLENCODED)
            .body(form)
            .retrieve()
            .body(new ParameterizedTypeReference<>() {});

        return (String) body.get("access_token");
    }
}

▸ pitfall — issuer-uri exact match

Spring Security validates the iss claim character-for-character against the configured issuer-uri. Trailing slashes, localhost vs container hostname — any mismatch returns 401 with a useless error. Always derive issuer-uri from the running container, never hard-code it.

19 / Resilience

Error testing — the negative paths

Most prod incidents come from paths nobody tested. Validation rejection. Timeouts. Constraint violations. Half-applied transactions. Retries that retry forever. This section is the negative-path playbook.

Pattern 1 — assert validation responses, every field

Bean Validation aggregates errors. Don't assert just 400 — assert which fields failed and which messages appear. A @ControllerAdvice that maps MethodArgumentNotValidException to a structured response makes this clean.

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiError onValidation(MethodArgumentNotValidException e) {
        List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors().stream()
            .map(fe -> new FieldError(fe.getField(), fe.getDefaultMessage(),
                String.valueOf(fe.getRejectedValue())))
            .toList();
        return new ApiError("VALIDATION_FAILED", "Request body invalid", fieldErrors);
    }

    @ExceptionHandler(ConstraintViolationException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiError onConstraint(ConstraintViolationException e) {
        var fieldErrors = e.getConstraintViolations().stream()
            .map(cv -> new FieldError(
                cv.getPropertyPath().toString(),
                cv.getMessage(),
                String.valueOf(cv.getInvalidValue())))
            .toList();
        return new ApiError("VALIDATION_FAILED", "Parameter invalid", fieldErrors);
    }

    @ExceptionHandler(NotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ApiError onNotFound(NotFoundException e) {
        return new ApiError("NOT_FOUND", e.getMessage(), List.of());
    }

    @ExceptionHandler(DataIntegrityViolationException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public ApiError onIntegrity(DataIntegrityViolationException e) {
        String message = e.getMostSpecificCause().getMessage();
        if (message != null && message.contains("idempotency_key"))
            return new ApiError("DUPLICATE", "Request already processed", List.of());
        return new ApiError("CONFLICT", "Resource already exists", List.of());
    }
}
@WebMvcTest(OrderController.class)
@Import({SecurityConfig.class, ApiExceptionHandler.class})
class OrderControllerErrorsTest {

    @Autowired MockMvc mvc;
    @MockitoBean OrderService orderService;

    @Test
    @WithMockUser(roles = "USER")
    void create_emptyBody_returnsAllFieldErrors() throws Exception {
        mvc.perform(post("/api/v1/orders")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content("{}"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
            .andExpect(jsonPath("$.errors[*].field",
                containsInAnyOrder("userId", "items")))
            .andExpect(jsonPath("$.errors[?(@.field=='userId')].message")
                .value(contains("required")));
    }

    @Test
    @WithMockUser(roles = "USER")
    void create_negativeQuantity_returnsTargetedError() throws Exception {
        var body = """
            {"userId":"USR-1","items":[{"sku":"SKU-1","quantity":-3,"unitPrice":1.00}],
             "contactEmail":"a@b.com","orderDate":"2026-04-28"}
            """;
        mvc.perform(post("/api/v1/orders")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(body))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors[0].field").value("items[0].quantity"));
    }

    @Test
    @WithMockUser(roles = "USER")
    void create_malformedJson_returns400() throws Exception {
        mvc.perform(post("/api/v1/orders")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content("{ this is not json"))
            .andExpect(status().isBadRequest());
    }

    @Test
    @WithMockUser(roles = "USER")
    void create_wrongContentType_returns415() throws Exception {
        mvc.perform(post("/api/v1/orders")
                .with(csrf())
                .contentType(MediaType.TEXT_PLAIN)
                .content("anything"))
            .andExpect(status().isUnsupportedMediaType());
    }

    @Test
    @WithMockUser(roles = "USER")
    void create_unknownField_isIgnoredByDefault() throws Exception {
        when(orderService.create(any(), any())).thenReturn(OrderFixtures.persisted());
        var body = """
            {"userId":"USR-1","items":[{"sku":"SKU-1","quantity":1,"unitPrice":1.00}],
             "contactEmail":"a@b.com","orderDate":"2026-04-28",
             "weirdField":"ignored"}
            """;
        mvc.perform(post("/api/v1/orders")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(body))
            .andExpect(status().isCreated());
    }
}

Pattern 2 — exception assertions, the AssertJ way

AssertJ exception chains read like sentences. Assert the type, the message, the cause, and even nested causes — without try/catch boilerplate.

// ▸ basic — type + message
assertThatThrownBy(() -> service.process(badInput))
    .isInstanceOf(ValidationException.class)
    .hasMessageContaining("invalid sku")
    .hasMessageMatching(".*SKU-\\d+.*");

// ▸ specific exception types — sugar for IllegalArgumentException, etc.
assertThatIllegalArgumentException()
    .isThrownBy(() -> Money.of("-1", "USD"))
    .withMessage("amount must be non-negative");

// ▸ assert no exception thrown
assertThatNoException().isThrownBy(() -> service.process(validInput));

// ▸ inspect a wrapped cause
assertThatThrownBy(() -> service.callRemote())
    .isInstanceOf(ServiceException.class)
    .hasCauseInstanceOf(ConnectException.class)
    .rootCause()
    .hasMessageContaining("connection refused");

// ▸ extract and inspect a custom exception's fields
assertThatThrownBy(() -> service.charge(card, amount))
    .asInstanceOf(InstanceOfAssertFactories.type(PaymentException.class))
    .extracting(PaymentException::getDeclineCode)
    .isEqualTo("INSUFFICIENT_FUNDS");

// ▸ Future / CompletableFuture — unwrap CompletionException
CompletableFuture<Order> future = service.processAsync(badInput);
assertThatThrownBy(future::get)
    .isInstanceOf(ExecutionException.class)
    .hasCauseInstanceOf(ValidationException.class);

// or use AssertJ's CompletableFuture support
assertThat(future)
    .failsWithin(Duration.ofSeconds(2))
    .withThrowableOfType(ExecutionException.class)
    .withCauseInstanceOf(ValidationException.class);

Pattern 3 — DB constraint violations & rollback

JPA throws a runtime exception, but the transaction state matters too. Test that a failed write rolls back so the next read sees consistent data.

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderTransactionTest {

    @Container @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired OrderRepository repo;
    @Autowired TestEntityManager em;
    @Autowired TransactionTemplate tx;

    @Test
    void duplicateIdempotencyKey_throwsAndRollsBack() {
        Order first = repo.save(OrderFixtures.simple("USR-1").withIdempotencyKey("KEY-1"));
        em.flush();
        long countBefore = repo.count();

        assertThatThrownBy(() -> tx.execute(status -> {
            Order dup = OrderFixtures.simple("USR-2").withIdempotencyKey("KEY-1");
            repo.save(dup);
            em.flush();
            return null;
        }))
        .isInstanceOf(DataIntegrityViolationException.class)
        .hasMessageContaining("idempotency_key");

        assertThat(repo.count()).isEqualTo(countBefore);   // rollback
    }

    @Test
    void notNullConstraint_isEnforced() {
        Order broken = OrderFixtures.simple("USR-1");
        ReflectionTestUtils.setField(broken, "userId", null);

        assertThatThrownBy(() -> { repo.save(broken); em.flush(); })
            .isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void uniqueViolation_translatesToConflict() {
        Order a = repo.save(OrderFixtures.simple("USR-1").withCorrelationId("CORR-1"));
        em.flush();

        assertThatThrownBy(() -> {
            Order b = OrderFixtures.simple("USR-2").withCorrelationId("CORR-1");
            repo.save(b);
            em.flush();
        }).isInstanceOf(DataIntegrityViolationException.class)
          .hasRootCauseInstanceOf(SQLException.class);
    }
}

Pattern 4 — retry & circuit breaker semantics

If your code retries, prove it retries the right number of times, with the right backoff, and gives up correctly. Mockito's thenThrow().thenReturn() chain lets you script transient failures.

@Service
public class ResilientPaymentClient {

    private final RestClient http;

    @Retryable(retryFor = TransientFailure.class,
               maxAttempts = 4,
               backoff = @Backoff(delay = 100, multiplier = 2))
    public PaymentResult charge(ChargeRequest req) {
        try {
            return http.post().uri("/charge").body(req)
                .retrieve().body(PaymentResult.class);
        } catch (ResourceAccessException e) {
            throw new TransientFailure(e);
        }
    }

    @Recover
    public PaymentResult fallback(TransientFailure e, ChargeRequest req) {
        return PaymentResult.deferred(req.id());
    }
}
@SpringBootTest(classes = {ResilientPaymentClient.class, RetryConfig.class})
@EnableRetry
class ResilientPaymentClientTest {

    @Autowired ResilientPaymentClient client;
    @MockitoBean RestClient http;

    @Test
    void retries_thenSucceeds_onTransientFailure() {
        var spec = mockSpecChain();
        when(spec.body(any(Class.class)))
            .thenThrow(new ResourceAccessException("timeout"))
            .thenThrow(new ResourceAccessException("timeout"))
            .thenReturn(PaymentResult.ok("PAY-1"));

        PaymentResult result = client.charge(new ChargeRequest("REQ-1", BigDecimal.TEN));

        assertThat(result.status()).isEqualTo("OK");
        verify(spec, times(3)).body(any(Class.class));
    }

    @Test
    void exhaustsAttempts_thenFallsBackToDeferred() {
        var spec = mockSpecChain();
        when(spec.body(any(Class.class)))
            .thenThrow(new ResourceAccessException("timeout"));

        PaymentResult result = client.charge(new ChargeRequest("REQ-2", BigDecimal.TEN));

        assertThat(result.status()).isEqualTo("DEFERRED");
        verify(spec, times(4)).body(any(Class.class));
    }
}

Pattern 5 — HTTP client errors with WireMock

For testing how your code handles upstream 4xx/5xx, WireMock returns real HTTP responses your client must parse. Mocking the client itself skips serialization & status-code logic — exactly the layer that breaks.

@SpringBootTest(webEnvironment = WebEnvironment.NONE)
class UserClientErrorTest {

    static WireMockServer wm;

    @BeforeAll static void start() {
        wm = new WireMockServer(0); wm.start();
    }
    @AfterAll  static void stop()  { wm.stop(); }
    @BeforeEach void reset()       { wm.resetAll(); }

    @DynamicPropertySource
    static void wireProps(DynamicPropertyRegistry r) {
        r.add("user-service.base-url", () -> "http://localhost:" + wm.port());
    }

    @Autowired UserClient client;

    @Test
    void notFound_returnsEmptyOptional() {
        wm.stubFor(get(urlEqualTo("/users/USR-404"))
            .willReturn(aResponse().withStatus(404)));

        Optional<User> u = client.findById("USR-404");

        assertThat(u).isEmpty();
    }

    @Test
    void serverError_throwsServiceException() {
        wm.stubFor(get(urlPathMatching("/users/.+"))
            .willReturn(aResponse().withStatus(503)
                .withBody("{\"error\":\"upstream_unavailable\"}")));

        assertThatThrownBy(() -> client.findById("USR-1"))
            .isInstanceOf(ServiceUnavailableException.class)
            .hasMessageContaining("upstream_unavailable");
    }

    @Test
    void slowResponse_triggersTimeout() {
        wm.stubFor(get(urlPathMatching("/users/.+"))
            .willReturn(aResponse().withFixedDelay(5_000).withStatus(200)));

        assertThatThrownBy(() -> client.findById("USR-1"))
            .hasRootCauseInstanceOf(SocketTimeoutException.class);
    }

    @Test
    void malformedJson_throwsDeserializationError() {
        wm.stubFor(get(urlPathMatching("/users/.+"))
            .willReturn(aResponse().withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("{ not valid json")));

        assertThatThrownBy(() -> client.findById("USR-1"))
            .hasCauseInstanceOf(JsonProcessingException.class);
    }

    @Test
    void rateLimited_retriesAfterDelay() {
        wm.stubFor(get(urlPathMatching("/users/.+"))
            .inScenario("rl").whenScenarioStateIs(STARTED)
            .willReturn(aResponse().withStatus(429).withHeader("Retry-After", "1"))
            .willSetStateTo("recovered"));
        wm.stubFor(get(urlPathMatching("/users/.+"))
            .inScenario("rl").whenScenarioStateIs("recovered")
            .willReturn(okJson("{\"id\":\"USR-1\",\"email\":\"a@b.com\"}")));

        Optional<User> u = client.findById("USR-1");

        assertThat(u).isPresent();
        wm.verify(2, getRequestedFor(urlPathMatching("/users/.+")));
    }
}

Pattern 6 — concurrent modification & race conditions

@Test
void concurrentSave_oneSucceeds_othersGetOptimisticLockException() throws Exception {
    UUID id = repo.save(OrderFixtures.simple("USR-1")).getId();
    em.flush(); em.clear();

    int threads = 10;
    ExecutorService exec = Executors.newFixedThreadPool(threads);
    CountDownLatch start = new CountDownLatch(1);
    AtomicInteger successes = new AtomicInteger();
    AtomicInteger conflicts = new AtomicInteger();

    List<Future<?>> futures = IntStream.range(0, threads).mapToObj(i ->
        exec.submit(() -> {
            start.await();
            try {
                tx.execute(status -> {
                    Order loaded = repo.findById(id).orElseThrow();
                    ReflectionTestUtils.setField(loaded, "status",
                        i % 2 == 0 ? OrderStatus.PAID : OrderStatus.SHIPPED);
                    repo.saveAndFlush(loaded);
                    return null;
                });
                successes.incrementAndGet();
            } catch (ObjectOptimisticLockingFailureException e) {
                conflicts.incrementAndGet();
            }
            return null;
        })).toList();

    start.countDown();
    for (Future<?> f : futures) f.get(5, TimeUnit.SECONDS);
    exec.shutdown();

    assertThat(successes).hasValue(1);
    assertThat(conflicts).hasValue(threads - 1);
}

Pattern 7 — chaos & partial failure in E2E

@Test
void createOrder_s3Down_orderStillPersists_invoiceQueuedForRetry() {
    // simulate S3 outage by stopping the LocalStack S3 service mid-test
    localStack.execInContainer("supervisorctl", "stop", "s3");

    String token = tokenFor("alice");
    var response = restBuilder.build()
        .post().uri("http://localhost:" + port + "/api/v1/orders")
        .header("Authorization", "Bearer " + token)
        .contentType(MediaType.APPLICATION_JSON)
        .body(sampleOrder())
        .retrieve()
        .toEntity(OrderResponse.class);

    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
    UUID orderId = response.getBody().id();

    // Order persisted in Postgres despite S3 failure
    assertThat(orderRepo.findById(orderId)).isPresent();

    // Invoice landed in retry queue, not the dead-letter
    await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> {
        var msgs = sqs.receiveMessage(b -> b
            .queueUrl(queueUrl("invoice-retry"))).messages();
        assertThat(msgs).extracting(Message::body)
            .anyMatch(m -> m.contains(orderId.toString()));
    });
}

▸ pitfall — flaky tests masquerading as race conditions

A test that "sometimes fails" is broken even when it passes — you've lost determinism. Common causes: shared DB state across tests, @SpringBootTest reusing application context dirtied by a previous test, Thread.sleep in async paths. Either reset state in @AfterEach, use @DirtiesContext, or rewrite with Awaitility.

— PART VII —

Putting it all together

One test. One JWT. Postgres + Redis + S3 + SQS + Temporal. The full system, asserted end to end.

20 / Final

End-to-end test — every container, one assertion

The capstone. Bring up every dependency in Testcontainers, fetch a real Keycloak JWT, POST an order, then assert: it's persisted in Postgres, cached in Redis, an invoice landed in S3, an event hit SQS, and a Temporal workflow started.

Base class — share containers across tests

Static container fields with @Container on a base class share container lifetimes across every test in the suite. Reuse cuts CI time from minutes to seconds.

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("test")
public abstract class FullStackTest {

    @Container @ServiceConnection
    static PostgreSQLContainer postgres =
        new PostgreSQLContainer<>("postgres:16-alpine").withReuse(true);

    @Container @ServiceConnection
    static GenericContainer redis =
        new GenericContainer<>("redis:7-alpine")
            .withExposedPorts(6379)
            .withReuse(true);

    @Container
    static LocalStackContainer localStack = new LocalStackContainer(
            DockerImageName.parse("localstack/localstack:3.8"))
        .withServices(Service.S3, Service.SQS).withReuse(true);

    @Container
    static KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:26.0")
        .withRealmImportFile("/realm-export.json").withReuse(true);

    @DynamicPropertySource
    static void wireEverything(DynamicPropertyRegistry r) {
        // AWS SDK + Spring Cloud AWS
        r.add("aws.endpoint", localStack::getEndpoint);
        r.add("aws.region",   localStack::getRegion);
        r.add("aws.access-key", localStack::getAccessKey);
        r.add("aws.secret-key", localStack::getSecretKey);
        r.add("spring.cloud.aws.endpoint", localStack::getEndpoint);
        r.add("spring.cloud.aws.region.static", localStack::getRegion);

        // Keycloak
        r.add("spring.security.oauth2.resourceserver.jwt.issuer-uri",
            () -> keycloak.getAuthServerUrl() + "/realms/tdd-app");
        r.add("spring.security.oauth2.resourceserver.jwt.jwk-set-uri",
            () -> keycloak.getAuthServerUrl()
                + "/realms/tdd-app/protocol/openid-connect/certs");

        // Temporal — start an in-process test server
        r.add("temporal.serviceAddress", () -> TestEnv.testServerAddress());
    }

    @BeforeAll
    static void provision() throws Exception {
        localStack.execInContainer("awslocal", "s3",  "mb",            "s3://app-uploads");
        localStack.execInContainer("awslocal", "sqs", "create-queue",  "--queue-name", "order-events");
    }

    protected String tokenFor(String username) { /* password grant against keycloak */ }
}

The actual end-to-end test

class CreateOrderE2EIT extends FullStackTest {

    @LocalServerPort int port;
    @Autowired OrderRepository orderRepo;
    @Autowired CacheManager cacheManager;
    @Autowired S3Client s3;
    @Autowired SqsClient sqs;
    @Autowired WorkflowClient workflowClient;
    @Autowired RestClient.Builder restBuilder;

    @Test
    void createOrder_orchestratesEntireSystem() {
        String token = tokenFor("alice");
        var body = Map.of(
            "userId", "USR-alice",
            "items", List.of(Map.of("sku","SKU-1","quantity",2,"unitPrice",9.99)),
            "contactEmail", "alice@example.com",
            "orderDate", LocalDate.now().toString());

        // 1) Hit the API with a real Keycloak token
        ResponseEntity response = restBuilder.build()
            .post().uri("http://localhost:" + port + "/api/v1/orders")
            .header("Authorization", "Bearer " + token)
            .contentType(MediaType.APPLICATION_JSON)
            .body(body)
            .retrieve()
            .toEntity(OrderResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        UUID orderId = response.getBody().id();

        // 2) Postgres persisted the order
        await().atMost(Duration.ofSeconds(5)).untilAsserted(() ->
            assertThat(orderRepo.findById(orderId)).isPresent());

        // 3) Redis cached the read-side projection
        await().atMost(Duration.ofSeconds(5)).untilAsserted(() ->
            assertThat(cacheManager.getCache("orders").get(orderId)).isNotNull());

        // 4) S3 received the invoice PDF
        await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> {
            ListObjectsV2Response listing = s3.listObjectsV2(b -> b.bucket("app-uploads"));
            assertThat(listing.contents())
                .extracting(S3Object::key)
                .anyMatch(k -> k.contains(orderId.toString()));
        });

        // 5) SQS got the OrderCreated event
        await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> {
            var msgs = sqs.receiveMessage(b -> b
                .queueUrl(queueUrl("order-events"))
                .waitTimeSeconds(2)).messages();
            assertThat(msgs)
                .extracting(Message::body)
                .anyMatch(s -> s.contains(orderId.toString()));
        });

        // 6) A Temporal workflow was scheduled for fulfillment
        await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> {
            DescribeWorkflowExecutionResponse desc = workflowClient
                .getWorkflowServiceStubs()
                .blockingStub()
                .describeWorkflowExecution(DescribeWorkflowExecutionRequest.newBuilder()
                    .setNamespace("default")
                    .setExecution(WorkflowExecution.newBuilder()
                        .setWorkflowId("order-" + orderId).build())
                    .build());
            assertThat(desc.getWorkflowExecutionInfo().getStatus())
                .isIn(WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_RUNNING,
                      WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_COMPLETED);
        });
    }
}

▸ this is the only test that should run all containers

Slice tests (@WebMvcTest, @DataJpaTest) run in milliseconds. Component tests (single Testcontainer per concern) run in seconds. End-to-end tests run in tens of seconds. Keep the pyramid: hundreds of slice tests, dozens of component tests, a handful of E2E. Use .withReuse(true) + ~/.testcontainers.properties with testcontainers.reuse.enable=true to keep containers warm across local runs.

21 / Final

Cheatsheet — patterns at a glance

The decision tree, distilled. Print it. Tape it next to your monitor. Quote it in code review.

Mockito — when to reach for what

SITUATION USE WHY
stub on a mockwhen(m.x()).thenReturn(y)readable, type-safe
stub on a spydoReturn(y).when(spy).x()avoids calling the real method
stub a void methoddoThrow(...).when(m).x()when() doesn't compile on void
capture call args@Captor + verify(...).capture()assert call shape
dynamic responsethenAnswer(inv -> ...)echo args, count calls
static methodtry (mockStatic(...)) {}always close the mock

Spring Boot test slices

ANNOTATION LOADS USE FOR
@WebMvcTestcontrollers, securityHTTP, JSON, validation
@DataJpaTestJPA, repositoriesqueries, constraints
@DataRedisTestRedis client onlyredis ops, serialization
@JsonTestJacksonDTO serialization
@SpringBootTestfull contextintegration / E2E

ReflectionTestUtils — quick reference

// Set / read private fields
ReflectionTestUtils.setField(target, "fieldName", value);
Object v = ReflectionTestUtils.getField(target, "fieldName");

// Set field declared on a superclass
ReflectionTestUtils.setField(target, Parent.class, "name", "x", String.class);

// Invoke private method (varargs)
Object out = ReflectionTestUtils.invokeMethod(target, "doStuff", arg1, arg2);

// Invoke a setter (rare)
ReflectionTestUtils.invokeSetterMethod(target, "user", aUser);

Awaitility — for everything async

// Wait until a condition becomes true
await().atMost(Duration.ofSeconds(5))
       .until(() -> repo.count() == 1);

// Wait until an assertion stops failing
await().atMost(Duration.ofSeconds(5))
       .untilAsserted(() ->
            assertThat(captured.get()).isNotNull());

// Polling interval + ignore exceptions during ramp-up
await().pollInterval(Duration.ofMillis(50))
       .pollDelay(Duration.ofMillis(200))
       .atMost(Duration.ofSeconds(10))
       .ignoreExceptionsInstanceOf(NoSuchKeyException.class)
       .untilAsserted(() ->
            assertThat(s3.headObject(req)).isNotNull());

The ten pitfalls

01

Using H2 for "tests" against a Postgres prod DB. Constraints differ. JSON columns differ. Stop.

02

Mocking RedisTemplate. Test against a real Redis container — serialization bugs hide.

03

Thread.sleep() in async tests. Awaitility every time.

04

when().thenReturn() on a spy. Use doReturn().when() — the real method runs otherwise.

05

@InjectMocks doesn't set @Value fields — use ReflectionTestUtils.setField.

06

Hard-coded ports in test config. Use @DynamicPropertySource with the running container.

07

Forgetting forcePathStyle(true) on the S3 client. LocalStack returns 404s otherwise.

08

Calling System.currentTimeMillis() inside a Temporal workflow. Use Workflow.currentTimeMillis().

09

synchronized blocks pin virtual threads on Java 21. Migrate hot paths to ReentrantLock.

10

Trailing slashes in OIDC issuer-uri. Spring compares strings — derive from the container.

22 / Final

References & further reading

Every link below is a primary source — official docs, GitHub repos, or canonical guides. Bookmark the ones for the libraries you reach for daily.

Spring & Java

Test frameworks

Containers & cloud emulation

AWS, messaging, workflows

Tutorials & deep guides

GitHub: every dependency