Spring Boot Profiles, Configuration, Testing and Actuator
Backend DevelopmentLesson 4 of 45 min read
What you'll learn
- Bind settings to a typed @ConfigurationProperties record instead of scattered @Value fields
- Use profiles and environment variables to change behaviour per environment
- Choose between a plain unit test, @WebMvcTest, @DataJpaTest and @SpringBootTest
- Expose and secure health and metrics endpoints with Spring Boot Actuator
On this page
A metro operator changes the peak-hour multiplier from 1.5 to 1.6 on a Friday afternoon. If that number is a constant buried in a class, the change needs a code review, a build and a deployment. If it is a configuration value, someone edits an environment variable and restarts. Deciding what is code and what is configuration is half of this lesson; the other half is proving your service works and watching it once it runs.
Typed configuration instead of scattered strings
@Value("${fare.base}") works, but it spreads string keys over the codebase and gives you no grouping or validation. @ConfigurationProperties binds a whole prefix to one object. With Boot 3, a record is bound through its constructor, so the settings are immutable. Durations understand suffixes such as 45m, and @Validated makes the app fail at startup if a value is nonsense, which is far cheaper than failing at 8:05 on a Monday.
package com.example.fares;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.LocalTime;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
@Validated
@ConfigurationProperties(prefix = "fare")
public record FareProperties(
@NotNull @Positive BigDecimal base,
@NotNull @Positive BigDecimal peakMultiplier,
@NotNull Duration transferWindow) {
}
// Register it once, for example on the application class:
// @SpringBootApplication
// @ConfigurationPropertiesScan
// public class FaresApplication { ... }
@Component
class FareCalculator {
private final FareProperties props;
FareCalculator(FareProperties props) { this.props = props; }
BigDecimal fareFor(LocalTime tapIn, Duration sinceLastTap) {
if (sinceLastTap != null && sinceLastTap.compareTo(props.transferWindow()) <= 0) {
return BigDecimal.ZERO.setScale(2); // free transfer
}
boolean peak = !tapIn.isBefore(LocalTime.of(8, 0)) && tapIn.isBefore(LocalTime.of(10, 0));
BigDecimal fare = peak ? props.base().multiply(props.peakMultiplier()) : props.base();
return fare.setScale(2, RoundingMode.HALF_UP);
}
}I ran the same rule as plain Java with a base of 2.50, a multiplier of 1.5 and a 45-minute window. A tap at 08:30 priced 3.75, a tap at 13:00 priced 2.50, and a tap at 13:20 only 30 minutes after the last one priced 0.00. Those three numbers are the expected values in the test further down.
Profiles and where values come from
A profile is a named slice of configuration activated with spring.profiles.active. A single YAML file can hold several documents separated by ---, each guarded by spring.config.activate.on-profile. Never commit production secrets: reference environment variables with ${NAME} placeholders and let the platform inject them.
fare:
base: 2.50
peak-multiplier: 1.5
transfer-window: 45m
spring:
datasource:
url: jdbc:h2:mem:fares
---
spring:
config:
activate:
on-profile: prod
datasource:
url: ${FARES_DB_URL}
username: ${FARES_DB_USER}
password: ${FARES_DB_PASSWORD}
management:
endpoints:
web:
exposure:
include: health,infoPrecedence, simplified: command-line arguments beat OS environment variables, which beat profile-specific files, which beat the base application.yml, which beats defaults in code. So FARE_PEAK_MULTIPLIER=1.6 in the environment overrides the YAML without a rebuild; relaxed binding maps FARE_PEAK_MULTIPLIER to fare.peak-multiplier. Activate production with SPRING_PROFILES_ACTIVE=prod or --spring.profiles.active=prod.
Pick the smallest test that proves the point
@SpringBootTest starts the entire application context and is slow, so reserve it for a few end-to-end checks. Slice annotations start only part of it. The best test of all needs no Spring: business rules in FareCalculator are plain objects.
| Test style | Starts | Use it for |
|---|---|---|
| Plain JUnit | Nothing | Rules and calculations like fares |
@WebMvcTest(Controller.class) | MVC layer only, with MockMvc | Routing, JSON shape, validation, status codes |
@DataJpaTest | JPA and an embedded database only | Repository queries and mappings |
@SpringBootTest | The full context | A handful of wiring and end-to-end checks |
package com.example.fares;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalTime;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
class FareCalculatorTest {
private final FareCalculator calc = new FareCalculator(
new FareProperties(new BigDecimal("2.50"), new BigDecimal("1.5"), Duration.ofMinutes(45)));
@Test
void peakFareIsMultiplied() {
assertEquals(new BigDecimal("3.75"), calc.fareFor(LocalTime.of(8, 30), null));
}
@Test
void transferInsideWindowIsFree() {
assertEquals(new BigDecimal("0.00"), calc.fareFor(LocalTime.of(13, 20), Duration.ofMinutes(30)));
}
}
@WebMvcTest(FareController.class)
class FareControllerTest {
@Autowired MockMvc mvc;
@MockBean FareCalculator calculator; // see the note on @MockitoBean below
@Test
void returnsQuoteAsJson() throws Exception {
when(calculator.fareFor(LocalTime.of(8, 30), null)).thenReturn(new BigDecimal("3.75"));
mvc.perform(get("/api/fares").param("tapIn", "08:30"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.fare").value(3.75));
}
}
// The controller under test:
// @RestController
// class FareController {
// record FareQuote(BigDecimal fare) {}
// private final FareCalculator calculator;
// FareController(FareCalculator c) { this.calculator = c; }
// @GetMapping("/api/fares")
// FareQuote quote(@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.TIME) LocalTime tapIn) {
// return new FareQuote(calculator.fareFor(tapIn, null));
// }
// }A last note on test data. Slice tests start fast because they skip most of the context, but they still need the beans they exercise. If a @WebMvcTest fails with No qualifying bean of type ... available, the controller depends on something the slice did not load, which is your cue to mock it (as above) rather than to widen the test to @SpringBootTest. Treat that failure as design feedback: a controller that needs five collaborators to answer one request is probably doing too much.
Actuator: let the service report on itself
Add spring-boot-starter-actuator and a request to /actuator/health returns a small JSON status such as {"status":"UP"}. Container platforms poll this to decide whether to route traffic or restart the instance. Very few endpoints are exposed over HTTP by default on purpose: env, beans and heapdump reveal internals. Choose exposure deliberately with management.endpoints.web.exposure.include, move the management port off the public one with management.server.port and protect it at the network level or with Spring Security. You can add your own signal by implementing HealthIndicator.
package com.example.fares;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
@Component
class GateControllerHealth implements HealthIndicator {
private final GateClient gates; // your own client for the turnstile controller
GateControllerHealth(GateClient gates) { this.gates = gates; }
@Override
public Health health() {
return gates.isReachable()
? Health.up().build()
: Health.down().withDetail("reason", "turnstile controller unreachable").build();
}
}Common mistakes and the errors they cause
- Missing `@ConfigurationPropertiesScan` or `@EnableConfigurationProperties`. The properties record is never registered, so injecting it fails with
required a bean of type 'FareProperties' that could not be found. - Wrong property name or unparseable value.
fare.base=abcfails startup withFailed to bind properties under 'fare.base'and a conversion error, which is the point of typed binding. - Placing `@SpringBootTest` everywhere. The suite takes minutes and failures are hard to localise. Test rules without Spring and use slices for web and JPA.
- Forgetting the profile. The app silently runs with the H2 default in production because
SPRING_PROFILES_ACTIVEwas never set. Log the active profiles at startup. - Exposing every actuator endpoint publicly.
include: "*"on a public port leaks configuration and heap data. Exposehealthandinfoonly, or secure the rest.
How this shows up in real projects and interviews
Real teams run at least three environments, so profile-aware config is a daily concern, and "how do you handle secrets?" is a standard interview question (answer: environment variables or a secrets manager, never the repository). Expect to be asked which test annotation you would use for a repository or a controller and why slices exist. Health endpoints connect to deployment topics covered in the Docker and Kubernetes courses, where probes call exactly the endpoint you configured here.
Try it yourself
Add a dev profile that sets fare.peak-multiplier to 1.0 and a prod profile that leaves it at 1.5. Write a @SpringBootTest with @ActiveProfiles("dev") asserting the injected FareProperties has multiplier 1.0, then start the app under prod and fetch /actuator/health.
Show a hint
Put the dev override in an application-dev.yml file next to application.yml. BigDecimal comparison in tests is easiest with compareTo(...) == 0 rather than equals.
Key takeaways
- Bind related settings to a
@ConfigurationPropertiesrecord and add@Validatedso bad configuration fails fast at startup. - Profiles and environment variables change behaviour per environment without rebuilding; keep secrets out of the repository.
- Prefer plain unit tests for rules, then slice tests (
@WebMvcTest,@DataJpaTest), and keep@SpringBootTestfor a few end-to-end checks. - Actuator gives health and metrics endpoints, but expose only what you need and keep the management port off the public internet.
Test yourself
A trick from this lesson's ride: A settings class is a form with required fields: bad input is refused at the door. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
What is the difference between @Value and @ConfigurationProperties?
@Value injects a single property by key, while @ConfigurationProperties binds a whole prefix into a typed object with relaxed binding and optional validation. For more than one or two settings, @ConfigurationProperties is cleaner and easier to test.
How do I switch between dev and prod profiles in Spring Boot?
Set spring.profiles.active in a properties file, pass --spring.profiles.active=prod on the command line, or set the SPRING_PROFILES_ACTIVE environment variable. Profile-specific files such as application-prod.yml then override the base configuration.
What is the difference between @WebMvcTest and @SpringBootTest?
@WebMvcTest loads only the web layer (controllers, JSON conversion, validation) and expects you to mock collaborators, so it is fast. @SpringBootTest loads the whole application context, which is slower but tests real wiring end to end.