CodeGym /Kursy /Spring Test /@ConfigurationProperties in tests

@ConfigurationProperties in tests

Spring Test
Poziom 7, Lekcja 3
Dostępny

1. Configuration is code too

Up to this point, we have discussed where a property comes from at all: from application-test.yml, a local override, or runtime registration. But as soon as the number of keys grows beyond a couple, the question changes. It is no longer important only “what value is there”, but also “what does it bind to”, “what are its defaults”, and “when should the configuration fail immediately instead of quietly breaking later”.

When we say “application configuration”, it is very easy to picture a boring YAML file living somewhere nearby and somehow affecting behavior. But in reality, configuration is code, just not written in Java. And this “code” has an unpleasant trait: the compiler does not check it, the IDE does not suggest anything, and typos in keys behave like ninjas — quietly, suddenly, and at the worst possible moment.

A classic symptom: you have several settings for one subsystem, for example a moderation client. You start with a couple of @Value, then add a third, then a fourth. And now strings are floating around in the code such as "contenthub.moderation.base-url", "contenthub.moderation.timeout", "contenthub.moderation.retries", and someone else accidentally wrote "contenthub.moderation.baseUrl" (without the dash) and wonders why the value is not picked up. And the worst part: tests also start “tweaking” these strings, and after a couple of weeks you are no longer sure which key is set where, and why a particular test works at all.

To regain control, we want to achieve three things. First, we want the settings for one subsystem to be collected together, not scattered across different classes. Second, we want the values to be typed, because a timeout is not a string but a Duration, and a file size limit is not “5MB” but a DataSize. Third, we want to test not a “string by key”, but a whole configuration object, with defaults and validation.

2. @ConfigurationProperties as a contract

@ConfigurationProperties is a Spring Boot mechanism that takes a set of configuration keys with a common prefix and binds them to a Java class. That is, instead of reading each property through @Value, we describe the configuration structure once and then use it like a regular object. It is almost like a DTO, only not for an HTTP request, but for application settings.

It is convenient to keep this in mind: YAML is an external contract (like an API, only for configuration). @ConfigurationProperties is the Java model of that contract. And binding is the “parser” that turns keys and strings from Environment into fields, dates, numbers, Duration, DataSize, and so on. The point is that your business code and infrastructure code should no longer deal with “parsing” configuration — it receives a ready-made typed object.

It is very useful to visualize this path:

flowchart TD
    A["application.yml / application-test.yml"] --> B["Environment PropertySources"]
    B --> C["Binder (Spring Boot)"]
    C --> D["@ConfigurationProperties bean AttachmentProperties"]
    D --> E["Service / Adapter AttachmentValidationService"]

The key advantage for testing is that you can test layer D separately: “is it bound correctly”, “which defaults are applied”, “is the configuration validated” — without mixing this with HTTP, the database, and the rest of the application. At this point, configuration stops being “magic from YAML” and becomes an object you can touch with your hands.

3. Naming and relaxed binding

After the first encounter with @ConfigurationProperties, almost everyone does the same thing: looks at the YAML key max-count, then at the field maxCount — and thinks, “is this really the same thing, or am I accidentally doing sorcery right now?”. The good news: it is not sorcery; it is designed that way.

Spring Boot supports “relaxed binding”, which means it can match different naming variants. In config, kebab-case is often used because it is more readable in YAML: max-size, base-url, enabled. In Java, naturally, camelCase: maxSize, baseUrl, enabled. Boot knows how to bind these variants to each other.

Here is a tiny table so the brain stops panicking:

YAML key Field in Java Why this way
contenthub.attachments.max-count maxCount dash in YAML ↔ camelCase in Java
contenthub.moderation.base-url baseUrl “url” remains part of the name
contenthub.notifications.enabled enabled matches in meaning and form

At the same time, it is important not to overuse the “freedom” of relaxed binding. You can write MAX_COUNT, max_count, or max-count, and it may work — but it will hurt the reader (and your future self a month from now). So within the course, we keep discipline: in YAML — kebab-case, in Java — camelCase.

4. ContentHub example: configuration groups

When you design configuration, think not “which properties do I need”, but “which subsystems do I have”. In ContentHub this is especially visible because the project is intentionally production-like: there is file storage, attachment limits, a moderation client, and publication notifications. Each subsystem has its own settings, and it makes sense to keep them under their own prefixes.

Let the configuration contain these groups:

- contenthub.storage.* — everything related to the root directory for file storage.

- contenthub.attachments.* — limits for size/quantity/types of attachments.

- contenthub.moderation.* — moderation service address and timeouts.

- contenthub.notifications.* — whether notifications are enabled and how they work.

For attachments, this is a good place to pause. The basic size and count limits can live directly in AttachmentProperties, and YAML can be used only when they really need to be overridden per environment. So such a shared test snapshot can safely limit itself to storage, moderation, and notifications:

# src/test/resources/application-test.yml

contenthub:
  storage:
    # In tests, we store files in build/ so we do not touch the "real" directories
    root: "build/test-storage"
  moderation:
    # In the test environment, we often point to a local mock/stub
    base-url: "http://localhost:8089"
    # Duration: time units can also be read in a human-friendly way
    timeout: 250ms
  notifications:
    # For most tests, it is easier to disable notifications
    enabled: false

Pay attention to the human readability. Even without knowing the Java code, you can see that in tests we write files to build/test-storage, moderation “looks” at a local URL, and notifications are disabled. This is an important psychological effect: the test configuration reads like a story, not like a collection of random numbers.

With attachments, a different useful story emerges: if these keys are not present in the external configuration, the limits will come from the fields of AttachmentProperties, and that can be tested separately.

5. AttachmentProperties: defaults

@ConfigurationProperties is usually made as plain a class as possible. And that is a compliment. There should be no business logic, calculations, or “smart” methods here. This class is a container for settings. The simpler, the better: it must be understandable even on Monday morning, when coffee has not helped yet.

Let us start with attachment limits. We need the types to be correct: size is DataSize, quantity is int. And we want a safe baseline in the code: if the setting was not specified, we still do not want “unlimited”.

import jakarta.validation.constraints.Min;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.unit.DataSize;
import org.springframework.validation.annotation.Validated;

@ConfigurationProperties("contenthub.attachments")
@Validated // Enable Jakarta Bean Validation during binding
public class AttachmentProperties {

    // Default: a safe size limit if it was not set in YAML
    private DataSize maxSize = DataSize.ofMegabytes(5);

    // Default: a safe quantity limit
    @Min(1) // Configuration is also input data: forbid 0 and negative values
    private int maxCount = 3;

    // getters/setters
}

There are two important ideas here. The first is defaults right in the fields. This is convenient: the class has a “base configuration”, and the application does not fall apart if you forget a key in YAML. The second is validation through @Min(1) and @Validated. Because configuration is also input data, just not from the user, but from the environment. And if the environment sends garbage, we want to learn about it immediately, not after three hours of debugging.

And one more practical point that often catches beginners off guard: the @ConfigurationProperties class must become a Spring bean. That is, it needs to be registered. The simplest and most “course-friendly” path is to enable properties scanning in the application:

import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@SpringBootApplication
@ConfigurationPropertiesScan // Tell Spring Boot to find and register @ConfigurationProperties classes
public class ContentHubApplication {
}

If you forget @ConfigurationPropertiesScan, you will get a very honest error like “No qualifying bean of type AttachmentProperties”. This is not Spring being “capricious” — it is trying to tell you: “I do not know what this class is; I did not create it.”

6. Duration and DataSize

As soon as you start storing timeouts and sizes in configuration, the temptation appears to make them strings: "250ms", "5MB", "2s". And that... works, but only until manual parsing starts somewhere in the code. And manual parsing in 2026 is roughly like writing your own ArrayList: possible, but better not do it without a reason.

Spring Boot can convert values from properties into Duration and DataSize. That means two nice things. First: your code becomes type-safe, because you cannot accidentally add “5MB” and “250ms”. Second: YAML becomes human-readable, because you write units directly in the configuration.

Example for moderation settings:

import jakarta.validation.constraints.NotBlank;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

@ConfigurationProperties("contenthub.moderation")
@Validated // Validation at context startup
public class ModerationProperties {

    // Base URL for the moderation service. An empty value is treated as a configuration error.
    @NotBlank
    private String baseUrl = "http://localhost:8089";

    // Timeout for requests to the moderation service
    private Duration timeout = Duration.ofSeconds(2);

    // getters/setters
}

And the corresponding YAML part (in tests, for example):

contenthub:
  moderation:
    base-url: "http://localhost:8089"
    timeout: 250ms

It is important that values like 250ms, 2s, 1m read almost like human speech. And in tests, you will also compare not strings, but proper objects:

// In tests, compare the typed value, not the YAML string
Duration timeout = properties.getTimeout();
assertThat(timeout).isEqualTo(Duration.ofMillis(250));

There is another bonus: when you have a Duration type, the IDE suggests methods like toMillis(), and when you have a string, the IDE suggests only... sadness.

Validation of settings: @Validated and “fail fast”

Validation of configuration is about psychological comfort. You want the application to fail as close to the cause of the problem as possible. Not 20 minutes after startup, not in the middle of request processing, not “somewhere inside RestClient”. But right at startup, with a clear message: “bad configuration”.

The mechanism is simple: you put @Validated on the properties class and add Jakarta Validation annotations to the fields. Then, during binding, Spring Boot validates the object, and if constraints are violated, the context does not start.

For example, in the attachments settings we already put @Min(1) on maxCount. In the moderation settings we put @NotBlank on baseUrl. That means values like these are considered invalid:

contenthub:
  moderation:
    base-url: ""   # empty string -> fail on startup

It sounds “strict”, but it is the right kind of strictness. An external integration without a base URL is not “maybe we will figure it out later”, it is “the application literally does not know where to go”. And it is better to find out immediately.

The same applies to limits. Configuration like max-count: 0 is almost always a mistake. It may be an “intentional attempt to disable attachments”, but then it is better to make a separate enabled flag, rather than trying to express meaning through an illegal limit.

7. Testing @ConfigurationProperties binding

Tests for @ConfigurationProperties are nice to write because you check exactly one thing: “when I set these keys, do I get this object”. It is not a business logic test, not an HTTP test, not a repository test. It is a test of the configuration contract.

The most direct (and beginner-friendly) way is to start the context with @SpringBootTest, set properties via properties = { ... }, pick up the test profile with @ActiveProfiles("test"), and then simply inject the properties class.

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.util.unit.DataSize;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(properties = {
        // Override the needed keys directly in the test annotation
        "contenthub.attachments.max-size=10MB",
        "contenthub.attachments.max-count=5"
})
@ActiveProfiles("test")
class AttachmentPropertiesBindingTest {

    @Autowired
    AttachmentProperties properties; // Inject the already bound typed object

    @Test
    void bindsAttachmentLimits() {
        // Check that DataSize parsed correctly
        assertThat(properties.getMaxSize()).isEqualTo(DataSize.ofMegabytes(10));
        // Check that the numeric limit was applied too
        assertThat(properties.getMaxCount()).isEqualTo(5);
    }
}

Pay attention to a few disciplinary points. We set the keys in the form that the application actually reads (contenthub.attachments.*). We do not use @Value at all — we are testing the properties class itself as an object. And we compare types, not strings: DataSize.ofMegabytes(10) instead of "10MB".

If you want to check field defaults specifically, there is one important condition: contenthub.attachments.* must not be set in application.yml, nor in application-test.yml, nor in local overrides. Then the absence of values really means “we take what is set in the class itself”.

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.util.unit.DataSize;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest // No overrides are set — we see how field defaults work without external values for these keys
class AttachmentPropertiesDefaultsTest {

    @Autowired
    AttachmentProperties properties;

    @Test
    void usesDefaultsWhenNoOverridesProvided() {
        // Defaults protect against "unlimited" if the keys were forgotten in YAML
        assertThat(properties.getMaxSize()).isEqualTo(DataSize.ofMegabytes(5));
        assertThat(properties.getMaxCount()).isEqualTo(3);
    }
}

If the external YAML still sets the same keys, then such a test checks not the class defaults but the order of property sources — and that is a different task. And yes, this test depends on the fact that you actually registered AttachmentProperties as a bean and that the defaults are set in code. But that is exactly what we want to protect: “the class has a safe base configuration”.

8. Negative scenario: invalid config

Negative cases in @ConfigurationProperties are a bit tricky. If the configuration is invalid, the Spring context will not start — which means a regular @SpringBootTest class will not even reach the @Test method. So it is more convenient to test “the application should fail” like this: launch the application manually inside the test and verify that it fails in the expected way.

It looks roughly like this:

import org.junit.jupiter.api.Test;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;

import static org.assertj.core.api.Assertions.assertThatThrownBy;

class AttachmentPropertiesValidationFailureTest {

    @Test
    void failsFastWhenMaxCountIsInvalid() {
        assertThatThrownBy(() ->
                new SpringApplicationBuilder(ContentHubApplication.class)
                        // We do not need a web server: we are testing only context startup and binding
                        .web(WebApplicationType.NONE)
                        .properties(
                                // Activate the test profile and feed in a deliberately invalid limit
                                "spring.profiles.active=test",
                                "contenthub.attachments.max-count=0"
                        )
                        .run()
        )
        // For us, the stable signal matters: the context does not start because of invalid binding/validation.
        .hasMessageContaining("contenthub.attachments")
        .hasStackTraceContaining("max-count");
    }
}

The specific wrapper class in the exception chain may change between Boot and binder versions. Engineering-wise, something else matters more: the application fails immediately at startup, and from the stack trace it is clear that the problem is specifically in contenthub.attachments.max-count, not somewhere deep in the business code.

And one more practical point: WebApplicationType.NONE helps avoid starting the web server here. We are testing configuration, not HTTP. So there is no reason to make the test more expensive.

9. Typical mistakes when working with @ConfigurationProperties

Mistake #1: you wrote the properties class but forgot to register it.
The most common scenario: the class is marked with @ConfigurationProperties, fields are written, and then you try @Autowired AttachmentProperties — and get “No qualifying bean”. In your head it feels like “Spring does not like me”, but in reality it is just a fact: Spring does not create a bean it does not know about. Most often this is solved with @ConfigurationPropertiesScan on ContentHubApplication or with explicit @EnableConfigurationProperties in the configuration (but the second option is usually less convenient when there are many classes).

Mistake #2: you use String instead of Duration and DataSize, and then parse manually.
This usually starts with the thought “what is there to parse, just one string”. Then a second format appears (seconds vs milliseconds), then a third, then tests for the parser, then a bug “5mb was not recognized because someone wrote it in lowercase”. Spring Boot already knows how to do this, so it is better not to compete with it in the Olympics of “who will write a bicycle faster”.

Mistake #3: you mix several prefixes in one properties class.
For example, one class contains storage.root, moderation.base-url, and attachments.max-size. At first it seems convenient: “one class — all settings”. Then it turns out that this class is needed everywhere, tests pull it all over the place, and you end up with a configuration “God capsule”. One class — one prefix, one subsystem, one responsibility. This greatly simplifies both reading and testing.

Mistake #4: you forget to enable validation (@Validated) and expect constraint annotations to work by themselves.
Putting @Min(1) on a field and not putting @Validated on the class is like installing an alarm system but not inserting batteries. The constraint exists formally, but nobody runs it. As a result, the application starts with an incorrect configuration, and problems show up late and unpleasantly.

Mistake #5: you put defaults only in YAML and turn the code into an “empty shell”.
If defaults live only in YAML, then your Java class becomes not an independent model, but just a reflection of the file. This is not always bad, but it often reduces resilience: it is enough to forget a key in one environment — and the application changes behavior. Safe defaults for key limits and timeouts are usually better kept in code, while YAML is used for environment-specific overrides.

1
Zadanie
Spring Test, poziom 7, lekcja 3
Niedostępne
Wiązanie `AttachmentProperties`
Wiązanie `AttachmentProperties`
1
Zadanie
Spring Test, poziom 7, lekcja 3
Niedostępne
Wartości domyślne w `ModerationProperties`
Wartości domyślne w `ModerationProperties`
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION