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)强调在编译时就确定的配置。但在现代测试中,我们可能使用 TestcontainersWiremock,需要在运行时根据随机设置来配置系统属性。这种场景下,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,可以使用 withexecute 方法:

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. 静默输出

使用 muteSystemOutmuteSystemErr 静默输出:

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 获取。


原始标题:Guide to the System Stubs Library