Spring 中的 @DynamicPropertySource 注解

1、概览

当代应用通常需要连接到各种外部服务,如 PostgreSQLApache KafkaCassandraRedis 和其他外部 API。

本文将带你了解 Spring 如何通过引入动态属性(@DynamicPropertySource)来帮助测试此类应用。

2、问题:动态属性

假设我们正在开发一个使用 PostgreSQL 作为数据库的应用。

创建 JPA 实体:

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

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

    private String title;

    private String content;

    // get、set 省略
}

我们需要编写测试来确保应用按预期运行,由于该测试需要与真实数据库创建连接,我们应该事先建立一个 PostgreSQL 实例。

在测试执行过程中,有不同的方法来设置此类基础工具。事实上,这类解决方案主要有三类:

  1. 专门为测试设置一个单独的数据库服务器
  2. 使用一些轻量级的、测试专用的替代品,如 H2
  3. 让测试本身管理数据库的生命周期

由于未区分测试环境和生产环境,与使用 H2 等测试替身相比,有更好的选择。第三个选项不仅可以与真实数据库一起使用,还可以为测试提供更好的隔离性。此外,借助 DockerTestcontainers 等技术,实现第三个选项非常容易。

如果使用 Testcontainers 等技术,测试流程如下:

  1. 在所有测试前设置 PostgreSQL 等组件。通常,这些组件会监听随机端口。
  2. 运行测试。
  3. 卸载组件。

如果 PostgreSQL 容器每次都监听随机端口,那么我们就应该以某种方式动态设置和更改 spring.datasource.url 配置属性。基本上,每个测试都应该有自己的配置属性版本。

当配置是静态的时候,可以使用 Spring Boot 的配置管理工具轻松地对其进行管理。但是,当我们面对的是动态配置时,同样的任务就会变得比较麻烦。

既然知道了问题所在,我们就来看看传统的解决方案。

3、传统解决方案

实现动态属性的第一种方法是使用自定义 ApplicationContextInitializer。先建立基础设施,然后使用第一步中的信息自定义 ApplicationContext

@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);
        }
    }

    // 省略 
}

来看看这个有点复杂的设置。首先,JUnit 创建并启动容器。容器准备就绪后,Spring 扩展调用 initializer(初始化器),将动态配置应用到 Spring Environment。显然,这种方法有点繁琐和复杂。

完成这些步骤后,才能编写测试:

@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...");
}

4、@DynamicPropertySource

Spring 5.2.5 引入了 @DynamicPropertySource 注解,以方便添加具有动态值的属性。我们所要做的就是创建一个静态方法,该方法使用 @DynamicPropertySource 注解,并且只有一个 DynamicPropertyRegistry 实例作为参数:

@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");
    }
    
    // 测试与以前相同
}

如上,在指定的 DynamicPropertyRegistry 上使用 add(String, Supplier<Object>) 方法向 Spring Environment 添加一些属性。与我们之前看到的初始化方法相比,这种方法要简洁得多。注意,注解为 @DynamicPropertySource 的方法必须声明为静态方法,并且只能接受一个 DynamicPropertyRegistry 类型的参数。

基本上,使用 @DynmicPropertySource 注解的主要动机是为了更方便地实现一些原本就可以实现的功能。虽然它最初是为了与 Testcontainers 配合使用而设计的,但我们可以在任何需要使用动态配置的地方使用它。

5、另一种选择:Test Fixtures(测试固件)

到目前为止,在这两种方法中,固件设置和测试代码都是紧密相连的。有时,两个关注点的这种紧密耦合会使测试代码变得复杂,尤其是当我们需要设置多个东西时。想象一下,如果我们在单个测试中同时使用 PostgreSQLApache Kafka,基础设施设置会是什么样子 ?

此外,基础设施设置和应用动态配置将在所有需要它们的测试中重复。

为了避免这些缺点,我们可以使用大多数测试框架提供的测试固件设施。例如,在 JUnit 5 中,我们可以定义一个扩展,在测试类中的所有测试之前启动 PostgreSQL 实例,配置 Spring Boot,并在运行测试后停止 PostgreSQL 实例:

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 处理容器关闭事宜
    }
}

在这里,我们通过实现 AfterAllCallbackBeforeAllCallback 来创建一个 JUnit 5 扩展。这样,JUnit 5 将在运行所有测试前执行 beforeAll() 逻辑,并在运行测试后执行 afterAll() 方法中的逻辑。采用这种方法,我们的测试代码将干净利落:

@SpringBootTest
@ExtendWith(PostgreSQLExtension.class)
@DirtiesContext
public class ArticleTestFixtureLiveTest {
    // 仅测试代码
}

在这里,我们还为测试类添加了 @DirtiesContext 注解。重要的是,这将重新创建 Application Context,并允许我们的测试类与运行在随机端口上的独立 PostgreSQL 实例交互。这样,我们的测试就可以在完全隔离的情况下,针对单独的数据库实例执行。

除了可读性更强之外,我们只需添加 @ExtendWith(PostgreSQLExtension.class) 注解,就能轻松重用相同的功能。而不必像其他两种方法那样,在需要的地方复制粘贴整个 PostgreSQL 设置。

6、总结

本文先介绍了对依赖于数据库的 Spring 组件进行测试有多麻烦,然后介绍了解决这种问题的几种方案。


Ref:https://www.baeldung.com/spring-dynamicpropertysource