1. 概述

现代应用几乎都离不开外部依赖:PostgreSQL、Kafka、Cassandra、Redis,或是各种第三方 API。如何在测试中真实模拟这些组件,是每个后端开发者都会踩的坑。

Spring Framework 5.2.5 引入了 @DynamicPropertySource 注解,让动态配置的管理变得简单粗暴。本文将带你从问题出发,对比传统方案,深入理解这个注解的用法,并探讨更优雅的替代方案。

✅ 你会学到:

  • 动态配置测试的痛点
  • @DynamicPropertySource 的正确打开方式
  • 如何用 JUnit 5 扩展实现更高复用性

2. 问题:动态配置的挑战

假设我们有一个使用 PostgreSQL 的典型应用,先看一个简单的 JPA 实体:

@Entity
@Table(name = "articles")
public class Article {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    private String title;

    private String content;

    // getters and setters
}

要测试这个实体的数据库行为,就得连真实数据库。常见的测试数据库方案有三种:

  • 独立测试数据库:专用服务器,配置固定,但环境差异大
  • ⚠️ H2 等内存数据库:启动快,但 SQL 兼容性差,容易踩坑
  • ✅✅ 测试容器化数据库:用 Testcontainers 启动真实 PostgreSQL,端口随机,最贴近生产

我们推荐第三种。但问题来了:如果数据库每次都在随机端口启动,那 spring.datasource.url 怎么配?

传统的 application.yml@TestPropertySource 都是静态的,无法应对这种动态场景。

这就是 @DynamicPropertySource 要解决的核心问题。


3. 传统方案:ApplicationContextInitializer

@DynamicPropertySource 出现之前,主流做法是自定义 ApplicationContextInitializer

@SpringBootTest
@Testcontainers
@ContextConfiguration(initializers = ArticleTraditionalLiveTest.EnvInitializer.class)
class ArticleTraditionalLiveTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:11")
      .withDatabaseName("prop")
      .withUsername("postgres")
      .withPassword("pass")
      .withExposedPorts(5432);

    static class EnvInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

        @Override
        public void initialize(ConfigurableApplicationContext applicationContext) {
            TestPropertyValues.of(
              String.format("spring.datasource.url=jdbc:postgresql://localhost:%d/prop", postgres.getFirstMappedPort()),
              "spring.datasource.username=postgres",
              "spring.datasource.password=pass"
            ).applyTo(applicationContext);
        }
    }

    // 测试逻辑
}

然后写测试:

@Autowired
private ArticleRepository articleRepository;

@Test
void givenAnArticle_whenPersisted_thenShouldBeAbleToReadIt() {
    Article article = new Article();
    article.setTitle("A Guide to @DynamicPropertySource in Spring");
    article.setContent("Today's applications...");

    articleRepository.save(article);

    Article persisted = articleRepository.findAll().get(0);
    assertThat(persisted.getId()).isNotNull();
    assertThat(persisted.getTitle()).isEqualTo("A Guide to @DynamicPropertySource in Spring");
    assertThat(persisted.getContent()).isEqualTo("Today's applications...");
}

⚠️ 这种方式虽然能用,但太啰嗦:

  • 需要额外的 initializer
  • 配置分散,可读性差
  • 复用成本高,每个测试都要复制粘贴

4. 新方案:@DynamicPropertySource

Spring 5.2.5 推出的 @DynamicPropertySource 就是来简化这件事的:

@SpringBootTest
@Testcontainers
public class ArticleLiveTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:11")
      .withDatabaseName("prop")
      .withUsername("postgres")
      .withPassword("pass")
      .withExposedPorts(5432);

    @DynamicPropertySource
    static void registerPgProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", 
          () -> String.format("jdbc:postgresql://localhost:%d/prop", postgres.getFirstMappedPort()));
        registry.add("spring.datasource.username", () -> "postgres");
        registry.add("spring.datasource.password", () -> "pass");
    }
    
    // 测试逻辑同上
}

✅ 核心优势:

  • 无需 initializer,代码集中
  • 使用 Supplier 延迟计算,性能更好
  • 语法简洁,一目了然

⚠️ 注意事项:

  • 方法必须是 static
  • 参数只能有一个:DynamicPropertyRegistry
  • 执行时机:在 @TestConstructor 之后,@BeforeEach 之前

虽然它最初为 Testcontainers 设计,但任何需要动态配置的场景都能用,比如动态 Kafka 地址、Redis 端口等。


5. 更优解:测试固件(Test Fixtures)

上面两种方式都有个问题:测试类和基础设施耦合太紧

如果测试同时依赖 PostgreSQL 和 Kafka,代码会迅速膨胀:

@DynamicPropertySource
static void registerProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", () -> "jdbc:...");
    registry.add("kafka.bootstrap-servers", () -> "localhost:" + kafka.getBootstrapServers());
    // ...更多配置
}

更好的做法是:把基础设施封装成可复用的测试扩展

示例:JUnit 5 扩展

public class PostgreSQLExtension implements BeforeAllCallback, AfterAllCallback {

    private PostgreSQLContainer<?> postgres;

    @Override
    public void beforeAll(ExtensionContext context) {
        postgres = new PostgreSQLContainer<>("postgres:11")
          .withDatabaseName("prop")
          .withUsername("postgres")
          .withPassword("pass")
          .withExposedPorts(5432);

        postgres.start();
        String jdbcUrl = String.format("jdbc:postgresql://localhost:%d/prop", postgres.getFirstMappedPort());
        System.setProperty("spring.datasource.url", jdbcUrl);
        System.setProperty("spring.datasource.username", "postgres");
        System.setProperty("spring.datasource.password", "pass");
    }

    @Override
    public void afterAll(ExtensionContext context) {
        // Testcontainers 会自动清理
    }
}

使用时只需一行注解:

@SpringBootTest
@ExtendWith(PostgreSQLExtension.class)
@DirtiesContext
public class ArticleTestFixtureLiveTest {
    // 只写测试逻辑,干净清爽
}

✅ 优势:

  • 配置完全解耦
  • 高度可复用,跨项目通用
  • 支持组合扩展(如同时加 KafkaExtension)
  • @DirtiesContext 确保每个测试用独立上下文,避免数据污染

6. 总结

方案 优点 缺点 推荐场景
ApplicationContextInitializer 兼容老版本 冗长,难复用 维护老项目
@DynamicPropertySource 简洁,原生支持 仍耦合在测试类 单组件测试
Test Fixtures 高内聚,易复用 需额外封装 中大型项目

✅ 推荐策略:

  • 小项目、快速验证 → 用 @DynamicPropertySource
  • 中大型项目 → 封装 JUnit 扩展,统一管理测试依赖

所有示例代码已托管至 GitHub:https://github.com/baeldung/spring-testing-2


原始标题:Guide to @DynamicPropertySource in Spring | Baeldung