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

96 lines
3.5 KiB
Markdown

[← 08 The diagnostics endpoint](08-diagnostics.md) · **09 · Testing Actuator** · [README →](../README.md)
# 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`](../src/test/java/com/ankurm/actuator/ActuatorExposureTests.java) runs
with `exposeall,secured` — the widest exposure with real security — and asserts:
```java
@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`.
```java
@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](04-securing-actuator.md), 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`](../src/test/java/com/ankurm/actuator/HealthGroupTests.java):
```java
@Test
void upstreamOutageMovesTheExternalApiIndicatorButNotLiveness() {
upstream.set(UpstreamState.Mode.DOWN);
assertThat(statusOf("externalApi")).isEqualTo(Status.DOWN);
assertThat(statusOf("livenessState")).isEqualTo(Status.UP);
}
```
That is [chapter 07](07-groups-and-probes.md) 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.
```java
@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
```java
@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`](../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](08-diagnostics.md) · **09** · [README →](../README.md)