1. 概述

当单元测试依赖环境变量的代码时,我们可能需要在测试实现中提供特定的变量值。Java 不允许直接修改环境变量,但有一些变通方法和辅助库可用。

本文将探讨依赖环境变量的单元测试挑战、Java 新版本带来的额外困难,以及 JUnit Pioneer、System Stubs、System Lambda 和 System Rules 等库的解决方案。我们将覆盖 JUnit 4、JUnit 5 和 TestNG 的使用场景。

2. 修改环境变量的挑战

在 JavaScript 等语言中,可以轻松修改测试环境:

beforeEach(() => {
   process.env.MY_VARIABLE = 'set';
});

但 Java 更严格:环境变量映射是不可变的。它是一个在 JVM 启动时初始化的不可修改 Map。虽然这有合理原因,但测试时我们仍希望控制环境。

2.1 环境不可变的原因

在普通 Java 程序执行中,如果全局运行时环境配置可修改,可能导致混乱。多线程环境下尤其危险:一个线程修改环境时,另一个线程可能正使用该环境启动进程,冲突设置会产生意外行为。

Java 设计者因此将环境变量映射中的全局值保护起来。相比之下,系统属性在运行时可以轻松修改

2.2 绕过不可变映射的方法

可通过反射打破封装访问内部字段:

Class<?> classOfMap = System.getenv().getClass();
Field field = classOfMap.getDeclaredField("m");
field.setAccessible(true);
Map<String, String> writeableEnvironmentVariables = (Map<String, String>)field.get(System.getenv());

UnmodifiableMap 包装对象的 m 字段是可变 Map

writeableEnvironmentVariables.put("baeldung", "has set an environment variable");

assertThat(System.getenv("baeldung")).isEqualTo("has set an environment variable");

Windows 上还有处理不区分大小写变量的 ProcessEnvironment 替代实现,使用此技术的库需考虑这点。但本质上,这就是绕过不可变环境变量 Map 的方法。

JDK 16 后,模块系统对 JDK 内部的保护更强,使用反射访问变得更困难

2.3 反射访问失效的场景

Java 模块系统默认禁止反射修改核心内部机制(JDK 17 起),这些被视为不安全实践,可能导致未来内部变更时的运行时错误。

可能遇到此类错误:

Unable to make field private static final java.util.HashMap java.lang.ProcessEnvironment.theEnvironment accessible: 
  module java.base does not "opens java.lang" to unnamed module @fdefd3f

这表示 Java 模块系统阻止了反射。可在 pom.xml 的测试运行器配置中添加命令行参数解决:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <argLine>
            --add-opens java.base/java.util=ALL-UNNAMED
            --add-opens java.base/java.lang=ALL-UNNAMED
        </argLine>
    </configuration>
</plugin>

此变通方案允许使用反射打破封装的代码,但可能带来不安全编码实践——测试时可用,运行时却意外失败。建议选择无需此变通方案的库。

2.4 为何需要编程设置环境变量

单元测试可通过测试运行器设置环境变量,若全局配置适用于整个测试套件,这是首选方案。在 pom.xmlsurefire 配置中添加环境变量:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <environmentVariables>
            <SET_BY_SUREFIRE>YES</SET_BY_SUREFIRE>
        </environmentVariables>
    </configuration>
</plugin>

测试中可访问该变量:

assertThat(System.getenv("SET_BY_SUREFIRE")).isEqualTo("YES");

但代码可能根据不同环境变量设置执行不同行为,我们可能希望用不同值测试所有行为变体。类似地,测试时可能有编码时无法预测的值(如 WireMock 端口或 Docker 容器中的测试数据库端口)。

2.5 测试库的正确选择

多个测试库可在测试时设置环境变量,各库对不同测试框架和 JDK 版本的兼容性不同。可根据工作流程偏好、变量值是否预知及 JDK 版本选择库。

注意:所有库不仅支持环境变量,它们都采用修改前捕获当前环境、测试完成后恢复原状态的机制。

3. 使用 JUnit Pioneer 设置环境变量

JUnit Pioneer 是 JUnit 5 扩展集,提供基于注解的环境变量设置和清除方法。添加依赖:

<dependency>
    <groupId>org.junit-pioneer</groupId>
    <artifactId>junit-pioneer</artifactId>
    <version>2.1.0</version>
    <scope>test</scope>
</dependency>

3.1 使用 SetEnvironmentVariable 注解

@SetEnvironmentVariable 注解测试类或方法:

@SetEnvironmentVariable(key = "pioneer", value = "is pioneering")
class EnvironmentVariablesSetByJUnitPioneerUnitTest {
}

注意 keyvalue 必须在编译时已知。测试代码可使用该环境变量:

@Test
void variableCanBeRead() {
    assertThat(System.getenv("pioneer")).isEqualTo("is pioneering");
}

可多次使用 @SetEnvironmentVariable 设置多个变量。

3.2 清除环境变量

可能需要清除系统提供的变量或类级别设置的变量:

@ClearEnvironmentVariable(key = "pioneer")
@Test
void givenEnvironmentVariableIsClear_thenItIsNotSet() {
    assertThat(System.getenv("pioneer")).isNull();
}

3.3 JUnit Pioneer 的限制

JUnit Pioneer 仅适用于 JUnit 5。它使用反射,因此需要 Java 16 或更低版本,或采用 add-opens 变通方案。

4. 使用 System Stubs 设置环境变量

System Stubs 支持 JUnit 4、JUnit 5 和 TestNG。像其前身 System Lambda 一样,它也可独立用于任何框架的测试代码。System Stubs 兼容 JDK 11 及以上所有版本

4.1 在 JUnit 5 中设置环境变量

添加依赖:

<dependency>
    <groupId>uk.org.webcompere</groupId>
    <artifactId>system-stubs-jupiter</artifactId>
    <version>2.1.3</version>
    <scope>test</scope>
</dependency>

首先添加扩展到测试类:

@ExtendWith(SystemStubsExtension.class)
class EnvironmentVariablesUnitTest {
}

初始化 EnvironmentVariables 存根对象作为测试类字段:

@SystemStub
private EnvironmentVariables environment = new EnvironmentVariables("MY VARIABLE", "is set");

注意:必须用 @SystemStub 注解对象,扩展才能正确处理。SystemStubsExtension 在测试期间激活此环境并在之后清理。测试中 EnvironmentVariables 对象也可修改System.getenv() 调用会获取最新配置。

更复杂场景:测试初始化时值未知。在 beforeEach() 中设置值时,无需在初始化列表创建对象:

@SystemStub
private EnvironmentVariables environmentVariables;

JUnit 调用 beforeEach() 时,扩展已创建对象,可用它设置所需变量:

@BeforeEach
void beforeEach() {
    environmentVariables.set("systemstubs", "creates stub objects");
}

测试执行时环境变量生效:

@Test
void givenEnvironmentVariableHasBeenSet_thenCanReadIt() {
    assertThat(System.getenv("systemstubs")).isEqualTo("creates stub objects");
}

测试方法完成后,环境变量恢复修改前状态。

4.2 在 JUnit 4 中设置环境变量

添加依赖:

<dependency>
    <groupId>uk.org.webcompere</groupId>
    <artifactId>system-stubs-junit4</artifactId>
    <version>2.1.3</version>
    <scope>test</scope>
</dependency>

System Stubs 提供 JUnit 4 规则。将其作为测试类字段:

@Rule
public EnvironmentVariablesRule environmentVariablesRule =
  new EnvironmentVariablesRule("system stubs", "initializes variable");

初始化时设置了环境变量。测试中或 @Before 方法中可调用 set() 修改变量。测试运行时变量生效:

@Test
public void canReadVariable() {
    assertThat(System.getenv("system stubs")).isEqualTo("initializes variable");
}

4.3 在 TestNG 中设置环境变量

添加依赖:

<dependency>
    <groupId>uk.org.webcompere</groupId>
    <artifactId>system-stubs-testng</artifactId>
    <version>2.1.3</version>
    <scope>test</scope>
</dependency>

提供类似 JUnit 5 的 TestNG 监听器。添加监听器到测试类:

@Listeners(SystemStubsListener.class)
public class EnvironmentVariablesTestNGUnitTest {
}

添加 @SystemStub 注解的 EnvironmentVariables 字段:

@SystemStub
private EnvironmentVariables setEnvironment;

beforeAll() 方法可初始化变量:

@BeforeClass
public void beforeAll() {
    setEnvironment.set("testng", "has environment variables");
}

测试方法可使用它们:

@Test
public void givenEnvironmentVariableWasSet_thenItCanBeRead() {
    assertThat(System.getenv("testng")).isEqualTo("has environment variables");
}

4.4 无测试框架使用 System Stubs

System Stubs 基于System Lambda 代码库,提供仅能在单个测试方法中使用的技术。添加核心依赖:

<dependency>
    <groupId>uk.org.webcompere</groupId>
    <artifactId>system-stubs-core</artifactId>
    <version>2.1.3</version>
    <scope>test</scope>
</dependency>

测试方法中,用构造临时设置环境变量。静态导入 SystemStubs

import static uk.org.webcompere.systemstubs.SystemStubs.withEnvironmentVariables;

使用 withEnvironmentVariables() 包装测试代码:

@Test
void useEnvironmentVariables() throws Exception {
    withEnvironmentVariables("system stubs", "in test")
      .execute(() -> {
          assertThat(System.getenv("system stubs"))
            .isEqualTo("in test");
      });
}

assertThat() 调用操作的是已设置变量的环境。execute() 闭包外,环境变量不受影响。

注意:**此技术要求测试方法声明 throws Exception**,因为 execute() 需处理可能抛出检查异常的闭包。

此技术要求每个测试设置自己的环境,不适用于生命周期超过单次测试的对象(如 Spring 上下文)。System Stubs 允许独立设置和拆除存根对象,可用测试类的 beforeAll()afterAll() 操作 EnvironmentVariables

private static EnvironmentVariables environmentVariables = new EnvironmentVariables();

@BeforeAll
static void beforeAll() throws Exception {
    environmentVariables.set("system stubs", "in test");
    environmentVariables.setup();
}

@AfterAll
static void afterAll() throws Exception {
    environmentVariables.teardown();
}

但测试框架扩展的优势是避免这种样板代码。

4.5 System Stubs 的限制

System Stubs 的 TestNG 功能仅限 2.1+ 版本,要求 Java 11+。版本 2 中,System Stubs 放弃了通用反射技术,改用 ByteBuddy 拦截环境变量调用。若项目使用 JDK 11 以下版本,无需使用这些新版本。

System Stubs 版本 1 兼容 JDK 8 到 16。

5. System Rules 和 System Lambda

System Rules 提供 JUnit 4 的环境变量解决方案,其作者用 System Lambda 替代它以提供测试框架无关方案。两者基于相同的核心技术。

5.1 用 System Rules 设置环境变量

添加依赖:

<dependency>
    <groupId>com.github.stefanbirkner</groupId>
    <artifactId>system-rules</artifactId>
    <version>1.19.0</version>
    <scope>test</scope>
</dependency>

添加规则到 JUnit 4 测试类:

@Rule
public EnvironmentVariables environmentVariablesRule = new EnvironmentVariables();

@Before 方法中设置值:

@Before
public void before() {
    environmentVariablesRule.set("system rules", "works");
}

测试方法访问正确环境:

@Test
public void givenEnvironmentVariable_thenCanReadIt() {
    assertThat(System.getenv("system rules")).isEqualTo("works");
}

规则对象 environmentVariablesRule 允许在测试方法内直接设置变量。

5.2 用 System Lambda 设置环境变量

添加依赖:

<dependency>
    <groupId>com.github.stefanbirkner</groupId>
    <artifactId>system-lambda</artifactId>
    <version>1.2.1</version>
    <scope>test</scope>
</dependency>

将依赖环境的代码放在测试闭包中。静态导入 SystemLambda

import static com.github.stefanbirkner.systemlambda.SystemLambda.withEnvironmentVariable;

编写测试:

@Test
void enviromentVariableIsSet() throws Exception {
    withEnvironmentVariable("system lambda", "in test")
      .execute(() -> {
          assertThat(System.getenv("system lambda"))
            .isEqualTo("in test");
      });
}

5.3 System Rules 和 System Lambda 的限制

两者都是成熟广泛的库,但无法用于 JDK 17+ 的环境变量操作。System Rules 严重依赖 JUnit 4。System Lambda 无法设置测试装置级别的环境变量,不能帮助 Spring 上下文初始化

6. 避免模拟环境变量

虽然讨论了多种测试时修改环境变量的方法,但需考虑是否必要或有益。

6.1 可能风险过高

如前所述,运行时修改环境变量并不简单。多线程代码中更棘手。若多个测试装置在同一 JVM 并行运行(如 JUnit 5 并发测试),不同测试可能同时以矛盾方式控制环境。

虽然上述测试库多线程使用时不会崩溃,但环境变量状态难以预测。更糟的是,一个线程可能捕获另一个测试的临时环境变量,误将其作为测试完成后系统应保留的状态。

类似地,若代码只能通过环境变量控制,测试会非常困难——设计上应避免这种情况。

6.2 使用依赖注入

在构造时接收所有输入的系统,比从系统资源拉取输入的系统更易测试。Spring 等依赖注入容器可构建更易独立测试的对象。

注意:Spring 允许用系统属性替代环境变量设置属性值。本文讨论的工具也都支持测试时设置和重置系统属性。

6.3 使用抽象

若模块必须拉取环境变量,不应直接依赖 System.getenv(),而应使用环境变量读取器接口:

@FunctionalInterface
interface GetEnv {
    String get(String name);
}

系统代码可通过构造函数注入此对象:

public class ReadsEnvironment {
    private GetEnv getEnv;

    public ReadsEnvironment(GetEnv getEnv) {
        this.getEnv = getEnv;
    }

    public String whatOs() {
        return getEnv.get("OS");
    }
}

运行时用 System::getenv 实例化,测试时传入自定义环境:

Map<String, String> fakeEnv = new HashMap<>();
fakeEnv.put("OS", "MacDowsNix");

ReadsEnvironment reader = new ReadsEnvironment(fakeEnv::get);
assertThat(reader.whatOs()).isEqualTo("MacDowsNix");

但这些替代方案可能显得笨重,且我们无法控制依赖环境变量的他人代码。因此,测试时动态控制环境变量的需求仍不可避免。

7. 总结

本文探讨了测试时设置环境变量的选项。当需要运行时灵活设置变量并兼容 JDK 17+ 时,此操作变得更困难。

我们讨论了通过不同编写生产代码避免问题的可能性,以及测试时修改环境变量的风险(尤其并发测试时)。还探索了四个流行库:JUnit Pioneer、System Stubs、System Rules 和 System Lambda,它们提供不同解决方案,兼容性各异。

本文示例代码可在 GitHub 获取。


原始标题:How to Mock Environment Variables in Unit Tests