1. 概述

main() 方法是每个 Java 应用程序的入口点,根据应用类型的不同,其实现方式也会有所差异。对于常规 Web 应用,main() 方法主要负责启动上下文;而对于某些控制台应用,我们可能会直接在其中编写业务逻辑。

测试 main() 方法相当复杂,因为它是一个静态方法,只接受字符串参数且不返回任何值。

本文将探讨如何测试 main 方法,重点关注命令行参数和输入流的处理。

2. Maven 依赖

本教程需要几个测试库(JUnit 和 Mockito)以及用于处理参数的 Apache Commons CLI:

<dependency>
     <groupId>commons-cli</groupId>
     <artifactId>commons-cli</artifactId>
     <version>1.5.0</version>
 </dependency>
 <dependency>
     <groupId>org.junit.jupiter</groupId>
     <artifactId>junit-jupiter-api</artifactId>
     <version>5.10.0</version>
     <scope>test</scope>
 </dependency>
 <dependency>
     <groupId>org.mockito</groupId>
     <artifactId>mockito-core</artifactId>
     <version>5.5.0</version>
     <scope>test</scope>
 </dependency>

可在 Maven 中央仓库找到最新版本的 JUnitMockitoApache Commons CLI

3. 场景设置

为说明 main() 方法的测试,我们定义一个实用场景:开发一个简单应用程序,计算输入数字的总和。它应能根据参数从控制台或文件读取输入。程序输入是一系列数字。

根据场景,程序应基于用户参数动态调整行为,执行不同的工作流。

3.1. 使用 Apache Commons CLI 定义程序参数

我们需要定义两个关键参数:"i" 和 "f":

  • "i" 选项指定输入源(FILE 或 CONSOLE)
  • "f" 选项指定读取的文件名,仅在 "i" 为 FILE 时有效

使用 Apache Commons CLI 库简化参数交互,它既能验证参数又能解析值。以下是用 Apache 的 Option builder 定义 "i" 选项的示例:

Option inputTypeOption = Option.builder("i")
  .longOpt("input")
  .required(true)
  .desc("The input type")
  .type(InputType.class)
  .hasArg()
  .build();

定义选项后,Apache Commons CLI 帮助解析输入参数,分支业务逻辑:

Options options = getOptions();
CommandLineParser parser = new DefaultParser();
CommandLine commandLine = parser.parse(options, args);

if (commandLine.hasOption("i")) {
    System.out.print("Option i is present. The value is: " + commandLine.getOptionValue("i") + " \n");
    String optionValue = commandLine.getOptionValue("i");
    InputType inputType = InputType.valueOf(optionValue);

    String fileName = null;
    if (commandLine.hasOption("f")) {
        fileName = commandLine.getOptionValue("f");
    }
    String inputString = inputReader.read(inputType, fileName);
    int calculatedSum = calculator.calculateSum(inputString);
}

为保持清晰简洁,我们将职责分离到不同类:

  • InputType 枚举封装输入参数值
  • InputReader 类根据 InputType 获取输入字符串
  • Calculator 基于解析字符串计算总和

这种分离使 main 类保持简单:

public static void main(String[] args) {

    Bootstrapper bootstrapper = new Bootstrapper(new InputReader(), new Calculator());

    bootstrapper.processRequest(args);
}

4. 如何测试 Main 方法

main() 方法的签名和行为与应用中常规方法不同。因此需要组合多种测试策略,专门处理静态方法、void 方法、输入流和参数。

下文将逐一介绍这些概念,先看 main() 方法的业务逻辑如何构建。

开发新应用时,若能完全控制架构,main() 方法应只包含初始化工作流的逻辑,不含复杂逻辑。这种架构下,可对工作流各部分(BootstrapperInputReaderCalculator)单独进行单元测试。

但对于有历史遗留的老应用,情况会更复杂。尤其当开发者将大量业务逻辑直接放在 main 类的静态上下文中时。遗留代码往往难以修改,我们只能基于现有代码进行测试。

4.1. 测试静态方法

过去用 Mockito 处理静态上下文很棘手,常需借助 PowerMockito 等库。但最新版 Mockito 已克服此限制。自 3.4.0 版引入 Mockito.mockStatic 后,无需额外库即可轻松模拟和验证静态方法。 这简化了涉及静态方法的测试场景。

使用 MockedStatic 可执行与常规 Mock 相同的操作:

try (MockedStatic<SimpleMain> mockedStatic = Mockito.mockStatic(StaticMain.class)) {
    mockedStatic.verify(() -> StaticMain.calculateSum(stringArgumentCaptor.capture()));
    mockedStatic.when(() -> StaticMain.calculateSum(any())).thenReturn(24);
}

要使 MockedStatic 作为 Spy 工作,需添加配置参数:

MockedStatic<StaticMain> mockedStatic = Mockito.mockStatic(StaticMain.class, Mockito.CALLS_REAL_METHODS)

配置好 MockedStatic 后,即可全面测试静态方法。

4.2. 测试 Void 方法

遵循函数式开发方法论,方法应满足:

  • 独立性
  • 不修改传入参数
  • 返回处理结果

这种行为下,可轻松基于返回结果编写单元测试。但测试 void 方法不同,重点转向方法执行产生的副作用和状态变化。

4.3. 测试程序参数

可像调用其他标准 Java 方法一样从测试类调用 main() 方法。要评估不同参数集下的行为,只需在调用时提供这些参数。

根据前文的 Options 定义,可用短参数 -i 调用 main()

String[] arguments = new String[] { "-i", "CONSOLE" };
SimpleMain.main(arguments);

也可用长参数形式调用:

String[] arguments = new String[] { "--input", "CONSOLE" };
SimpleMain.main(arguments);

4.4. 测试数据输入流

控制台读取通常基于 System.in

private static String readFromConsole() {
    System.out.println("Enter values for calculation: \n");
    return new Scanner(System.in).nextLine();
}

System.in 是主机环境指定的"标准"输入流,通常对应键盘输入。测试中无法提供键盘输入,但可更改 System.in 引用的流类型:

InputStream fips = new ByteArrayInputStream("1 2 3".getBytes());
System.setIn(fips);

此示例中,我们更改了默认输入类型,使应用从 ByteArrayInputStream 读取,而非等待用户输入。

测试中可使用任何其他 InputStream,例如从文件读取:

InputStream fips = getClass().getClassLoader().getResourceAsStream("test-input.txt");
System.setIn(fips);

此外,可用相同方法替换输出流以验证程序写入内容:

ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
PrintStream out = new PrintStream(byteArrayOutputStream);
System.setOut(out);

此方法下,控制台将无输出,因为 System.out 会将所有数据发送到 ByteArrayOutputStream 而非控制台。

5. 完整测试示例

结合前文知识编写完整测试,步骤如下:

  1. 将 main 类模拟为 Spy
  2. 将输入参数定义为 String 数组
  3. 替换 System.in 中的默认流
  4. 验证程序在静态上下文中调用了所有必需方法,或将必要结果写入控制台
  5. System.inSystem.out 流恢复原始状态,避免影响其他测试

以下示例测试 StaticMain 类(所有逻辑在静态上下文中)。我们用 ByteArrayInputStream 替换 System.in,基于 verify() 构建验证:

@Test
public void givenArgumentAsConsoleInput_WhenReadFromSubstitutedByteArrayInputStream_ThenSuccessfullyCalculate() throws IOException {
    String[] arguments = new String[] { "-i", "CONSOLE" };
    try (MockedStatic mockedStatic = Mockito.mockStatic(StaticMain.class, Mockito.CALLS_REAL_METHODS); 
      InputStream fips = new ByteArrayInputStream("1 2 3".getBytes())) {

        InputStream original = System.in;

        System.setIn(fips);

        ArgumentCaptor stringArgumentCaptor = ArgumentCaptor.forClass(String.class);

        StaticMain.main(arguments);

        mockedStatic.verify(() -> StaticMain.calculateSum(stringArgumentCaptor.capture()));

        System.setIn(original);
    }
}

SimpleMain 类可采用不同策略,因其业务逻辑已分散到其他类。此时无需模拟 SimpleMain 类(内部无其他方法)。我们用文件流替换 System.in,基于传播到 ByteArrayOutputStream 的控制台输出构建验证:

@Test
public void givenArgumentAsConsoleInput_WhenReadFromSubstitutedFileStream_ThenSuccessfullyCalculate() throws IOException {
    String[] arguments = new String[] { "-i", "CONSOLE" };

    InputStream fips = getClass().getClassLoader().getResourceAsStream("test-input.txt");
    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
    PrintStream out = new PrintStream(byteArrayOutputStream);

    System.setIn(fips);
    System.setOut(out);

    SimpleMain.main(arguments);

    String consoleOutput = byteArrayOutputStream.toString(Charset.defaultCharset());
    assertTrue(consoleOutput.contains("Calculated sum: 10"));

    fips.close();
    out.close();
}

6. 总结

本文探讨了多种 main 方法设计及其对应的测试方法,涵盖:

  • 静态方法测试
  • void 方法测试
  • 参数处理
  • 默认系统流更改

完整示例可在 GitHub 获取。


原始标题:Test Main Method with JUnit