概述

在某些情况下,方法可能需要调用 System.exit() 来关闭应用程序。例如,当应用只执行一次然后退出,或者在遇到致命错误如数据库连接丢失时。然而,如果一个方法调用了 System.exit(),那么从单元测试中调用它并进行断言就会变得困难,因为这会导致测试也退出。

本文将指导您如何在使用 JUnit 进行测试时,处理调用 System.exit() 的方法。

1. 项目设置

首先,我们创建一个Java项目。我们将创建一个服务,用于将任务保存到数据库。如果保存任务时数据库抛出异常,服务将调用 System.exit()

1.1. JUnit 和 Mockito 依赖

添加以下依赖项:JUnitMockito

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.10.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-core</artifactId>
        <version>5.11.0</version>
        <scope>test</scope>
    </dependency>
</dependencies>

1.2. 代码设置

首先,添加一个名为 Task 的实体类:

public class Task {
    private String name;

    // getters, setters and constructor
}

接下来,创建一个与数据库交互的 DAO

public class TaskDAO {
    public void save(Task task) throws Exception {
        // save the task
    }
}

save() 方法的具体实现对本文目的不重要。

然后,创建一个 TaskService,调用 DAO:

public class TaskService {

    private final TaskDAO taskDAO = new TaskDAO();

    public void saveTask(Task task) {
        try {
            taskDAO.save(task);
        } catch (Exception e) {
            System.exit(1);
        }
    }
}

值得注意的是,如果save() 方法抛出异常,应用将退出。

1.3. 单元测试

尝试为上面的 saveTask() 方法编写单元测试:

@Test
void givenDAOThrowsException_whenSaveTaskIsCalled_thenSystemExitIsCalled() throws Exception {
    Task task = new Task("test");
    TaskDAO taskDAO = mock(TaskDAO.class);
    TaskService service = new TaskService(taskDAO);
    doThrow(new NullPointerException()).when(taskDAO).save(task);
    service.saveTask(task);
}

我们已模拟了 TaskDAO,使其在调用 save() 方法时抛出异常。这将导致执行 saveTask() 方法的 catch 块,其中调用了 System.exit()

运行此测试时,我们会发现它在完成前就退出了

测试用例因应用在测试结束前退出而被跳过

2. 使用安全管理器的工作绕行(Java 17 之前)

我们可以提供一个 安全管理器来阻止单元测试退出。 我们的安全管理器将阻止 System.exit() 调用,并在发生时抛出异常。然后我们可以捕获这个异常进行断言。默认情况下,Java 不使用安全管理器,允许对所有 System 方法的调用。

需要注意的是,SecurityManager 在 Java 17 及更高版本中已被弃用,使用时会抛出异常。

2.1. 安全管理器

让我们看看安全管理器的实现:

class NoExitSecurityManager extends SecurityManager {
    @Override
    public void checkPermission(Permission perm) {
    }

    @Override
    public void checkExit(int status) {
        super.checkExit(status);
        throw new RuntimeException(String.valueOf(status));
    }
}

以下是代码中的一些关键行为:

  • 必须重写 checkPermission() 方法,因为默认的安全管理器实现如果调用 System.exit() 将抛出异常。
  • 当我们的代码调用 System.exit() 时,NoExitSecurityManagercheckExit() 方法将介入并抛出异常。
  • 可以抛出任何类型的异常,只要它是未检查异常。

2.2. 修改测试

下一步是修改测试,使其使用安全管理器。我们将 在测试运行时添加和移除安全管理器setUp()tearDown() 方法:

@BeforeEach
void setUp() {
    System.setSecurityManager(new NoExitSecurityManager());
}

最后,**更新测试用例以捕获当 System.exit() 被调用时抛出的 RuntimeException**:

@Test
void givenDAOThrowsException_whenSaveTaskIsCalled_thenSystemExitIsCalled() throws Exception {
    Task task = new Task("test");
    TaskDAO taskDAO = mock(TaskDAO.class);
    TaskService service = new TaskService(taskDAO);
    try {
        doThrow(new NullPointerException()).when(taskDAO).save(task);
        service.saveTask(task);
    } catch (RuntimeException e) {
         Assertions.assertEquals("1", e.getMessage());
    }
}

我们使用 catch 块来验证退出消息的状态与 DAO 设置的退出代码相同。

3. 系统Lambda库

另一种编写测试的方法是使用 System Lambda Library。此库有助于测试调用 System 类方法的代码。我们将探讨如何利用此库编写测试。

3.1. 依赖项

首先,添加 system-lambda 的依赖:

<dependency>
    <groupId>com.github.stefanbirkner</groupId>
    <artifactId>system-lambda</artifactId>
    <version>1.2.1</version>
    <scope>test</scope>
</dependency>

3.2. 修改测试用例

接下来,将原始测试代码包装在库提供的 catchSystemExit() 方法中。此方法将阻止系统退出,并返回退出代码。然后我们进行断言

@Test
void givenDAOThrowsException_whenSaveTaskIsCalled_thenSystemExitIsCalled() throws Exception {
    int statusCode = catchSystemExit(() -> {
        Task task = new Task("test");
        TaskDAO taskDAO = mock(TaskDAO.class);
        TaskService service = new TaskService(taskDAO);
        doThrow(new NullPointerException()).when(taskDAO).save(task);
        service.saveTask(task);
    });
    Assertions.assertEquals(1, statusCode);
}

4. 使用 JMockit

JMockit 库提供了一种方式来模拟 System 类。我们可以利用它改变 System.exit() 的行为,防止系统退出。让我们看看如何操作。

4.1. 依赖

添加 JMockit 的依赖:

<dependency>
    <groupId>org.jmockit</groupId>
    <artifactId>jmockit</artifactId>
    <version>1.49</version>
    <scope>test</scope>
</dependency>

此外,我们需要在 JVM 初始化参数中添加 -javaagent 来启用 JMockit。可以使用 Maven Surefire 插件来实现这一点:

<plugins>
    <plugin>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>2.22.2</version> 
        <configuration>
           <argLine>
               -javaagent:"${settings.localRepository}"/org/jmockit/jmockit/1.49/jmockit-1.49.jar
           </argLine>
        </configuration>
    </plugin>
</plugins>

这使得 JMockit 在JUnit之前初始化,这样所有测试都会通过 JMockit 运行。对于较旧的 JMockit 版本,不需要这个初始化参数。

4.2. 修改测试

接下来,修改测试以模拟 System.exit()

@Test
public void givenDAOThrowsException_whenSaveTaskIsCalled_thenSystemExitIsCalled() throws Exception {
    new MockUp<System>() {
        @Mock
        public void exit(int value) {
            throw new RuntimeException(String.valueOf(value));
        }
    };

    Task task = new Task("test");
    TaskDAO taskDAO = mock(TaskDAO.class);
    TaskService service = new TaskService(taskDAO);
    try {
        doThrow(new NullPointerException()).when(taskDAO).save(task);
        service.saveTask(task);
    } catch (RuntimeException e) {
        Assertions.assertEquals("1", e.getMessage());
    }
}

这将抛出一个异常,我们可以捕获并像之前的安全管理器示例一样进行断言。

5. 结论

本文探讨了在使用 JUnit 测试时,如何处理调用 System.exit() 的代码变得困难。我们还介绍了如何通过添加安全管理器来解决这个问题,并查看了 System Lambda 和 JMockit 库,它们提供了更简单的方法来处理这个问题。

本文中的代码示例可在 GitHub 查看。