1. 概述
当我们编写的代码依赖于系统资源(如环境变量、系统属性)或执行像 System.exit
这样的进程级操作时,测试会变得非常困难。
Java 并没有提供直接修改环境变量的方法,而且在一个测试中设置的值可能会影响到其他测试的执行。此外,如果我们的代码中调用了 System.exit
,那么测试很可能直接中断,导致无法正常运行。
早期为了解决这些问题,社区提供了 System Rules 和 System Lambda 库。本文将介绍一个基于 System Lambda 的分支项目 —— **System Stubs**,它为 JUnit 5 提供了更现代的解决方案。
2. 为什么选择 System Stubs?
2.1. System Lambda 不是 JUnit 插件
原始的 System Rules 只能用于 JUnit 4。虽然可以通过 JUnit Vintage 在 JUnit 5 中运行,但这意味着仍需编写 JUnit 4 风格的测试。后来,System Lambda 被开发出来,作为一个与测试框架无关的版本,可以在每个测试方法中使用:
@Test
void aSingleSystemLambda() throws Exception {
restoreSystemProperties(() -> {
System.setProperty("log_dir", "test/resources");
assertEquals("test/resources", System.getProperty("log_dir"));
});
// more test code here
}
测试代码被包装成一个 lambda 表达式,并通过方法进行调用。在执行完后,会自动清理设置。
虽然这种方式在某些场景下有效,但也存在一些缺点。
2.2. 避免冗余代码
System Lambda 的优势在于其工厂类中提供了一些常见测试场景的模板方法。然而,如果需要在多个测试中使用,就会导致代码重复和膨胀。
首先,即使测试代码本身不抛出异常,包装方法仍需要声明 throws Exception
。其次,如果多个测试需要相同的配置,每个测试都需要重复设置。
最麻烦的是当我们需要同时设置多个资源时。例如,如果我们要同时设置环境变量和系统属性,就需要嵌套两层:
@Test
void multipleSystemLambdas() throws Exception {
restoreSystemProperties(() -> {
withEnvironmentVariable("URL", "https://www.baeldung.com")
.execute(() -> {
System.setProperty("log_dir", "test/resources");
assertEquals("test/resources", System.getProperty("log_dir"));
assertEquals("https://www.baeldung.com", System.getenv("URL"));
});
});
}
而 JUnit 插件或扩展可以有效减少这些样板代码。
2.3. 减少样板代码
我们期望能以最少的样板代码编写测试:
@SystemStub
private EnvironmentVariables environmentVariables = ...;
@SystemStub
private SystemProperties restoreSystemProperties;
@Test
void multipleSystemStubs() {
System.setProperty("log_dir", "test/resources");
assertEquals("test/resources", System.getProperty("log_dir"));
assertEquals("https://www.baeldung.com", System.getenv("ADDRESS"));
}
这种写法由 System Stubs 的 JUnit 5 扩展提供,使测试更加简洁。
2.4. 测试生命周期钩子
如果只能使用 execute-around 模式,那么很难在测试生命周期的各个阶段插入 stubbing 行为。这在与其他 JUnit 扩展(如 @SpringBootTest
)结合使用时尤为困难。
如果我们要在 Spring Boot 测试中设置环境变量,很难将整个测试生态系统嵌入到一个测试方法中。我们需要一种方法在测试套件级别激活设置。
这正是 System Lambda 无法做到的,也是 System Stubs 被创建的主要原因之一。
2.5. 支持动态属性
一些框架(如 JUnit Pioneer)强调在编译时就确定的配置。但在现代测试中,我们可能使用 Testcontainers 或 Wiremock,需要在运行时根据随机设置来配置系统属性。这种场景下,System Stubs 更加适合。
2.6. 更强的可配置性
虽然像 catchSystemExit
这样的预设方法非常方便,但依赖开发者提供所有可能的配置选项并不现实。
System Stubs 通过组合方式提供了更大的灵活性,并且 依然支持 System Lambda 的原始测试构造方式,同时还提供了 JUnit 5 扩展、JUnit 4 规则和更多配置选项。
2.7. JDK 兼容性
从 Java 16 开始,由于 反射访问控制更严格,修改环境变量变得更加困难。从版本 2 开始,System Stubs 不再使用反射来修改环境变量,使其在新版本 JDK 中更容易使用。
3. 快速开始
3.1. 依赖配置
使用 JUnit 5 扩展 需要较新的 JUnit 5 版本:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
然后添加 System Stubs 相关依赖:
<!-- for testing with only lambda pattern -->
<dependency>
<groupId>uk.org.webcompere</groupId>
<artifactId>system-stubs-core</artifactId>
<version>2.1.6</version>
<scope>test</scope>
</dependency>
<!-- for JUnit 4 -->
<dependency>
<groupId>uk.org.webcompere</groupId>
<artifactId>system-stubs-junit4</artifactId>
<version>2.1.6</version>
<scope>test</scope>
</dependency>
<!-- for JUnit 5 -->
<dependency>
<groupId>uk.org.webcompere</groupId>
<artifactId>system-stubs-jupiter</artifactId>
<version>2.1.6</version>
<scope>test</scope>
</dependency>
我们只需要根据使用的测试框架导入相应的依赖即可。后两个依赖会自动包含 core 模块。
3.2. JUnit 4 环境变量
我们可以通过在测试类中添加一个 @Rule
注解的字段来控制环境变量:
@Rule
public EnvironmentVariablesRule environmentVariablesRule = new EnvironmentVariablesRule();
@Test
public void givenEnvironmentCanBeModified_whenSetEnvironment_thenItIsSet() {
environmentVariablesRule.set("ENV", "value1");
assertThat(System.getenv("ENV")).isEqualTo("value1");
}
通常我们会在 @Before
方法中设置环境变量,以便在所有测试中共享:
@Before
public void before() {
environmentVariablesRule.set("ENV", "value1")
.set("ENV2", "value2");
}
也可以在构造时传入初始值:
@Rule
public EnvironmentVariablesRule environmentVariablesRule =
new EnvironmentVariablesRule("ENV", "value1",
"ENV2", "value2");
还可以使用 @ClassRule
注解将规则应用到整个测试类:
@ClassRule
public static EnvironmentVariablesRule environmentVariablesRule = ...;
3.3. JUnit 5 环境变量
在 JUnit 5 中,我们需要先添加扩展:
@ExtendWith(SystemStubsExtension.class)
class EnvironmentVariablesJUnit5 {
// tests
}
然后添加一个 @SystemStub
注解的字段:
@SystemStub
private EnvironmentVariables environmentVariables;
System Stubs 扩展会自动构造对象,就像 Mockito 扩展构造 mock 一样:
@Test
void givenEnvironmentCanBeModified_whenSetEnvironment_thenItIsSet() {
environmentVariables.set("ENV", "value1");
assertThat(System.getenv("ENV")).isEqualTo("value1");
}
我们也可以在构造时设置初始值:
@SystemStub
private EnvironmentVariables environmentVariables =
new EnvironmentVariables("ENV", "value1");
或者使用 fluent API:
@SystemStub
private EnvironmentVariables environmentVariables =
new EnvironmentVariables()
.set("ENV", "value1")
.set("ENV2", "value2");
字段也可以是 static
类型,用于 @BeforeAll
和 @AfterAll
生命周期。
3.4. JUnit 5 参数注入
如果只想在特定测试中使用 stub 对象,可以使用参数注入:
@Test
void givenEnvironmentCanBeModified(EnvironmentVariables environmentVariables) {
environmentVariables.set("ENV", "value1");
assertThat(System.getenv("ENV")).isEqualTo("value1");
}
参数对象会在测试开始前被构造并激活,在测试结束后自动清理。
3.5. Execute-Around 环境变量
System Lambda 的原始方法也可以通过 SystemStubs
类使用:
withEnvironmentVariable("ENV3", "val")
.execute(() -> {
assertThat(System.getenv("ENV3")).isEqualTo("val");
});
等价于:
return new EnvironmentVariables().set("ENV3", "val");
如果测试代码有返回值,可以通过 execute
方法返回:
String extracted = new EnvironmentVariables("PROXY", "none")
.execute(() -> System.getenv("PROXY"));
assertThat(extracted).isEqualTo("none");
这种方式在测试 AWS Lambda 处理程序时非常常见。
3.6. 多个 System Stubs
在使用 execute-around 模式时,如果需要多个 stub,可以使用 with
和 execute
方法:
with(new EnvironmentVariables("FOO", "bar"), new SystemProperties("prop", "val"))
.execute(() -> {
assertThat(System.getenv("FOO")).isEqualTo("bar");
assertThat(System.getProperty("prop")).isEqualTo("val");
});
4. 系统属性
4.1. JUnit 4 系统属性
通过 @Rule
注解添加 SystemPropertiesRule
:
@Rule
public SystemPropertiesRule systemProperties =
new SystemPropertiesRule("db.connection", "false");
可以在 @Before
方法中设置额外属性:
@Before
public void before() {
systemProperties.set("before.prop", "before");
}
4.2. JUnit 5 系统属性
使用 @SystemStub
注解添加 SystemProperties
字段:
@ExtendWith(SystemStubsExtension.class)
class RestoreSystemProperties {
@SystemStub
private SystemProperties systemProperties;
}
也可以通过参数注入使用:
@Test
void willRestorePropertiesAfter(SystemProperties systemProperties) {
}
如果需要设置初始属性,可以在构造时传入:
@SystemStub
private SystemProperties systemProperties =
new SystemProperties("prop", "val");
或者在 @BeforeEach
方法中设置:
@BeforeEach
void before() {
systemProperties.set("beforeProperty", "before");
}
4.3. Execute-Around 系统属性
使用 restoreSystemProperties
方法:
restoreSystemProperties(() -> {
System.setProperty("unrestored", "true");
});
assertThat(System.getProperty("unrestored")).isNull();
也可以显式构造对象:
String result = new SystemProperties()
.execute(() -> {
System.setProperty("unrestored", "true");
return "it works";
});
assertThat(result).isEqualTo("it works");
assertThat(System.getProperty("unrestored")).isNull();
4.4. 从文件加载属性
可以使用 PropertySource
从文件或资源加载属性:
SystemProperties systemProperties =
new SystemProperties(PropertySource.fromResource("test.properties"));
5. System.out 和 System.err
5.1. JUnit 4 SystemOutRule 和 SystemErrRule
@Rule
public SystemOutRule systemOutRule = new SystemOutRule();
System.out.println("line1");
System.out.println("line2");
assertThat(systemOutRule.getLines())
.containsExactly("line1", "line2");
也可以获取完整文本:
assertThat(systemOutRule.getText())
.startsWith("line1");
使用 getLinesNormalized
获取统一换行符的文本:
assertThat(systemOutRule.getLinesNormalized())
.isEqualTo("line1\nline2\n");
SystemErrRule
用法相同:
@Rule
public SystemErrRule systemErrRule = new SystemErrRule();
@Test
public void whenCodeWritesToSystemErr_itCanBeRead() {
System.err.println("line1");
System.err.println("line2");
assertThat(systemErrRule.getLines())
.containsExactly("line1", "line2");
}
5.2. JUnit 5 示例
@SystemStub
private SystemOut systemOut;
@SystemStub
private SystemErr systemErr;
@Test
void whenWriteToOutput_thenItCanBeAsserted() {
System.out.println("to out");
System.err.println("to err");
assertThat(systemOut.getLines()).containsExactly("to out");
assertThat(systemErr.getLines()).containsExactly("to err");
}
5.3. Execute-Around 示例
@Test
void givenTapOutput_thenGetOutput() throws Exception {
String output = tapSystemOutNormalized(() -> {
System.out.println("a");
System.out.println("b");
});
assertThat(output).isEqualTo("a\nb\n");
}
也可以组合使用:
SystemOut systemOut = new SystemOut();
SystemProperties systemProperties = new SystemProperties("a", "!");
with(systemOut, systemProperties)
.execute(() -> {
System.out.println("a: " + System.getProperty("a"));
});
assertThat(systemOut.getLines()).containsExactly("a: !");
5.4. 静默输出
使用 muteSystemOut
或 muteSystemErr
静默输出:
muteSystemOut(() -> {
System.out.println("nothing is output");
});
也可以通过 NoopStream
实现:
@Rule
public SystemOutRule systemOutRule = new SystemOutRule(new NoopStream());
5.5. 自定义输出
可以通过实现 Output
接口自定义输出行为:
new SystemOut(new DisallowWriteStream())
.execute(() -> System.out.println("boo"));
6. 模拟 System.in
6.1. 测试输入流
使用 AltInputStream
提供测试输入:
LinesAltStream testInput = new LinesAltStream("line1", "line2");
Scanner scanner = new Scanner(testInput);
assertThat(scanner.nextLine()).isEqualTo("line1");
6.2. JUnit 4 示例
@Rule
public SystemInRule systemInRule =
new SystemInRule("line1", "line2", "line3");
测试代码中读取:
@Test
public void givenInput_canReadFirstLine() {
assertThat(new Scanner(System.in).nextLine())
.isEqualTo("line1");
}
6.3. JUnit 5 示例
@SystemStub
private SystemIn systemIn = new SystemIn("line1", "line2", "line3");
6.4. Execute-Around 示例
withTextFromSystemIn("line1", "line2", "line3")
.execute(() -> {
assertThat(new Scanner(System.in).nextLine())
.isEqualTo("line1");
});
6.5. 自定义输入
可以使用 setInputStream
设置自定义输入流:
systemIn.setInputStream(new FileInputStream("input.txt"));
7. 模拟 System.exit
7.1. JUnit 4 示例
@Rule
public SystemExitRule systemExitRule = new SystemExitRule();
@Test
public void whenExit_thenExitCodeIsAvailable() {
assertThatThrownBy(() -> {
System.exit(123);
}).isInstanceOf(AbortExecutionException.class);
assertThat(systemExitRule.getExitCode()).isEqualTo(123);
}
7.2. JUnit 5 示例
@SystemStub
private SystemExit systemExit;
7.3. Execute-Around 示例
int exitCode = catchSystemExit(() -> {
System.exit(123);
});
assertThat(exitCode).isEqualTo(123);
8. 自定义测试资源(JUnit 5)
8.1. 创建 TestResource
public class FakeDatabaseTestResource implements TestResource {
private String databaseConnection = "closed";
@Override
public void setup() throws Exception {
databaseConnection = "open";
}
@Override
public void teardown() throws Exception {
databaseConnection = "closed";
}
public String getDatabaseConnection() {
return databaseConnection;
}
}
8.2. Execute-Around 模式
FakeDatabaseTestResource fake = new FakeDatabaseTestResource();
assertThat(fake.getDatabaseConnection()).isEqualTo("closed");
fake.execute(() -> {
assertThat(fake.getDatabaseConnection()).isEqualTo("open");
});
8.3. 在 JUnit 5 中使用
@ExtendWith(SystemStubsExtension.class)
class FakeDatabaseJUnit5UnitTest {
@Test
void useFakeDatabase(FakeDatabaseTestResource fakeDatabase) {
assertThat(fakeDatabase.getDatabaseConnection()).isEqualTo("open");
}
}
9. 在 JUnit 5 Spring 测试中使用
@ExtendWith(SystemStubsExtension.class)
public class SpringAppWithDynamicPropertiesTest {
@SystemStub
private static EnvironmentVariables environmentVariables;
@BeforeAll
static void beforeAll() {
String baseUrl = ...;
environmentVariables.set("SERVER_URL", baseUrl);
}
@Nested
@SpringBootTest(classes = {RestApi.class, App.class},
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class InnerSpringTest {
@LocalServerPort
private int serverPort;
}
}
10. 总结
System Stubs 提供了一种简洁而强大的方式来模拟系统资源,支持 JUnit 4 和 JUnit 5,适用于环境变量、系统属性、标准输入输出以及 System.exit
的测试场景。通过其灵活的扩展机制,我们还可以自定义测试资源,与 Spring 等框架无缝集成。
完整示例代码可在 GitHub 获取。