Skip to main content
PProfileMatch

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

  1. Typed configuration instead of scattered strings
  2. Profiles and where values come from
  3. Pick the smallest test that proves the point
  4. Actuator: let the service report on itself
  5. Common mistakes and the errors they cause
  6. How this shows up in real projects and interviews

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.

FareProperties.java and FareCalculator.java
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.

src/main/resources/application.yml
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,info

Precedence, 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 styleStartsUse it for
Plain JUnitNothingRules and calculations like fares
@WebMvcTest(Controller.class)MVC layer only, with MockMvcRouting, JSON shape, validation, status codes
@DataJpaTestJPA and an embedded database onlyRepository queries and mappings
@SpringBootTestThe full contextA handful of wiring and end-to-end checks
FareCalculatorTest.java and FareControllerTest.java
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.

GateControllerHealth.java
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=abc fails startup with Failed 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_ACTIVE was never set. Log the active profiles at startup.
  • Exposing every actuator endpoint publicly. include: "*" on a public port leaks configuration and heap data. Expose health and info only, 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 @ConfigurationProperties record and add @Validated so 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 @SpringBootTest for 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.

Ride this lesson

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.