Files
spring-boot-demo/src/test/java/com/ankurm/actuator/HealthGroupTests.java
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

82 lines
3.2 KiB
Java

package com.ankurm.actuator;
import com.ankurm.actuator.health.UpstreamState;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.health.contributor.Status;
import org.springframework.boot.health.registry.HealthContributorRegistry;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The contract that matters operationally: an upstream outage must move readiness and must NOT
* move liveness.
*
* <p>See docs/07-groups-and-probes.md.
*/
// 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 the indicator was configured with. With the mock environment the
// indicator reports DOWN with "Connection refused" and the test proves nothing.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@ActiveProfiles("groups")
class HealthGroupTests {
@Autowired
private HealthContributorRegistry registry;
@Autowired
private UpstreamState upstream;
@AfterEach
void reset() {
this.upstream.set(UpstreamState.Mode.UP);
}
private Status statusOf(String name) {
var contributor = this.registry.getContributor(name);
assertThat(contributor).as("contributor '%s' is registered", name).isNotNull();
return ((HealthIndicator) contributor).health().getStatus();
}
@Test
void everyCustomIndicatorIsRegisteredUnderTheExpectedName() {
// The bean name, minus a trailing "HealthIndicator", is the key in the JSON response.
// Rename the bean and you silently break every dashboard that reads it.
assertThat(statusOf("ordersDatabase")).isNotNull();
assertThat(statusOf("externalApi")).isNotNull();
assertThat(statusOf("kafka")).isNotNull();
}
@Test
void upstreamOutageMovesTheExternalApiIndicatorButNotLiveness() {
assertThat(statusOf("externalApi")).isEqualTo(Status.UP);
assertThat(statusOf("livenessState")).isEqualTo(Status.UP);
this.upstream.set(UpstreamState.Mode.DOWN);
assertThat(statusOf("externalApi")).isEqualTo(Status.DOWN);
assertThat(statusOf("livenessState"))
.as("a third-party outage must never make this process look unrecoverable")
.isEqualTo(Status.UP);
}
@Test
void theExternalApiIndicatorRespectsItsTimeoutBudget() {
this.upstream.set(UpstreamState.Mode.SLOW);
long start = System.nanoTime();
Status status = statusOf("externalApi");
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
assertThat(status).isEqualTo(Status.DOWN);
// The stub sleeps 30s. demo.upstream.timeout-ms is 750. If this assertion ever fails,
// someone removed the read timeout and the health endpoint can now block a worker.
assertThat(elapsedMs).isLessThan(5_000L);
}
}