如何在单元测试中模拟环境变量
最后更新:2024年5月11日
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 严格得多。在 Java 中,环境变量映射是不可变的。它是一个不可修改的 Map,在 JVM 启动时初始化。虽然这有充分的理由,但我们可能仍然希望在测试时控制我们的环境。
2.1. 为什么环境是不可变的
在 Java 程序的正常执行中,如果像运行时环境配置这样全局的东西可以被修改,可能会变得混乱。这尤其是在涉及多个线程时。例如,一个线程可能正在修改环境,同时另一个线程正在启动一个使用该环境的进程,任何冲突的设置都可能以意想不到的方式交互。
因此,Java 的设计者维护了环境变量映射中的全局值安全。相比之下,系统属性可以在运行时轻松更改。
2.2. 解决不可修改的 Map 问题
对于不可变的环境变量 Map 对象,有一个解决方法。尽管它的只读 UnmodifiableMap 类型,我们可以打破封装并使用 反射 访问内部字段
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 中的测试运行器配置中添加一些额外的命令行参数来使用 >–add-opens 来允许这种反射访问来解决此问题
<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 的一组扩展。它提供了一种基于注释的方式来设置和清除环境变量。
我们可以使用 junit-pioneer依赖项来添加它
<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 开始的所有版本的 JDK 兼容。
4.1. 在 JUnit 5 中设置环境变量
为此,我们需要 System Stubs 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 stub 对象初始化为测试类的一个字段,并设置我们想要使用的环境变量
@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 中设置环境变量
为此,我们需要 System Stubs 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 中设置环境变量
为此,我们需要 System Stubs TestNG 依赖项
<dependency>
<groupId>uk.org.webcompere</groupId>
<artifactId>system-stubs-testng</artifactId>
<version>2.1.3</version>
<scope>test</scope>
</dependency>
这提供了一个 TestNG 监听器,其工作方式与上述 JUnit 5 解决方案类似。
我们将监听器添加到我们的测试类
@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 的代码库,后者带有只能在单个测试方法中使用的一些技术。这意味着测试框架的选择是完全开放的。
因此,System Stubs Core 可以用于在 JUnit 测试方法的任何位置设置环境变量。
首先,让我们获取 system-stubs-core 依赖项
<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() 调用 Closure 之外,环境变量不会受到影响。
我们应该注意,此技术要求我们的测试在测试方法上具有throws Exception,因为execute() 函数必须处理可能调用带有已检查异常的方法的闭包。
此技术还要求每个测试设置自己的环境,如果我们要使用生命周期大于单个测试的测试对象(例如,Spring Context),则效果不佳。
System Stubs 允许其每个 stub 对象独立于测试框架进行设置和拆卸。因此,我们可以使用测试类的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.0 版本发布周期中,System Stubs 偏离了前面描述的基于反射的常用技术。它现在使用 ByteBuddy 来拦截环境变量调用。但是,如果项目使用的 JDK 版本低于 11,则也不需要使用这些较新的版本。
System Stubs 1.0 版本提供与 JDK 8 到 JDK 16 的兼容性。
5. System Rules 和 System Lambda
System Rules 是用于环境变量的最成熟的测试库之一,它为 JUnit 4 提供了设置环境变量的解决方案,它的作者用 System Lambda 取代它,以提供一种与测试框架无关的方法。它们基于相同的核心技术,用于在测试时替换环境变量。
5.1. 使用 System Rules 设置环境变量
首先我们需要 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 设置环境变量
为此我们需要 system-lambda 依赖
<dependency>
<groupId>com.github.stefanbirkner</groupId>
<artifactId>system-lambda</artifactId>
<version>1.2.1</version>
<scope>test</scope>
</dependency>
如 System Stubs 解决方案中所示,我们可以将依赖于环境的代码放入测试中的闭包中。为此,我们应该静态导入 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 context。
6. 避免模拟环境变量
虽然我们讨论了许多在测试时修改环境变量的方法,但可能值得考虑这是否必要,甚至是有益的。
6.1. 或许风险太高
如我们上面每个解决方案所示,在运行时更改环境变量并不简单。在存在多线程代码的情况下,情况可能会更加棘手。如果多个测试 fixture 在同一个 JVM 中并行运行,例如使用 JUnit 5 的并发 功能,那么存在不同测试尝试同时以矛盾的方式控制环境的风险。
虽然上面的一些测试库在多线程同时使用时可能不会崩溃,但很难预测环境变量在一瞬间如何被设置。更糟糕的是,一个线程可能会捕获另一个测试的临时环境变量,好像它们是所有测试完成后系统应该保持的正确状态一样。
例如,来自另一个测试库的例子,当Mockito 模拟静态方法时,它将该模拟限制在当前线程内,因为这种全局模拟会破坏并发测试。因此,修改环境变量会遇到完全相同的风险。一个测试会影响整个 JVM 的全局状态,并在其他地方引起副作用。
同样,如果我们运行的代码只能通过环境变量控制,那么测试起来可能非常困难,而且我们当然可以通过设计避免这种情况,对吗?
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");
然而,这些环境变量的替代方案可能看起来很繁重,让我们希望 Java 能够像早期的 JavaScript 示例那样提供控制权。 同样,我们无法控制其他人编写的代码,这些代码可能依赖于环境变量。
因此,似乎不可避免的是,我们仍然可能会遇到想要能够在测试时动态控制某些环境变量的情况。
7. 结论
在本文中,我们研究了在测试时设置环境变量的选项。我们看到,当我们需要在运行时使这些变量灵活,并且可用于 JDK 17 及更高版本时,这样做会变得更加困难。
然后,我们讨论了如果我们以不同的方式编写生产代码,是否可以完全避免这个问题。我们考虑了在测试时修改环境变量相关的风险,尤其是在使用并发测试时。
我们还探讨了在测试时设置环境变量的四种最流行的库:JUnit Pioneer、System Stubs、System Rules 和 System Lambda。这些库中的每一个都提供了解决问题的不同方法,并且在 JDK 版本和测试框架之间具有不同的兼容性。
支持本文的代码可在 GitHub 上获取。 一旦你以 Baeldung Pro 会员 身份登录,就开始学习并在项目上进行编码。















