Перейти к основному содержимому

Руководство по библиотеке системных заглушек

· 22 мин. чтения

1. Обзор

Может быть сложно протестировать наше программное обеспечение, если оно зависит от системных ресурсов, таких как переменные среды, системные свойства, или использует операции уровня процесса, такие как System.exit .

Java не предоставляет прямого метода для установки переменных среды, и мы рискуем тем, что значения, установленные в одном тесте, повлияют на выполнение другого. Точно так же мы можем избежать написания тестов JUnit для кода, который может выполнить System.exit , поскольку есть вероятность, что это прервет тесты.

Системные правила и системные лямбда-библиотеки были ранними решениями этих проблем. В этом руководстве мы рассмотрим новую вилку System Lambda под названием System Stubs , которая представляет собой альтернативу JUnit 5 .

2. Почему системные заглушки?

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
}

Тестовый код выражается как лямбда, передаваемая методу, который устанавливает необходимую заглушку. Очистка происходит непосредственно перед тем, как управление возвращается остальной части тестового метода.

Хотя в некоторых случаях это работает хорошо, у этого подхода есть несколько недостатков.

2.2. Избегайте лишнего кода

Преимущество подхода System Lambda заключается в том, что внутри его фабричного класса есть несколько общих рецептов для выполнения определенных типов тестов. Однако это приводит к некоторому раздуванию кода, когда мы хотим использовать его во многих тестовых примерах.

Во-первых, даже если сам тестовый код не выдает проверенное исключение, это делает метод-оболочка, поэтому все методы получают исключение throws Exception . Во-вторых, установка одного и того же правила для нескольких тестов требует дублирования кода. Каждый тест должен выполнять одну и ту же конфигурацию независимо.

Однако самый громоздкий аспект этого подхода возникает, когда мы пытаемся настроить более одного инструмента за раз. Допустим, мы хотим установить некоторые переменные среды и системные свойства. В конечном итоге нам понадобятся два уровня вложенности, прежде чем наш тестовый код запустится:

@Test
void multipleSystemLambdas() throws Exception {
restoreSystemProperties(() -> {
withEnvironmentVariable("URL", "https://www.foreach.com")
.execute(() -> {
System.setProperty("log_dir", "test/resources");
assertEquals("test/resources", System.getProperty("log_dir"));
assertEquals("https://www.foreach.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.foreach.com", System.getenv("ADDRESS"));
}

Этот подход обеспечивается расширением SystemStubs JUnit 5 и позволяет составлять наши тесты с меньшим количеством кода.

2.4. Крючки жизненного цикла тестирования

Когда единственным доступным инструментом является шаблон выполнения вокруг , невозможно привязать поведение заглушки ко всем частям жизненного цикла теста. Это особенно сложно при попытке объединить его с другими расширениями JUnit, такими как @SpringBootTest .

Если бы мы хотели настроить некоторые переменные среды вокруг теста Spring Boot, то мы не могли бы разумно встроить всю эту тестовую экосистему в один метод тестирования. Нам понадобится способ активировать тестовую настройку вокруг тестового набора.

Это никогда не было бы возможно с методологией, используемой System Lambda, и это было одной из основных причин создания системных заглушек.

2.5. Поощряйте динамические свойства

Другие фреймворки для настройки системных свойств, такие как JUnit Pioneer , делают упор на конфигурации, известные во время компиляции. В современных тестах, где мы можем использовать Testcontainers или Wiremock , нам нужно настроить свойства нашей системы на основе случайных настроек времени выполнения после запуска этих инструментов. Лучше всего это работает с тестовой библиотекой, которую можно использовать на протяжении всего жизненного цикла тестирования.

2.6. Больше конфигураций

Полезно иметь готовые тестовые рецепты, такие как catchSystemExit , которые оборачивают тестовый код для выполнения одной задачи. Однако это зависит от разработчиков тестовой библиотеки, которые предоставят все варианты конфигурации, которые нам могут понадобиться.

Конфигурация по композиции более гибкая и является важной частью новой реализации системных заглушек.

Однако System Stubs поддерживает исходные тестовые конструкции из System Lambda для обратной совместимости. Кроме того, он предоставляет новое расширение JUnit 5, набор правил JUnit 4 и многие другие параметры конфигурации. Несмотря на то, что он основан на исходном коде, он был сильно рефакторинг и модульность, чтобы обеспечить более богатый набор функций.

Давайте узнаем больше об этом.

3. Начало работы

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

Для расширения JUnit 5 требуется достаточно актуальная версия JUnit 5 :

<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.8.1</version>
<scope>test</scope>
</dependency>

Давайте добавим все зависимости библиотеки System Stubs в наш pom.xml :

<!-- for testing with only lambda pattern -->
<dependency>
<groupId>uk.org.webcompere</groupId>
<artifactId>system-stubs-core</artifactId>
<version>1.1.0</version>
<scope>test</scope>
</dependency>

<!-- for JUnit 4 -->
<dependency>
<groupId>uk.org.webcompere</groupId>
<artifactId>system-stubs-junit4</artifactId>
<version>1.1.0</version>
<scope>test</scope>
</dependency>

<!-- for JUnit 5 -->
<dependency>
<groupId>uk.org.webcompere</groupId>
<artifactId>system-stubs-jupiter</artifactId>
<version>1.1.0</version>
<scope>test</scope>
</dependency>

Мы должны отметить, что нам нужно импортировать столько из них, сколько нам нужно для используемой нами тестовой среды. Действительно, оба последних транзитивно включают основную зависимость.

Теперь давайте напишем наш первый тест.

3.2. Переменные среды JUnit 4

Мы можем управлять переменными среды, объявив аннотированное поле JUnit 4 @Rule в нашем тестовом классе типа EnvironmentVariablesRule . Это будет активировано JUnit 4 при выполнении наших тестов и позволит нам устанавливать переменные среды внутри теста:

@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");
}

Здесь следует отметить использование метода fluent set , который упрощает установку нескольких значений посредством цепочки методов .

Мы также можем использовать конструктор объекта EnvironmentVariablesRule для предоставления значений при построении:

@Rule
public EnvironmentVariablesRule environmentVariablesRule =
new EnvironmentVariablesRule("ENV", "value1",
"ENV2", "value2");

Существует несколько перегрузок конструктора, позволяющих предоставлять переменные в разных формах. Пример в приведенном выше примере позволяет предоставлять любое количество пар имя-значение с помощью varargs .

Каждое из правил System Stubs JUnit 4 является подклассом одного из основных объектов-заглушек. Их также можно использовать на протяжении всего жизненного цикла всего тестового класса с аннотацией @ClassRule в статическом поле, что приведет к их активации перед первым тестом, а затем очистке сразу после последнего.

3.3. Переменные среды JUnit 5

Прежде чем использовать объекты System Stubs внутри теста JUnit 5, мы должны добавить расширение в наш тестовый класс:

@ExtendWith(SystemStubsExtension.class)
class EnvironmentVariablesJUnit5 {
// tests
}

Затем мы можем создать поле в тестовом классе, которым JUnit 5 будет управлять за нас. Мы аннотируем это @SystemStub , чтобы расширение знало, как его активировать:

@SystemStub
private EnvironmentVariables environmentVariables;

Расширение будет управлять только объектами, отмеченными @SystemStub , что позволяет нам использовать другие объекты System Stub в тесте вручную, если мы предпочитаем.

Здесь мы не предоставили никакой конструкции объекта-заглушки. Расширение создает его для нас так же, как расширение Mockito создает моки .

Теперь мы можем использовать объект, чтобы помочь нам установить переменные среды внутри одного из наших тестов:

@Test
void givenEnvironmentCanBeModified_whenSetEnvironment_thenItIsSet() {
environmentVariables.set("ENV", "value1");

assertThat(System.getenv("ENV")).isEqualTo("value1");
}

Если мы хотим предоставить переменные среды, которые применяются ко всем тестам извне тестового метода, мы можем сделать это внутри метода @BeforeEach или использовать конструктор EnvironmentVariables для установки наших значений:

@SystemStub
private EnvironmentVariables environmentVariables =
new EnvironmentVariables("ENV", "value1");

Как и в случае с EnvironmentVariablesRule , существует несколько перегрузок конструктора, позволяющих нам разными способами устанавливать нужные переменные. Мы также можем свободно использовать метод set для установки значений, если мы предпочитаем:

@SystemStub
private EnvironmentVariables environmentVariables =
new EnvironmentVariables()
.set("ENV", "value1")
.set("ENV2", "value2");

Мы также можем сделать наши поля статическими , чтобы ими можно было управлять в рамках жизненного цикла @BeforeAll / @AfterAll .

3.4. Внедрение параметров JUnit 5

Хотя размещение объектов-заглушек в полях полезно при их использовании для всех наших тестов, мы можем предпочесть использовать их только для выбранных. Этого можно добиться путем внедрения параметров JUnit 5:

@Test
void givenEnvironmentCanBeModified(EnvironmentVariables environmentVariables) {
environmentVariables.set("ENV", "value1");

assertThat(System.getenv("ENV")).isEqualTo("value1");
}

В данном случае объект EnvironmentVariables был создан для нас с его конструктором по умолчанию, что позволило нам использовать его в рамках одного теста. Объект также был активирован, чтобы он работал в среде выполнения. Это будет убрано, когда тест будет закончен.

Все объекты System Stub имеют конструктор по умолчанию и возможность перенастройки во время работы. Мы можем внедрить в наши тесты столько, сколько нам нужно.

3.5. Выполнение вокруг переменных среды

Исходные фасадные методы System Lambda для создания заглушек также доступны через класс SystemStubs . Внутренне они реализуются путем создания экземпляров объектов-заглушек. Иногда объект, возвращаемый из рецепта, является объектом-заглушкой для дальнейшей настройки и использования:

withEnvironmentVariable("ENV3", "val")
.execute(() -> {
assertThat(System.getenv("ENV3")).isEqualTo("val");
});

За кулисами withEnvironmentVariable делает эквивалент:

return new EnvironmentVariables().set("ENV3", "val");

Метод execute является общим для всех объектов SystemStub . Он устанавливает заглушку, определенную объектом, затем выполняет переданную лямбду. После этого он убирает и возвращает управление окружающему тесту.

Если тестовый код возвращает значение, то это значение может быть возвращено с помощью execute :

String extracted = new EnvironmentVariables("PROXY", "none")
.execute(() -> System.getenv("PROXY"));

assertThat(extracted).isEqualTo("none");

Это может быть полезно, когда код, который мы тестируем, должен иметь доступ к настройкам среды для создания чего-либо. Он обычно используется при тестировании таких вещей, как обработчики AWS Lambda , которые часто настраиваются с помощью переменных среды.

Преимущество этого шаблона для случайных тестов заключается в том, что мы должны явно настраивать заглушки только там, где это необходимо. Поэтому он может быть более точным и видимым. Однако это не позволяет нам делиться настройками между тестами и может быть более многословным.

3.6. Несколько системных заглушек

Мы уже видели, как плагины JUnit 4 и JUnit 5 создают и активируют для нас объекты-заглушки. Если есть несколько заглушек, они устанавливаются и удаляются соответствующим образом кодом фреймворка.

Однако, когда мы создаем объекты-заглушки для шаблона выполнения вокруг, нам нужно, чтобы наш тестовый код выполнялся внутри них всех.

Этого можно добиться с помощью методов with / execute . Они работают путем создания композита из нескольких объектов-заглушек, используемых с одним выполнением :

with(new EnvironmentVariables("FOO", "bar"), new SystemProperties("prop", "val"))
.execute(() -> {
assertThat(System.getenv("FOO")).isEqualTo("bar");
assertThat(System.getProperty("prop")).isEqualTo("val");
});

Теперь, когда мы увидели общую форму использования объектов System Stubs, как с поддержкой фреймворка JUnit, так и без нее, давайте посмотрим на остальные возможности библиотеки.

4. Системные свойства

Мы можем вызвать System.setProperty в любое время в Java. Однако при этом существует риск утечки настроек из одного теста в другой. Основная цель заглушки SystemProperties — восстановить исходные настройки свойств системы после завершения теста. Однако для общего кода настройки также полезно определить, какие системные свойства следует использовать до начала теста.

4.1. Системные свойства JUnit 4

Добавив правило в тестовый класс JUnit 4, мы можем изолировать каждый тест от любых вызовов System.setProperty , сделанных в других методах тестирования. Мы также можем предоставить некоторые предварительные свойства через конструктор:

@Rule
public SystemPropertiesRule systemProperties =
new SystemPropertiesRule("db.connection", "false");

С этим объектом мы также можем установить некоторые дополнительные свойства в методе JUnit @Before :

@Before
public void before() {
systemProperties.set("before.prop", "before");
}

Мы также можем использовать метод set в теле теста или использовать System.setProperty , если хотим. Мы должны использовать set только при создании SystemPropertiesRule или в методе @Before , так как он сохраняет настройку в правиле, готовом к применению позже.

4.2. Системные свойства JUnit 5

У нас есть два основных варианта использования объекта SystemProperties . Мы можем захотеть сбросить системные свойства после каждого тестового случая, или мы можем захотеть подготовить некоторые общие системные свойства в центральном месте для использования каждым тестовым случаем.

Для восстановления системных свойств нам необходимо добавить в наш тестовый класс как расширение JUnit 5, так и поле SystemProperties :

@ExtendWith(SystemStubsExtension.class)
class RestoreSystemProperties {
@SystemStub
private SystemProperties systemProperties;

}

Теперь каждый тест будет иметь любые системные свойства, которые впоследствии будут изменены.

Мы также можем сделать это для выбранных тестов путем внедрения параметров:

@Test
void willRestorePropertiesAfter(SystemProperties systemProperties) {

}

Если мы хотим, чтобы в тесте были установлены свойства, мы можем либо назначить эти свойства в конструкции нашего объекта SystemProperties , либо использовать метод @BeforeEach :

@ExtendWith(SystemStubsExtension.class)
class SetSomeSystemProperties {
@SystemStub
private SystemProperties systemProperties;

@BeforeEach
void before() {
systemProperties.set("beforeProperty", "before");
}
}

Опять же, отметим, что тест JUnit 5 должен быть аннотирован с помощью @ExtendWith(SystemStubsExtension.class). Расширение создаст объект System Stubs, если мы не укажем новый оператор в списке инициализаторов.

4.3. Системные свойства с Execute Around

Класс SystemStubs предоставляет метод restoreSystemProperties , позволяющий запускать тестовый код с восстановленными свойствами:

restoreSystemProperties(() -> {
// test code
System.setProperty("unrestored", "true");
});

assertThat(System.getProperty("unrestored")).isNull();

Для этого требуется лямбда, которая ничего не возвращает. Если мы хотим использовать общую функцию настройки для создания свойств, получения возвращаемого значения из тестового метода или объединения SystemProperties с другими заглушками с помощью / execute , то мы можем создать объект явно:

String result = new SystemProperties()
.execute(() -> {
System.setProperty("unrestored", "true");
return "it works";
});

assertThat(result).isEqualTo("it works");
assertThat(System.getProperty("unrestored")).isNull();

4.4. Свойства в файлах

Объекты SystemProperties и EnvironmentVariables могут быть созданы из Map . Это позволяет предоставлять объект свойств Java в качестве источника либо системных свойств, либо переменных среды. ``

Внутри класса PropertySource есть вспомогательные методы для загрузки свойств Java из файлов или ресурсов. Эти файлы свойств представляют собой пары имя/значение:

name=foreach
version=1.0

Мы можем загрузить из ресурса test.properties с помощью функции fromResource :

SystemProperties systemProperties =
new SystemProperties(PropertySource.fromResource("test.properties"));

В PropertySource есть аналогичные удобные методы для других источников, таких как fromFile или fromInputStream .

5. Системный выход и системная ошибка

Когда наше приложение пишет в System.out, его может быть сложно протестировать. Иногда это решается использованием интерфейса в качестве цели вывода и имитацией его во время тестирования:

interface LogOutput {
void write(String line);
}

class Component {
private LogOutput log;

public void method() {
log.write("Some output");
}
}

Подобные методы хорошо работают с моками Mockito , но в них нет необходимости, если мы можем просто перехватить сам System.out .

5.1. JUnit 4 SystemOutRule и SystemErrRule

Чтобы перехватывать выходные данные в System.out в тесте JUnit 4, мы добавляем SystemOutRule :

@Rule
public SystemOutRule systemOutRule = new SystemOutRule();

После этого любой вывод в System.out можно прочитать в тесте:

System.out.println("line1");
System.out.println("line2");

assertThat(systemOutRule.getLines())
.containsExactly("line1", "line2");

У нас есть выбор форматов для текста. В приведенном выше примере используется Stream<String> , предоставленный getLines . Мы также можем выбрать получение всего блока текста:

assertThat(systemOutRule.getText())
.startsWith("line1");

Однако мы должны отметить, что этот текст будет иметь символы новой строки, которые различаются в зависимости от платформы. Мы можем заменить новые строки на \n на каждой платформе, используя нормализованную форму:

assertThat(systemOutRule.getLinesNormalized())
.isEqualTo("line1\nline2\n");

SystemErrRule работает для System.err так же, как и его аналог System.out :

@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");
}

Существует также класс SystemErrAndOutRule , который одновременно подключает и System.out , и System.err к одному буферу.

5.2. Пример JUnit 5

Как и в случае с другими объектами System Stub, нам нужно только объявить поле или параметр типа SystemOut или SystemErr . Это даст нам захват вывода:

@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");
}

Мы также можем использовать класс SystemErrAndOut , чтобы направить оба набора выходных данных в один и тот же буфер.

5.3. Пример выполнения вокруг

Фасад SystemStubs предоставляет некоторые функции для прослушивания вывода и возврата его в виде строки :

@Test
void givenTapOutput_thenGetOutput() throws Exception {
String output = tapSystemOutNormalized(() -> {
System.out.println("a");
System.out.println("b");
});

assertThat(output).isEqualTo("a\nb\n");
}

Мы должны отметить, что эти методы не предоставляют такой богатый интерфейс, как сами необработанные объекты. Захват вывода не может быть легко объединен с другими заглушками, такими как установка переменных среды.

Однако объекты SystemOut , SystemErr и SystemErrAndOut можно использовать напрямую. Например, мы могли бы объединить их с некоторыми SystemProperties :

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");
});

Мы можем добиться того же во всех тестах с помощью JUnit 4 SystemOutRule :

@Rule
public SystemOutRule systemOutRule = new SystemOutRule(new NoopStream());

В JUnit 5 мы можем использовать ту же технику:

@SystemStub
private SystemOut systemOut = new SystemOut(new NoopStream());

5.5. Настройка

Как мы видели, существует несколько вариантов перехвата вывода. Все они имеют общий базовый класс в библиотеке. Для удобства несколько вспомогательных методов и типов, таких как SystemErrAndOut, помогают выполнять обычные действия. Однако сама библиотека легко настраивается.

Мы могли бы предоставить нашу собственную цель для захвата вывода как реализацию Output . Мы уже видели использование TapStream класса Output в первых примерах. NoopStream используется для отключения звука. Еще у нас есть DisallowWriteStream , который выдает ошибку, если в него что-то пишет: ``

// throws an exception:
new SystemOut(new DisallowWriteStream())
.execute(() -> System.out.println("boo"));

6. Насмешливая система

У нас может быть приложение, которое считывает ввод со стандартного ввода . Тестирование может включать извлечение алгоритма в функцию, которая считывает данные из любого InputStream , а затем передает ей предварительно подготовленный входной поток. В общем, модульный код лучше, так что это хороший шаблон.

Однако, если мы тестируем только основные функции, мы теряем тестовое покрытие кода, который предоставляет System.in в качестве источника.

В любом случае создавать собственные потоки может быть неудобно. К счастью, у System Stubs есть решения для всех этих проблем.

6.1. Тестовые входные потоки

System Stubs предоставляет семейство классов AltInputStream в качестве альтернативных входных данных для любого кода, считывающего данные из InputStream :

LinesAltStream testInput = new LinesAltStream("line1", "line2");

Scanner scanner = new Scanner(testInput);
assertThat(scanner.nextLine()).isEqualTo("line1");

В этом примере мы использовали массив строк для создания LinesAltStream , но мы могли бы предоставить входные данные из Stream<String> , что позволяет использовать его с любым источником текстовых данных без обязательной загрузки всего в память сразу . .

6.2. Пример JUnit 4

Мы можем предоставить строки для ввода в тесте JUnit 4, используя SystemInRule :

@Rule
public SystemInRule systemInRule =
new SystemInRule("line1", "line2", "line3");

Затем тестовый код может прочитать этот ввод из System.in :

@Test
public void givenInput_canReadFirstLine() {
assertThat(new Scanner(System.in).nextLine())
.isEqualTo("line1");
}

6.3. Пример JUnit 5

Для тестов JUnit 5 мы создаем поле SystemIn :

@SystemStub
private SystemIn systemIn = new SystemIn("line1", "line2", "line3");

Затем наши тесты будут запускаться с System.in , предоставляющим эти строки в качестве входных данных.

6.4. Пример выполнения вокруг

Фасад SystemStubs предоставляет withTextFromSystemIn в качестве фабричного метода, который создает объект SystemIn для использования с его методом execute :

withTextFromSystemIn("line1", "line2", "line3")
.execute(() -> {
assertThat(new Scanner(System.in).nextLine())
.isEqualTo("line1");
});

6.5. Настройка

Дополнительные функции могут быть добавлены к объекту SystemIn либо при построении, либо во время его выполнения в рамках теста.

Мы можем вызвать andExceptionThrownOnInputEnd , что приведет к тому, что чтение из System.in вызовет исключение, когда закончится текст. Это может имитировать прерванное чтение из файла.

We can also set the input stream to come from any InputStream , like FileInputStream , by using setInputStream . We also have LinesAltStream and TextAltStream , which operate on the input text.

7. Mocking System.Exit

As mentioned previously, if our code can call System.exit , it can make for dangerous and hard to debug test faults. One of our aims in stubbing System.exit is to make an accidental call into a traceable error. Another motivation is to test intentional exits from the software.

7.1. JUnit 4 Example

Let's add the SystemExitRule to a test class as a safety measure to prevent any System.exit from stopping the JVM:

@Rule
public SystemExitRule systemExitRule = new SystemExitRule();

However, we may also wish to see if the right exit code was used . For that, we need to assert that the code throws the AbortExecutionException , which is the System Stubs signal that System.exit was called.

@Test
public void whenExit_thenExitCodeIsAvailable() {
assertThatThrownBy(() -> {
System.exit(123);
}).isInstanceOf(AbortExecutionException.class);

assertThat(systemExitRule.getExitCode()).isEqualTo(123);
}

In this example, we've used assertThatThrownBy from AssertJ to catch and check the exception signaling exit occurred. Then we looked at getExitCode from the SystemExitRule to assert the exit code.

7.2. JUnit 5 Example

For JUnit 5 tests, we declare the @SystemStub field:

@SystemStub
private SystemExit systemExit;

Then we use the SystemExit class in the same way as SystemExitRule in JUnit 4. Given that the SystemExitRule class is a subclass of SystemExit , they have the same interface.

7.3. Execute-Around Example

The SystemStubs class provides catchSystemExit, which internally uses SystemExit ‘s execute function:

int exitCode = catchSystemExit(() -> {
System.exit(123);
});
assertThat(exitCode).isEqualTo(123);

Compared with the JUnit plugin examples, this code does not throw an exception to indicate a system exit. Instead, it catches the error and records the exit code. With the facade method, it returns the exit code.

When we use the execute method directly, the exit is caught, and the exit code is set inside the SystemExit object. We can then call getExitCode to get the exit code, or null if there was none.

8. Custom Test Resources in JUnit 5

JUnit 4 already provides a simple structure for creating test rules like the ones used in System Stubs. If we want to make a new test rule for some resource, with a setup and teardown, we can subclass ExternalResource and provide overrides of the before and after methods.

JUnit 5 has a more complex pattern for resource management. For simple use cases, it's possible to use the System Stubs library as a starting point. The SystemStubsExtension operates on anything that satisfies the TestResource interface.

8.1. Creating a TestResource

We can create a subclass of TestResource and then use our custom objects in the same way we use System Stubs ones. We should note that we need to provide a default constructor if we want to use the automatic creation of fields and parameters.

Let's say we wanted to open a connection to a database for some tests and close it afterward:

public class FakeDatabaseTestResource implements TestResource {
// let's pretend this is a database connection
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;
}
}

We're using the databaseConnection string as an illustration of a resource like a database connection. We modify the state of the resource in the setup and teardown methods.

8.2. Execute-Around Is Built-In

Now let's try using this with the execute-around pattern:

FakeDatabaseTestResource fake = new FakeDatabaseTestResource();
assertThat(fake.getDatabaseConnection()).isEqualTo("closed");

fake.execute(() -> {
assertThat(fake.getDatabaseConnection()).isEqualTo("open");
});

As we can see, the TestResource interface gave it the execute-around capabilities of the other objects.

8.3. Custom TestResource in JUnit 5 Test

We can also use this inside a JUnit 5 test:

@ExtendWith(SystemStubsExtension.class)
class FakeDatabaseJUnit5UnitTest {

@Test
void useFakeDatabase(FakeDatabaseTestResource fakeDatabase) {
assertThat(fakeDatabase.getDatabaseConnection()).isEqualTo("open");
}
}

So, it is easy to create additional test objects that follow the System Stubs design.

9. Environment and Property Overrides for JUnit 5 Spring Tests

Setting environment variables for Spring tests can be difficult. We might compose a custom rule for integration testing to set some system properties for Spring to pick up.

We may also use an ApplicationContextInitializer class to plug into our Spring Context , providing extra properties for the test.

As many Spring applications are controlled by system property or environment variable overrides, it may be easier to use System Stubs to set these in an outer test, with the Spring test running as an inner class.

There's a full example provided in the System Stubs documentation. We start by creating an outer class:

@ExtendWith(SystemStubsExtension.class)
public class SpringAppWithDynamicPropertiesTest {

// sets the environment before Spring even starts
@SystemStub
private static EnvironmentVariables environmentVariables;
}

In this instance, the @ SystemStub field is static and is initialized in the @BeforeAll method:

@BeforeAll
static void beforeAll() {
String baseUrl = ...;

environmentVariables.set("SERVER_URL", baseUrl);
}

This point in the test lifecycle allows some global resources to be created and applied to the running environment before the Spring test runs.

Then, we can put the Spring test into a @Nested class. This causes it to be run only when the parent class is set up:

@Nested
@SpringBootTest(classes = {RestApi.class, App.class},
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class InnerSpringTest {
@LocalServerPort
private int serverPort;

// Test methods
}

The Spring context is created against the state of the environment set by the @ SystemStub objects in the outer class.

This technique also allows us to control the configuration of any other libraries that depend on the state of system properties or environment variables that may be running behind Spring Beans.

This can allow us to hook into the test lifecycle to modify things like proxy settings or HTTP connection pool parameters before a Spring test runs.

10. Conclusion

In this article, we've looked at the importance of being able to mock system resources and how System Stubs allows for complex configurations of stubbing with a minimum of code repetition through its JUnit 4 and JUnit 5 plugins.

We saw how to provide and isolate environment variables and system properties in our tests. Then we looked at capturing the output and controlling the input on the standard streams. We also looked at capturing and asserting calls to System.exit .

Finally, we looked at how to create custom test resources and how to use System Stubs with Spring.

Как всегда, полный исходный код примеров доступен на GitHub .