Spring Boot 项目中,配置来源过多、测试环境互相污染,常常会导致本地通过而 CI 失败。本文以一个通知客户端为例,梳理配置设计、属性覆盖、Profile 隔离、动态配置和测试陷阱。
问题背景:配置不是写进 yml 就结束了
Spring Boot 项目运行一段时间后,配置通常会分散在多个地方:application.yml、Profile 配置文件、环境变量、启动参数、容器编排文件,以及测试类上的注解。它们各自都合理,但组合起来就容易出现一种很难排查的问题:
- 本地测试通过,CI 测试失败;
- 修改了
application-test.yml,测试结果却没有变化; - 某个测试类单独执行正常,和其他测试一起执行就失败;
- 开发环境误用了生产地址,或者集成测试真的访问了外部服务;
@Value读取到的值和配置类中的值不一致。
这些问题通常不是 Spring Boot “随机”加载配置,而是项目没有明确配置边界,也没有在测试中明确声明配置来源。
下面用一个通知客户端作为例子。业务代码需要读取通知服务的地址、请求超时时间和是否启用通知。
先把配置设计成一个明确的边界
不建议在业务类中到处写字符串形式的 @Value:
@Value("${notify.url}")
private String url;
@Value("${notify.timeout-ms:1000}")
private int timeout;这种写法的问题是配置键散落在代码中,默认值、校验规则和使用位置彼此分离。更稳妥的做法是使用 @ConfigurationProperties,让配置成为一个独立的对象。
package com.example.demo.config;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
@Validated
@ConfigurationProperties(prefix = "notify")
public class NotifyProperties {
@NotBlank
private String baseUrl;
@Min(100)
private int timeoutMs = 2000;
private boolean enabled = true;
public String getBaseUrl() {
return baseUrl;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public int getTimeoutMs() {
return timeoutMs;
}
public void setTimeoutMs(int timeoutMs) {
this.timeoutMs = timeoutMs;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}在启动类或配置类上注册它:
@SpringBootApplication
@EnableConfigurationProperties(NotifyProperties.class)
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}配置文件保持简单:
# application.yml
notify:
base-url: https://notify.example.com
timeout-ms: 2000
enabled: true业务组件只依赖 NotifyProperties,不关心配置来自文件、环境变量还是测试注入:
@Component
public class NotifyClient {
private final NotifyProperties properties;
public NotifyClient(NotifyProperties properties) {
this.properties = properties;
}
public boolean isAvailable() {
return properties.isEnabled()
&& properties.getBaseUrl() != null
&& !properties.getBaseUrl().isBlank();
}
public int timeoutMs() {
return properties.getTimeoutMs();
}
}这样做的价值不只是代码更整齐。配置校验会在应用启动阶段失败,而不是等到某个请求第一次执行时才暴露问题。
配置覆盖的核心:不要只记顺序,要明确测试意图
Spring Boot 会合并多个属性源。实际项目中常见的覆盖来源包括:
- 默认配置文件,例如
application.yml; - 当前 Profile 对应的配置,例如
application-test.yml; - 操作系统环境变量和容器注入的变量;
- 启动命令参数,例如
--notify.timeout-ms=500; - 测试类通过
@TestPropertySource、@SpringBootTest(properties = ...)或@DynamicPropertySource添加的属性。
具体优先级会受到 Spring Boot 版本、测试注解和启动方式影响。工程上更重要的原则是:测试不要依赖“猜出来的优先级”,而要在测试代码中明确写出它需要的配置。
例如,一个只验证配置绑定的测试,可以这样写:
@SpringBootTest(
classes = DemoApplication.class,
properties = {
"notify.base-url=http://localhost:18080",
"notify.timeout-ms=800",
"notify.enabled=false"
}
)
class NotifyPropertiesTest {
@Autowired
private NotifyProperties properties;
@Test
void shouldBindTestProperties() {
assertThat(properties.getBaseUrl())
.isEqualTo("http://localhost:18080");
assertThat(properties.getTimeoutMs()).isEqualTo(800);
assertThat(properties.isEnabled()).isFalse();
}
}这种写法适合少量、临时的属性覆盖。如果一组测试共享一套稳定配置,可以使用 src/test/resources/application-test.yml:
notify:
base-url: http://localhost:18080
timeout-ms: 500
enabled: false然后显式启用 Profile:
@SpringBootTest(classes = DemoApplication.class)
@ActiveProfiles("test")
class NotifyClientTest {
@Autowired
private NotifyClient client;
@Test
void testProfileShouldDisableExternalNotification() {
assertThat(client.isAvailable()).isFalse();
assertThat(client.timeoutMs()).isEqualTo(500);
}
}注意,application-test.yml 并不会因为文件名中出现了 test 就自动生效。必须有 spring.profiles.active=test,或者在测试类上使用 @ActiveProfiles("test")。这是最常见的配置测试陷阱之一。
外部资源配置应使用动态注入
如果测试依赖数据库、Redis 或临时 HTTP 服务,端口往往是运行时分配的。这时不应该把端口写死在 application-test.yml 中,而应使用 @DynamicPropertySource 将资源实际地址注入 Spring 环境。
@SpringBootTest
class NotifyHttpIntegrationTest {
private static final MockWebServer SERVER = new MockWebServer();
@BeforeAll
static void startServer() throws IOException {
SERVER.start();
}
@AfterAll
static void stopServer() throws IOException {
SERVER.shutdown();
}
@DynamicPropertySource
static void registerProperties(DynamicPropertyRegistry registry) {
registry.add("notify.base-url", () -> SERVER.url("/").toString());
registry.add("notify.timeout-ms", () -> 1000);
registry.add("notify.enabled", () -> true);
}
@Test
void shouldUseTheRuntimeServerAddress() {
// 调用 NotifyClient 或业务服务,并断言 MockWebServer 收到请求
}
}这里的关键不是注解本身,而是配置来源和资源生命周期保持一致:服务启动后再获取地址,测试结束后关闭资源。若使用 Testcontainers,也应采用同样思路,把容器暴露出来的端口通过动态属性注册,而不是复制到固定配置文件中。
常见陷阱:为什么测试会互相影响
1. 把集成测试和单元测试混在一起
@SpringBootTest 会加载完整应用上下文,速度较慢,而且容易触发数据库连接、消息消费者和定时任务。纯粹测试一个配置类或业务类时,不必启动整个 Spring 容器,可以直接构造对象,或者使用更窄的测试切片。
2. 测试类之间共享了可变外部状态
数据库表没有清理、Mock HTTP 服务没有关闭、系统属性被修改后没有恢复,都会造成“单独运行正常,批量运行失败”。测试应该尽量自包含,资源创建和销毁放在同一测试类或统一的生命周期管理中。
3. 使用 @TestPropertySource 却忘记它只覆盖指定内容
测试属性文件不是完整替代生产配置,而是对环境进行补充或覆盖。若测试依赖某个默认值,最好直接在测试配置中写出,不要假设未来默认配置永远不变。
4. 配置键重命名没有同步检查
notify.base-url 和 notify.url 是两个不同的属性。配置类改名后,旧配置可能静默失效,直到校验或业务运行时才发现。配置对象应配合启动校验,并在代码评审中把配置键变更当作接口变更处理。
5. 为了让测试通过而关闭校验
在测试环境中把必填地址改成空字符串、把超时设为负数,再通过默认值掩盖问题,短期看似方便,长期会让测试失去价值。测试可以替换真实地址,但不应破坏生产配置的基本约束。
一套更稳定的实践建议
第一,按职责集中配置。一个外部依赖对应一个属性类,避免多个组件分别读取同一组配置。
第二,区分配置类型。业务开关、连接地址、超时时间和密钥的管理方式不同,不要把所有内容都堆在一个巨大配置类里。敏感信息不应提交到仓库,应由部署环境注入。
第三,测试中显式声明环境。需要 Profile 就使用 @ActiveProfiles,需要临时覆盖就使用测试属性,需要动态端口就使用 @DynamicPropertySource。
第四,验证“最终生效值”,而不是只检查某个文件。配置文件是输入,绑定后的属性对象才是应用真正使用的结果。
第五,减少完整上下文测试数量。启动边界、配置绑定和少量关键集成链路需要完整测试,普通业务规则则应优先使用快速单元测试。
总结
Spring Boot 的配置问题,表面上是某个键没有生效,实质上通常是配置边界和测试边界没有被设计清楚。
将配置集中到类型安全的属性对象中,用启动校验保证基本约束;在测试中明确使用 Profile、测试属性或动态属性;对外部资源实行创建、注入、销毁的完整生命周期管理。这样做之后,配置来源会更容易追踪,测试也不再依赖本地机器上的残留环境。
稳定的 Spring Boot 项目,并不是配置文件越少越好,而是每个配置都有清晰的来源、作用范围和验证方式。