1. Обзор

В определенных ситуациях нам может потребоваться, чтобы метод вызывал System.exit() и завершал работу приложения. Например, в случае если приложение должно быть запущено только один раз, а затем завершено, или в случае фатальных ошибок, таких как потеря соединений с базой данных.

Если метод вызывает System.exit(), вызвать его из юнит-тестов и делать ассерты становится трудно, потому что это приведет к завершению юнит-теста.

В этом посте мы рассмотрим, как тестировать методы, вызывающие System.exit() с использованием фреймворка JUnit.

2. Настройка проекта

Начнем с создания Java-проекта. Мы создадим службу, которая сохраняет задачи в базу данных. Если сохранение задач в базу данных приведет к выбросу исключения, служба вызовет System.exit().

2.1. Зависимости JUnit и Mockito

Давайте добавим зависимости JUnit и Mockito:

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

2.2. Настройка кода

Начнем с добавления класса сущности под названием Task:

public class Task {
    private String name;

    // геттеры, сеттеры и конструктор
}

Далее создадим DAO, отвечающий за взаимодействие с базой данных:

public class TaskDAO {
    public void save(Task task) throws Exception {
        // сохраняем таск
    }
}

Реализация метода 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() выбрасывает исключение.

2.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(). Это приведет к выполнению блока catch функции saveTask(), который вызывает System.exit().

Запустив этот тест, мы обнаружим, что он завершается, не успев пройти до конца:

3. Обходной путь решения проблемы с помощью Security Manager (до Java 17)

Чтобы избежать завершения юнит-теста, можно использовать Security Manager. Security Manager предотвратит вызовы System.exit(), а если вызов состоится, выбросит исключение. Затем можно перехватить выброшенное исключение, чтобы сделать ассерты. Security Manager не используется в Java по умолчанию, и вызовы всех методов System разрешены.

Важно отметить, что SecurityManager в Java 17 признан устаревшим и будет приводить к появлению исключений при использовании с Java 17 или более поздней ее версии.

3.1. Security Manager

Посмотрим на реализацию Security Manager:

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() должен быть переопределен, потому что дефолтная реализация Security Manager в случае вызова System.exit() выбрасывает исключение.

  • Всякий раз, когда код вызывает System.exit(), метод checkExit() класса NoExitSecurityManager будет выбрасывать исключение.

  • Вместо RuntimeException может быть выброшено любое другое исключение, если это непроверяемое исключение.

3.2. Модификация теста

Следующим шагом будет модификация теста для использования реализации SecurityManager. Мы добавим методы setUp() и tearDown() для установки и удаления Security Manager во время выполнения теста:

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

Наконец, давайте изменим тест-кейс, чтобы перехватить RuntimeException, который будет выброшен при вызове System.exit():

@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.

4. Библиотека System Lambda

Также написать тест можно с использованием библиотеки System Lambda. Эта библиотека помогает тестировать код, вызывающий методы класса System. Рассмотрим, как использовать ее для написания нашего теста.

4.1. Зависимости

Начнем с добавления зависимости system-lambda:

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

4.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);
}

5. Использование JMockit

Фреймворк JMockit предоставляет возможность сделать мок класса System. Его можно использовать для того, чтобы изменить поведение System.exit() и предотвратить завершение работы приложения. Давайте рассмотрим, как это сделать.

5.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 параметр инициализации не требуется.

5.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());
    }
}

Это вызовет исключение, которое мы можем поймать и проверить, как и в предыдущем примере с Security Manager-ом.

6. Заключение

В этой статье мы рассмотрели, как сложно может быть использовать JUnit для тестирования кода, вызывающего System.exit(). Затем мы разобрали способ решения этой проблемы путем добавления Security Manager. Также упомянули библиотеки System Lambda и JMockit, которые дают более простые способы решения этой проблемы.

Примеры кода, использованные в этой статье, можно найти на GitHub.


Завтра вечером состоится открытое занятие для начинающих Java-разработчиков, которые хотят научиться применять enum в своих приложениях. На уроке поговорим о том, что такое перечисления, конструкторы, поля, методы, синглетон. Регистрация открыта по ссылке для всех желающих.

Комментарии (2)


  1. Neikist
    23.11.2022 18:55
    +9

    А не гораздо ли проще вынести завершение работы в отдельную абстракцию?


    1. aleksandy
      23.11.2022 19:56

      Не просто проще, но именно так и нужно сделать. А ещё код возврата приложения нужно проверять именно у приложения. Т.е. в тесте запустить процесс jvm и проверить, что он возвращает.