Files
spring-boot-demo/docs/09-testing-actuator.md
Ankur Mhatre 4b6cefa60a Spring Boot 4 Actuator in production: endpoints, security, custom health indicators
Companion repository for the ankurm.com article. Every transcript in docs/output/
was produced by running this project; scripts/run-all.sh regenerates all of them.

Verified against Spring Boot 4.1.1 / Framework 7.0.9 / Security 7.1.1 /
Micrometer 1.17.1 / kafka-clients 4.2.1 on Temurin JDK 25.0.4.1+1.
2026-09-04 10:40:04 +05:30

3.5 KiB

← 08 The diagnostics endpoint · 09 · Testing Actuator · README →

09 — Testing Actuator

Eight tests, and none of them assert the happy path. They pin the behaviour that would be expensive to rediscover after an upgrade.

Pin the surprises

ActuatorExposureTests runs with exposeall,secured — the widest exposure with real security — and asserts:

@Test
void heapdumpIsNotExposedEvenWithWildcardExposure() throws Exception {
    mvc().perform(get("/actuator/heapdump")).andExpect(status().isNotFound());
}

If a future Boot release changes the access default for heapdump, or if somebody adds management.endpoint.heapdump.access=unrestricted to a shared config, this test is where you find out. Same for shutdown.

@Test
void envMasksEveryValueRegardlessOfKeyName() throws Exception {
    // acme.partner.credential matches no password/secret/token pattern.
    ... .andExpect(jsonPath("$.property.value").value("******"));
}

@Test
void anonymousHealthCarriesNoComponentBreakdown() throws Exception {
    mvc().perform(get("/actuator/health"))
         .andExpect(jsonPath("$.components").doesNotExist());
}

That last one is the show-details: when-authorized contract from chapter 04, asserted rather than assumed. It is a one-line configuration change away from leaking your dependency map to anonymous callers.

Test the operational contract, not the indicator

HealthGroupTests:

@Test
void upstreamOutageMovesTheExternalApiIndicatorButNotLiveness() {
    upstream.set(UpstreamState.Mode.DOWN);
    assertThat(statusOf("externalApi")).isEqualTo(Status.DOWN);
    assertThat(statusOf("livenessState")).isEqualTo(Status.UP);
}

That is chapter 07 as an executable assertion. It fails if someone adds externalApi to the liveness group, which is the exact change that causes a fleet-wide restart loop.

@Test
void theExternalApiIndicatorRespectsItsTimeoutBudget() {
    upstream.set(UpstreamState.Mode.SLOW);   // the stub sleeps 30s
    ...
    assertThat(elapsedMs).isLessThan(5_000L);
}

Fails if someone removes the read timeout. A generous bound, because timing assertions on CI are how you get flaky suites — but 5 s versus 30 s is a wide enough gap to be safe.

DEFINED_PORT, and why

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)

Not the default MOCK environment. ExternalApiHealthIndicator makes a real HTTP call to this application's own stub controller, so a servlet container has to be listening on the port it was configured with. Under MOCK the indicator reports DOWN with a connection error and every assertion about upstream state is meaningless — which is precisely what happened on the first run of this suite.

RANDOM_PORT does not help here: the indicator resolves its URL at bean construction, before the server binds, so local.server.port is not available to it.

What is not tested

There is no test for the 60-second Kafka timeout. A test that takes a minute to pass does not belong in a suite people run before pushing — scripts/demo-kafka-timeout.sh captures it instead, and the transcript is committed. Some evidence is better as a script than as an assertion.


← 08 · 09 · README →