从其测试代码中覆盖 Quarkus 应用程序的配置

为了实现良好的测试覆盖率,通常需要从测试代码中覆盖 Quarkus 应用的配置。每当一个配置属性决定应用程序的行为方式时,都需要测试所有可能的配置值。

所有分支都需要测试
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

@ApplicationScoped
public class MyService {

    @Inject
    MyConfig config;

    public void doSomething() {
        if (config.newFeatureEnabled()) {
            // This branch needs to be tested.
        } else {
            // So does that branch.
        }
    }
}

@ConfigMapping(prefix = "my-config")
interface MyConfig { (1)

    @WithDefault("false")
    boolean newFeatureEnabled();
}
1 在实际项目中,此接口很可能为 public 并在单独的文件中声明。

有很多方法可以从测试代码中覆盖配置。本文将向您展示五种方法,并重点介绍每种方法的优点和缺点。

本文中的所有代码片段(以及更多!)都可以在 gwenneg/blog-overriding-configuration-from-test-code 存储库中找到。

方法 #1:Quarkus 测试配置文件

Quarkus 测试配置文件是覆盖配置的最佳方法之一。它们可以在本机模式下进行测试时使用,而本文中的大多数方法则不能。除了配置覆盖之外,它们还提供了许多其他功能,可以更轻松地测试 Quarkus 应用。

从配置覆盖的角度来看,测试配置文件确实存在一些缺点。首先,Quarkus 在使用每个测试配置文件之前都会重新启动,这显然会减慢测试执行速度。为了覆盖同一配置属性的多个值,测试还必须分为多个测试配置文件和类。因此,更大的项目可能会最终拥有大量测试配置文件,并在测试之间花费大量时间重新启动 Quarkus。使用测试配置文件维护或审查测试代码也可能更具挑战性。

待测试的代码
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import org.eclipse.microprofile.config.inject.ConfigProperty;

@Path("/features")
public class FeaturesResource {

    @Inject
    FeaturesConfig featuresConfig; (1)

    @ConfigProperty(name = "amazing-feature-enabled", defaultValue = "false") (1)
    boolean amazingFeatureEnabled;

    @GET
    @Path("/awesome")
    public boolean isAwesomeFeatureEnabled() {
        return featuresConfig.awesomeFeatureEnabled();
    }

    @GET
    @Path("/amazing")
    public boolean isAmazingFeatureEnabled() {
        return amazingFeatureEnabled;
    }
}

@ConfigMapping(prefix = "features")
interface FeaturesConfig { (2)

    @WithDefault("false")
    boolean awesomeFeatureEnabled();
}
1 测试配置文件既支持配置映射,也支持 @ConfigProperty
2 在实际项目中,此接口很可能为 public 并在单独的文件中声明。

大多数关于测试配置文件的指南都会以冗长的方式介绍它们,以展示它们的所有功能。实际上,只需添加几行额外代码,就可以将测试配置文件添加到现有的测试类中

也是测试配置文件的测试类
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import io.restassured.RestAssured;
import java.util.Map;
import org.hamcrest.CoreMatchers;
import org.junit.jupiter.api.Test;

@QuarkusTest
@TestProfile(FeaturesResourceTest.class)
public class FeaturesResourceTest implements QuarkusTestProfile { (1)

    @Override
    public Map<String, String> getConfigOverrides() { (2)
        return Map.of(
            "features.awesome-feature-enabled", "true", (3)
            "amazing-feature-enabled", "true"
        );
    }

    @Test
    void test() {

        RestAssured.given()
            .when().get("/features/awesome")
            .then().body(CoreMatchers.is("true"));

        RestAssured.given()
            .when().get("/features/amazing")
            .then().body(CoreMatchers.is("true"));
    }
}
1 如果配置文件不与多个测试类共享,测试类本身可以实现 QuarkusTestProfile。这可以使测试代码的维护和审查更容易。如果多个测试类依赖于同一配置文件,那么该配置文件很可能需要在专用类中声明。
2 此方法来自 QuarkusTestProfile,可以从测试代码中覆盖配置。
3 FeaturesConfig 接口生成的配置键以 features. 为前缀,而来自 @ConfigProperty 注入的配置键没有前缀。

测试配置文件还可以利用配置文件感知文件从测试代码中覆盖配置

application-blog.properties
features.awesome-feature-enabled=true

使用时,测试配置文件需要覆盖默认配置配置文件

测试代码
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import io.restassured.RestAssured;
import org.hamcrest.CoreMatchers;
import org.junit.jupiter.api.Test;

@QuarkusTest
@TestProfile(FeaturesResourceTest.class)
public class FeaturesResourceTest implements QuarkusTestProfile {

    @Override
    public String getConfigProfile() { (1)
        return "blog"; (2)
    }

    @Test
    void test() {
        RestAssured.given()
            .when().get("/features/awesome")
            .then().body(CoreMatchers.is("true"));
    }
}
1 此方法来自 QuarkusTestProfile,可以覆盖默认配置配置文件。
2 application-blog.properties 文件将被加载,因为 blog 配置配置文件是活动的。

如果测试仅在 JVM 模式下运行而不是在本机模式下运行,则 application-blog.properties 文件可以放在 src/test/resources 文件夹中。还需要在同一位置放置一个额外的 application.properties 文件(可能为空)以启用配置文件感知文件。

如果测试在本机模式下运行,也需要相同的 application-blog.propertiesapplication.properties 文件,但它们必须放在 src/main/resources 文件夹中。application.properties 文件还需要包含以下行

application.properties
quarkus.native.resources.includes=application*.properties

方法 #2:使用 Mockito 模拟配置

现在,这是我不要求本机测试时的首选方法。

首先,让我们看看它如何与配置映射配合使用

待测试的代码
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;

@Path("/features")
public class FeaturesResource {

    @Inject
    FeaturesConfig featuresConfig;

    @GET
    @Path("/awesome")
    public boolean isAwesomeFeatureEnabled() {
        return featuresConfig.awesomeFeatureEnabled();
    }
}

@ConfigMapping(prefix = "features")
interface FeaturesConfig { (1)

    @WithDefault("false")
    boolean awesomeFeatureEnabled();
}
1 在实际项目中,此接口很可能为 public 并在单独的文件中声明。
测试代码
import io.quarkus.test.InjectMock;
import io.quarkus.test.Mock;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import io.smallrye.config.SmallRyeConfig;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.Config;
import org.hamcrest.CoreMatchers;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

@QuarkusTest
public class FeaturesResourceTest {

    @Inject
    SmallRyeConfig smallRyeConfig;

    @Produces (1)
    @ApplicationScoped
    @Mock
    FeaturesConfig featuresConfig() { (2)
        return smallRyeConfig.getConfigMapping(FeaturesConfig.class);
    }

    @InjectMock (3)
    FeaturesConfig featuresConfig;

    @Test
    void test() {
        Mockito.when(featuresConfig.awesomeFeatureEnabled()).thenReturn(true); (4)
        RestAssured.given()
            .when().get("/features/awesome")
            .then().body(CoreMatchers.is("true"));
    }
}
1 可以省略此注解。
2 这是使 FeaturesConfig 接口实现可代理所必需的。没有它,就无法使用 @InjectMock 来模拟它。
3 借助 quarkus-junit5-mockito 扩展,可以模拟配置类。本机模式下的测试不支持注入,因此这仅在 JVM 模式下运行测试时才有效。
4 配置可以从测试方法或使用 JUnit 的生命周期注解(如 @BeforeEach)进行注解的方法进行模拟。

如果您的项目依赖于 @ConfigProperty 而不是 @ConfigMapping 怎么办?这也可以!您只需将配置属性移到一个额外的 @ApplicationScoped bean 中。该 bean 可能用于集中 Quarkus 应用的所有配置属性,也可能不用于。

集中的配置类,在应用程序启动时进行日志记录
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.enterprise.event.Startup;
import java.util.Map;
import java.util.TreeMap;
import org.eclipse.microprofile.config.inject.ConfigProperty;

@ApplicationScoped
public class FeaturesConfig {

    private static final String AWESOME_FEATURE_ENABLED = "awesome-feature-enabled";

    @ConfigProperty(name = AWESOME_FEATURE_ENABLED, defaultValue = "false")
    boolean awesomeFeatureEnabled;

    // Omitted: additional config properties.

    public boolean isAwesomeFeatureEnabled() {
        return awesomeFeatureEnabled;
    }

    // This is an optional bonus unrelated to the blog post topic.
    void logConfigAtStartup(@Observes Startup event) { (1)

        Map<String, Object> config = new TreeMap<>(); (2)
        config.put(AWESOME_FEATURE_ENABLED, awesomeFeatureEnabled);
        // Omitted: put all config keys and values into the map.

        Log.info("=== Startup configuration ===");
        config.forEach((key, value) -> {
            Log.infof("%s=%s", key, value); (3)
        });
    }
}
1 此方法在应用程序启动时执行。有关应用程序生命周期事件的更多详细信息,请参阅应用程序初始化和终止指南。
2 TreeMap 有助于按键字母顺序自动排序 Map 条目。
3 应用程序配置在启动时进行日志记录。如果您需要根据过去的日志来调查问题,这确实很有帮助。但是,请注意不要记录任何敏感的配置值!(例如,秘密或密码)
待测试的代码
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;

@Path("/features")
public class FeaturesResource {

    @Inject
    FeaturesConfig featuresConfig;

    @GET
    @Path("/awesome")
    public boolean isAwesomeFeatureEnabled() {
        return featuresConfig.isAwesomeFeatureEnabled();
    }
}
测试代码
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.hamcrest.CoreMatchers;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

@QuarkusTest
public class FeaturesResourceTest {

    @InjectMock (1)
    FeaturesConfig featuresConfig;

    @Test
    void test() {
        Mockito.when(featuresConfig.isAwesomeFeatureEnabled()).thenReturn(true); (2)
        RestAssured.given()
            .when().get("/features/awesome")
            .then().body(CoreMatchers.is("true"));
    }
}
1 借助 quarkus-junit5-mockito 扩展,可以模拟配置类。本机模式下的测试不支持注入,因此这仅在 JVM 模式下运行测试时才有效。
2 配置可以从测试方法或使用 JUnit 的生命周期注解(如 @BeforeEach)进行注解的方法进行模拟。

此方法还可以利用 JUnit 的 @ParameterizedTest 功能,并使用单个测试方法测试配置属性的多个值

基于 @ParameterizedTest 的测试代码
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.hamcrest.CoreMatchers;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mockito;

@QuarkusTest
public class FeaturesResourceTest {

    @InjectMock
    FeaturesConfig featuresConfig;

    @ParameterizedTest
    @ValueSource(booleans = {true, false})
    void test(boolean awesomeFeatureEnabled) { (1)
        Mockito.when(featuresConfig.isAwesomeFeatureEnabled()).thenReturn(awesomeFeatureEnabled);
        RestAssured.given()
            .when().get("/features/awesome")
            .then().body(CoreMatchers.is(String.valueOf(awesomeFeatureEnabled)));
    }
}
1 运行测试时,此方法将针对 @ValueSource 注解提供的每个值调用一次。

方法 #3:构造函数注入

如果您需要在大型项目中进行本机测试,而该项目存在本文前面提到的 Quarkus 测试配置文件的缺点?通过 CDI bean 的构造函数注入可能是适合您的方法。

待测试的代码
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import jakarta.inject.Singleton;
import org.eclipse.microprofile.config.inject.ConfigProperty;

@Singleton
public class FeaturesService {

    private final FeaturesConfig featuresConfig;
    private final boolean amazingFeatureEnabled;

    public FeaturesService( (1)
        FeaturesConfig featuresConfig,
        @ConfigProperty(name = "amazing-feature-enabled", defaultValue = "false") boolean amazingFeatureEnabled
    ) {
        this.featuresConfig = featuresConfig;
        this.amazingFeatureEnabled = amazingFeatureEnabled;
    }

    public boolean isAwesomeFeatureEnabled() {
        return featuresConfig.awesomeFeatureEnabled();
    }

    public boolean isAmazingFeatureEnabled() {
        return amazingFeatureEnabled;
    }
}

@ConfigMapping(prefix = "features")
interface FeaturesConfig { (2)

    @WithDefault("false")
    boolean awesomeFeatureEnabled();
}
1 配置被注入到 CDI bean 的构造函数中。此方法支持配置映射@ConfigProperty
2 在实际项目中,此接口很可能为 public 并在单独的文件中声明。
测试代码
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

@QuarkusTest
public class FeaturesServiceTest {

    @Test
    void test() {

        FeaturesConfig featuresConfig = new FeaturesConfig() { (1)
            @Override
            public boolean awesomeFeatureEnabled() {
                return true;
            }
        };
        FeaturesService featuresService = new FeaturesService(featuresConfig, true); (2)

        Assertions.assertTrue(featuresService.isAwesomeFeatureEnabled());
        Assertions.assertTrue(featuresService.isAmazingFeatureEnabled());
    }
}
1 这用于从 FeaturesConfig 接口覆盖配置。
2 当 bean 构造函数被调用时,配置会从测试中被覆盖。第一个参数覆盖依赖于 @ConfigMapping 的配置。第二个参数覆盖依赖于 @ConfigProperty 的配置。

通过这种方法,在运行测试时 CDI 不会执行任何注入,因为 bean 是手动实例化的,而不是由 Quarkus 的 CDI 容器管理的。可以通过在被测试 bean 的构造函数中注入所有依赖项(其他 bean 和/或配置)来缓解此缺点。完成此操作后,CDI 注入仍然无法正常工作,但测试代码将能够提供测试执行所需的所有依赖项。

方法 #4:测试组件

Quarkus 最近推出了一项实验性功能,称为测试组件,可用于从测试代码中覆盖配置。该功能由 quarkus-junit5-component 扩展提供。

此方法不会启动完整的 Quarkus 应用。它仅启动 CDI 容器并注入用 @jakarta.inject.Inject@io.quarkus.test.InjectMock 注解的测试字段。因此,与Quarkus 测试配置文件中的完整 Quarkus 应用重启相比,它可能更快,尤其是在大型项目中。

此方法不适用于本机测试,因为它依赖于测试代码中的注入,而这些注入仅在 JVM 模式下运行测试时才受支持。

让我们看看它是如何工作的

待测试的代码
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;

@ApplicationScoped
public class FeaturesService {

    @Inject
    FeaturesConfig featuresConfig; (1)

    @ConfigProperty(name = "amazing-feature-enabled", defaultValue = "false") (1)
    boolean amazingFeatureEnabled;

    public boolean isAwesomeFeatureEnabled() {
        return featuresConfig.awesomeFeatureEnabled();
    }

    public boolean isAmazingFeatureEnabled() {
        return amazingFeatureEnabled;
    }
}

@ConfigMapping(prefix = "features")
interface FeaturesConfig { (2)

    @WithDefault("false")
    boolean awesomeFeatureEnabled();
}
1 测试组件支持配置映射@ConfigProperty
2 在实际项目中,此接口很可能为 public 并在单独的文件中声明。
测试代码
import io.quarkus.test.component.QuarkusComponentTest;
import io.quarkus.test.component.TestConfigProperty;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

@QuarkusComponentTest (1)
@TestConfigProperty(key = "features.awesome-feature-enabled", value = "true") (2)
public class FeaturesServiceTest {

    @Inject
    FeaturesService featuresService;

    @Test
    @TestConfigProperty(key = "amazing-feature-enabled", value = "true") (2)
    void test() {
        Assertions.assertTrue(featuresService.isAwesomeFeatureEnabled());
        Assertions.assertTrue(featuresService.isAmazingFeatureEnabled());
    }
}
1 普通的 @QuarkusTest 注解已被 @QuarkusComponentTest 取代。
2 @TestConfigProperty 可以用于测试类、测试方法或两者兼有。

方法 #5:系统属性

我绝对不推荐这种方法,但它确实存在并且也能奏效,所以我还是提一下。系统属性可用于从测试代码中覆盖配置。但是,此方法存在重大缺点

  • 它在原生模式下不起作用。

  • 它不适用于配置映射

  • 当配置定义在 @ApplicationScoped@Singleton bean 中,并在该 bean 初始化之前设置时,它只能生效一次。在 bean 初始化之后,对系统属性所做的任何更改都不会影响配置。

待测试的代码
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import org.eclipse.microprofile.config.inject.ConfigProperty;

@Path("/features")
public class FeaturesResource {

    @ConfigProperty(name = "awesome-feature-enabled", defaultValue = "false")
    boolean awesomeFeatureEnabled;

    @GET
    @Path("/awesome")
    public boolean isAwesomeFeatureEnabled() {
        return awesomeFeatureEnabled;
    }
}

可以使用 Maven 或 Gradle 从命令行设置系统属性

Maven 命令
./mvnw verify -Dawesome-feature-enabled=true

也可以从测试代码中设置它们

测试代码
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.hamcrest.CoreMatchers;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;

@QuarkusTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class) (1)
class FeaturesResourceTest {

    @Test
    @Order(1) (2)
    void firstTest() {
        System.setProperty("awesome-feature-enabled", "true");
        RestAssured.given()
            .when().get("/features/awesome")
            .then().body(CoreMatchers.is("true"));
    }

    @Test
    @Order(2) (3)
    void lastTest() {
        System.setProperty("awesome-feature-enabled", "false");
        RestAssured.given()
            .when().get("/features/awesome")
            .then().body(CoreMatchers.is("true")); (4)
    }
}
1 在此代码片段中,测试按固定顺序运行以演示系统属性的局限性。
2 此测试始终第一个运行。
3 此测试始终最后一个运行。
4 此测试依赖于具有默认 @Singleton 作用域的 CDI bean,该 bean 已由前一个测试初始化。因此,此测试的结果无法通过系统属性进行更改。

结论

首先,本文并非从测试代码中覆盖配置的所有现有方法的详尽列表。还有其他选项,例如使用反射(难以维护),我没有包含在内,可能还有我甚至不知道的方法。请随时在评论中分享您对这个话题的经验和看法!

你们中的大多数人在阅读本文时可能心里都有一个问题:哪种方法最好?嗯,正如您可能在本文中理解的那样,它们(目前)都不是完美的。它们都有缺点。以我的经验来看,真正的问题不是选择最佳方法,而是如何更好地结合不同的方法并利用它们提供的最佳功能。

如果您不确定要在项目中引入哪种方法,gwenneg/blog-overriding-configuration-from-test-code 存储库可能会帮助您做出决定。它包含本文提到的所有方法的实现。

感谢您阅读本文!希望它能帮助您更好地测试 Quarkus 应用。