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.xml
的 surefire
配置中添加环境变量:
<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 {
}
注意 key
和 value
必须在编译时已知。测试代码可使用该环境变量:
@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 获取。