Java Testing Cookbook and Recipes
Short recipes for the Java and Spring Boot testing scenarios that come up on most projects: unit tests, web slices, HTTP clients, Testcontainers, Kafka, LocalStack, security, architecture rules, concurrency, browser tests, and load tests. Each recipe links to a runnable project.
Disclaimer: This article was drafted with AI assistance. Every snippet was checked against the linked repository and every test in it passes, and a human reviewed the article before publishing.
Every recipe below comes from mtkhawaja/testing-cookbook, a multi-project Maven build where each project is one self-contained example of a single testing technique. The snippets here are trimmed to the interesting lines; each one links to the project you can clone and run.
Note: The examples assume Java 25, Maven 4 (4.0.0-rc-6, via the wrapper), and Spring Boot 4.1.1 or above. JUnit Jupiter and AssertJ unless a recipe says otherwise, with the house convention of a method-level @DisplayName mirrored by the method name.
#!/usr/bin/env bash
git clone https://github.com/mtkhawaja/testing-cookbook.git
cd testing-cookbook
./mvnw clean install # builds and tests everything
./mvnw test -pl projects/web # one project at a time
Code that calls Instant.now()
When the code under test calls Instant.now() itself, the test has no way to say what “now” should be. Inject a java.time.Clock instead, then pin it with Clock.fixed(...) and advance it with Clock.offset(...).
private static final Instant NOW = Instant.parse("2025-01-01T00:00:00Z");
private static final Duration TTL = Duration.ofMinutes(30);
@DisplayName("Should stamp the token with the clock's time When a token is issued")
@Test
void shouldStampTheTokenWithTheClocksTimeWhenATokenIsIssued() {
final Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);
final AccessTokenFactory factory = new AccessTokenFactory(clock, TTL);
final AccessToken token = factory.issue("user-1");
assertThat(token.issuedAt()).isEqualTo(NOW);
assertThat(token.expiresAt()).isEqualTo(NOW.plus(TTL));
}
The expiry test builds a factory whose clock already sits past the TTL, so nothing has to sleep and wait for real time to pass.
Asserting that something was logged
Sometimes the behavior you care about is a log line, and scraping stdout for it is brittle. Attach a Logback ListAppender to the logger instead and assert on the captured events, level and arguments included.
@BeforeEach
void attachAppender() {
logger = (Logger) LoggerFactory.getLogger(AccountActivityLogger.class);
appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
}
@DisplayName("Should log an INFO event with the user id When a login is recorded")
@Test
void shouldLogAnInfoEventWithTheUserIdWhenALoginIsRecorded() {
activityLogger.recordLogin("user-42");
assertThat(appender.list).singleElement().satisfies(event -> {
assertThat(event.getLevel()).isEqualTo(INFO);
assertThat(event.getFormattedMessage()).isEqualTo("User login succeeded userId=user-42");
assertThat(event.getArgumentArray()).containsExactly("user-42");
});
}
Detach the appender in @AfterEach. Spring Boot’s OutputCaptureExtension is the alternative when you want the rendered console text rather than the log events.
Test objects full of fields you do not care about
A test about one field should not have to construct twenty. Instancio fills the whole object graph, and you pin only the field under test.
@DisplayName("Should treat the customer as a minor When only the age field is set below the threshold")
@Test
void shouldTreatTheCustomerAsAMinorWhenOnlyTheAgeFieldIsSetBelowTheThreshold() {
final Customer customer = Instancio.of(Customer.class)
.set(field(Customer::age), 17)
.create();
assertThat(agePolicy.isAdult(customer)).isFalse();
}
withSeed(...) makes the generated data reproducible when a failure needs chasing.
Mapper output as a contract
A MapStruct mapper renames and flattens fields. Testing it getter by getter tracks the implementation rather than the contract, so compare the serialized form against a checked-in JSON fixture instead.
@DisplayName("Should match the expected JSON contract When mapping each fixture scenario")
@ParameterizedTest(name = "{0}")
@ValueSource(strings = {"complete", "null-optionals", "empty-collections"})
void shouldMatchTheExpectedJsonContractWhenMappingEachFixtureScenario(final String scenario) throws IOException {
try (InputStream input = fixture(scenario + "-input.json");
InputStream expected = fixture(scenario + "-expected.json")) {
final CustomerSource source = JSON.readValue(input, CustomerSource.class);
final CustomerView converted = MAPPER.toView(source);
final JsonNode expectedTree = JSON.readTree(expected);
final JsonNode actualTree = JSON.valueToTree(converted);
assertThat(actualTree).as("JSON mapping contract for %s", scenario).isEqualTo(expectedTree);
}
}
Strict tree equality catches missing fields, extra fields, explicit nulls, and array order, while ignoring property order. The trade-off is that it checks the serialized contract, not Java state hidden behind Jackson annotations.
The HTTP layer without starting a server
You want status codes, JSON bodies, and validation checked without dragging in the database or the rest of the context. @WebMvcTest loads the web layer on its own, and @MockitoBean stands in for the collaborators.
@WebMvcTest(controllers = EventController.class)
class EventControllerWebMVCTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private EventService service;
@DisplayName("Should get event by id When the event exists")
@Test
void shouldGetEventByIdWhenTheEventExists() throws Exception {
final var existingEvent = TestEvents.event("beta");
when(service.get(existingEvent.getId())).thenReturn(Optional.of(existingEvent));
mockMvc.perform(get(EventsApi.PATH_GET_EVENT, existingEvent.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("beta"));
verify(service).get(existingEvent.getId());
}
}
When the filter chain and real serialization matter, the same project has the full-server variants: RestTestClient and TestRestTemplate against a RANDOM_PORT.
Code that calls someone else’s API
Here the class under test is the client, not the server. @RestClientTest hands you a MockRestServiceServer that asserts on the outgoing request and scripts the response.
@RestClientTest(
properties = "greeting.service.base-url=https://example.test",
components = {GreetingRestClientConfiguration.class, RestClientGreetingClient.class}
)
class RestClientGreetingClientTest {
@DisplayName("GET /api/greeting returns message via RestClient")
@Test
void shouldReturnMessageWhenInvokingHTTPGetRequestAgainstGreetingsViaARestClient() {
server.expect(requestTo("https://example.test/api/greeting?name=Muneeb"))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess("""
{"message":"Hello, Muneeb"}""", MediaType.APPLICATION_JSON));
GreetingResponse response = greetingClient.getGreeting("Muneeb");
assertThat(response.message()).isEqualTo("Hello, Muneeb");
}
}
The project does both RestClient and RestTemplate, plus error mapping for non-2xx responses.
A real HTTP server that you control
MockRestServiceServer intercepts inside Spring, so it never touches the actual network stack. WireMock starts a real server on a real port.
@WireMockTest
class EventApiWireMockTest {
@DisplayName("Should send the event and return the created body When creating a new event")
@Test
void shouldSendTheEventAndReturnTheCreatedBodyWhenCreatingANewEvent(final WireMockRuntimeInfo wireMock) {
stubFor(post(urlEqualTo(EventsApi.PATH_CREATE_EVENT))
.withRequestBody(matchingJsonPath("$.title", equalTo("alpha-it")))
.willReturn(aResponse()
.withStatus(201)
.withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.withBody(eventJson(createdId, "alpha-it"))));
final Event created = clientFor(wireMock).post()
.uri(EventsApi.PATH_CREATE_EVENT)
.body(request)
.retrieve()
.body(Event.class);
assertThat(created.getId()).isEqualTo(createdId);
}
}
Pin the JDK client to HTTP/1.1. Its default HTTP/2 trips WireMock with RST_STREAM: Stream cancelled on any request carrying a body, which is a confusing failure to hit when the request itself is fine.
A custom starter or auto-configuration
Bugs in starter-style code tend to be conditional. Does the bean appear by default, disappear when disabled, and step aside for a user override? ApplicationContextRunner answers all three without booting an application.
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(ChecksumAutoConfiguration.class));
@DisplayName("Should not create ChecksumService bean when checksum.service.enabled is false")
@Test
void shouldNotCreateChecksumServiceBeanWhenDisabled() {
contextRunner
.withPropertyValues("checksum.service.enabled=false")
.run(context -> assertThat(context).doesNotHaveBean(ChecksumService.class));
}
@DisplayName("Should allow user to override ChecksumService bean")
@Test
void shouldAllowUserOverride() {
contextRunner
.withBean(ChecksumService.class, () -> input -> "user-override")
.run(context -> {
assertThat(context).hasSingleBean(ChecksumService.class);
assertThat(context.getBean(ChecksumService.class).calculateChecksum("x".getBytes()))
.isEqualTo("user-override");
});
}
Security rules
Authentication and authorization live in two places, the filter chain and the annotations, so they need testing at both levels. URL rules go through @WebMvcTest with the real security configuration imported.
@BeforeEach
void setUp() {
mvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
}
@DisplayName("Should reject anonymous access When the endpoint requires authentication")
@Test
void shouldRejectAnonymousAccessWhenTheEndpointRequiresAuthentication() throws Exception {
mvc.perform(get("/private")).andExpect(status().isUnauthorized());
}
The apply(springSecurity()) line is the easy one to miss. Without it @WithMockUser populates a thread-local that never reaches the filter chain, and every test returns 401 for reasons that look like a configuration bug. Method security guarded by @PreAuthorize is tested separately, where the expected outcome is an AccessDeniedException rather than a status code. The project also covers CSRF, role checks, and real HTTP Basic credentials.
An in-memory database
The appeal is repository tests that run anywhere, with no Docker daemon in sight. A profile per engine keeps the test classes identical across H2, HSQLDB, and Derby.
---
spring:
config.activate.on-profile: h2-test
datasource:
url: jdbc:h2:mem:db;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
jpa:
hibernate.ddl-auto: create-drop
@ActiveProfiles("h2-test")
class DuckServiceH2Test extends DuckServiceTest {
}
The trade-off is real. An in-memory engine in compatibility mode hides dialect, type, identity, and RETURNING differences from the database you actually deploy against, so it works for logic that happens to touch persistence but makes a poor substitute for an integration test.
The actual database
The in-memory recipe above stops being good enough as soon as the SQL gets interesting. Testcontainers starts the real engine, and @ServiceConnection wires the connection details into Spring without any @DynamicPropertySource boilerplate.
@Testcontainers
@DataJdbcTest
@Import({EventStore.class, CallbackConfiguration.class})
class EventStoreTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:18.0");
@Autowired
private EventRepository repository;
}
Keep the container field static, or you get a fresh database per test method. The same project does the MongoDB equivalent with @DataMongoTest, including duplicate-key translation into Spring’s DuplicateKeyException and the detail that the Mongo slice does not roll back writes between tests.
A Kafka producer and consumer
Messaging is asynchronous, so the assertion has to wait on something happening in another thread. An embedded broker plus a Mockito timeout verification keeps that readable.
@SpringBootTest(classes = TestApplication.class, properties = {
"spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}",
})
@EmbeddedKafka
class NotificationServiceTest {
@DisplayName("Should save published event When the producer publishes a new event")
@Test
void shouldSavePublishedEventWhenTheProducerPublishesANewEvent() {
final var event = new QuackEvent(UUID.randomUUID(), "Donald", "Mallard");
service.publish(event);
verify(repository, timeout(TimeUnit.MINUTES.toMillis(1))).save(event);
}
}
The property line matters: spring.embedded.kafka.brokers is what @EmbeddedKafka publishes, and the application still reads spring.kafka.bootstrap-servers.
S3, SQS, and other AWS services
Testing against real AWS is slow, costs money, and needs credentials in CI. LocalStack emulates the services in a container, and Spring Cloud AWS understands @ServiceConnection for it.
@TestConfiguration(proxyBeanMethods = false)
public final class LocalStackS3AndSQSSupport {
@Bean
@ServiceConnection
LocalStackContainer localstackContainer() throws Exception {
final var localStackImage = DockerImageName.parse("localstack/localstack:4.12.0");
final var localStack = new LocalStackContainer(localStackImage);
localStack.start();
localStack.execInContainer("awslocal", "s3", "mb", "s3://" + this.bucketName);
localStack.execInContainer("awslocal", "sqs", "create-queue", "--queue-name", this.queueName);
return localStack;
}
}
Importing that one @TestConfiguration into a @SpringBootTest is enough to get a bucket and a queue, which keeps the per-test setup down to a single annotation.
Architecture that drifts
Layering rules usually live in a wiki page nobody reads, and six months later the web layer is talking straight to persistence. ArchUnit turns those rules into ordinary tests that fail the build.
@AnalyzeClasses(packages = "com.muneebkhawaja.testing.cookbook.arch",
importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
@ArchTest
static final ArchRule layers_are_respected = layeredArchitecture().consideringOnlyDependenciesInLayers()
.layer("Web").definedBy("..web..")
.layer("Service").definedBy("..service..")
.layer("Persistence").definedBy("..persistence..")
.whereLayer("Web").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Web")
.whereLayer("Persistence").mayOnlyBeAccessedByLayers("Service");
@ArchTest
static final ArchRule layers_are_free_of_cycles =
slices().matching("com.muneebkhawaja.testing.cookbook.arch.(*)..").should().beFreeOfCycles();
}
ArchUnit reads compiled classes from the classpath, so in a multi-project build each project needs its own rules class rather than one central one.
A race condition that only shows up in production
A bug that reproduces once in ten thousand runs is unlikely to turn up in a loop of a thousand iterations, and a green build tells you nothing either way. Fray explores thread schedules deterministically instead of hoping the scheduler cooperates.
@ExtendWith(FrayTestExtension.class)
class HitCounterTest {
@ConcurrencyTest(iterations = 1000)
@DisplayName("Should remain consistent across multiple hits When the lock free counter is updated concurrently")
@Test
public void shouldRemainConsistentAcrossMultipleHitsWhenTheLockFreeCounterIsUpdatedConcurrently()
throws InterruptedException {
hitCounterTest(new LockFreeHitCounter());
}
}
The test compares four implementations: lock-free, reentrant lock, read-write lock, and a private monitor. Run these through Maven: the prepare-fray goal instruments the tests, and the IDE’s plain JUnit runner skips the schedule exploration entirely.
Specifications that non-developers read
Acceptance criteria written in a ticket drift away from the tests that are supposed to enforce them. Cucumber makes the Gherkin itself the executable artifact.
Feature: Greeting page
Scenario: Greets the visitor by name
Given the greeting page is open
When I greet "Ada"
Then the page shows the greeting "Hello, Ada!"
The Spring wiring has one sharp edge. A class annotated with @CucumberContextConfiguration is instantiated but not initialized by Spring, and Cucumber refuses to start if more than one such class is on the glue path. Give each feature its own suite with an explicit glue package, and set the same glue in the IDE run configuration, which otherwise defaults to scanning the classpath root.
One more that took me a while to spot: name JUnit Platform @Suite runners *Test. Surefire’s default includes are *Test, Test*, *Tests, and *TestCase, so a runner called FeatureOneSuite is skipped silently and Maven reports Tests run: 0 while the build stays green.
When the project uses TestNG
Not every codebase is on JUnit. TestNG integrates with Spring through AbstractTestNGSpringContextTests, and Surefire picks tests from a suite XML rather than by class name.
@ContextConfiguration(classes = {TestApplication.class})
@Test(groups = {"integration"})
public class StringStatisticsIntegrationTest extends AbstractTestNGSpringContextTests {
@Autowired
private StringStatisticsService service;
}
#!/usr/bin/env bash
./mvnw test -Dtestng.suite=src/test/resources/suites/unit-tests.xml -pl projects/testng-tests
Because the suite XML drives selection, -Dtest= does not filter these the way it does elsewhere. Edit the suite to change what runs.
The browser
Browser tests need a browser, and “works on my machine” turns literal the moment Chrome auto-updates. Testcontainers runs a pinned headless browser in Docker, so you and CI get the same version.
private static final DockerImageName BROWSER_IMAGE = DockerImageName.parse("selenium/standalone-chromium:4.43.0")
.asCompatibleSubstituteFor("selenium/standalone-chrome");
@Bean(destroyMethod = "stop")
@Lazy
BrowserWebDriverContainer browser(@LocalServerPort final int port) {
// lets the browser reach the app on the host as http://host.testcontainers.internal:<port>
Testcontainers.exposeHostPorts(port);
final var browser = new BrowserWebDriverContainer(BROWSER_IMAGE).withAccessToHost(true);
browser.start();
return browser;
}
Use selenium/standalone-chromium rather than standalone-chrome, because only the Chromium image is multi-arch and runs on Apple Silicon. Testcontainers knows nothing about that image, so declare the substitution with asCompatibleSubstituteFor. Make the browser beans @Lazy, since the random server port is unknown until after the eager singletons are created. And reach for an explicit WebDriverWait condition rather than a bare findElement after a click, which races the navigation. That one showed up as a failing scenario the first time I ran the TestNG suite.
The same scenarios run in two flavors: a JUnit Platform @Suite, and AbstractTestNGCucumberTests where each scenario becomes its own TestNG test. They are separate projects on purpose, since Surefire runs exactly one provider per execution.
projects/ui-tests and projects/ui-tests-testng
Performance, as a test
Load testing usually lives in a separate tool, in a GUI-authored XML file that nobody can review in a pull request. The JMeter Java DSL writes the plan as Java and asserts on the results like any other test.
@DisplayName("Should serve quote lookups under the latency budget When 10 users each make 20 requests")
@Test
void shouldServeQuoteLookupsUnderTheLatencyBudgetWhen10UsersEachMake20Requests() throws IOException {
final TestPlanStats stats = testPlan(
threadGroup(10, 20,
httpSampler(url("/api/quotes/1"))
.children(responseAssertion().containsSubstrings("Kent Beck"))),
jtlWriter("target/jmeter/results")
).run();
assertThat(stats.overall().samplesCount()).isEqualTo(200);
assertThat(stats.overall().errorsCount()).isZero();
assertThat(stats.overall().sampleTimePercentile99()).isLessThan(Duration.ofMillis(500));
}
Name your samplers if you want per-sampler statistics, since httpSampler(url) gets JMeter’s default label and byLabel(url) then returns null. saveAsJmx(...) exports the plan for the GUI when someone asks for it.
The load generator and the application share one JVM and one machine here, so these numbers catch regressions rather than measure capacity. For real throughput figures, point the same plan at a deployed environment and generate the load from somewhere else.
References
The repository:
Framework and platform:
- Spring Boot testing
- Spring Framework testing
- Spring Security testing
- Spring for Apache Kafka testing
- Spring Cloud AWS
- Maven Surefire
Runners and assertions:
Test data and mapping:
Doubles and real services:
Structure, concurrency, and the browser:
Performance: