Рубрики
Без рубрики

Модульное тестирование на Java с JUnit 5

Автор оригинала: Olivera Popović.

Вступление

JUnit – популярная платформа тестирования для Java. Простое использование очень просто, и JUnit 5 привнес некоторые отличия и удобства по сравнению с JUnit 4.

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

Установка JUnit 5

Установка JUnit так же проста, как включение зависимостей:


    org.junit.jupiter
    junit-jupiter-api
    5.4.0-RC1
    test


    org.junit.jupiter
    junit-jupiter-engine
    5.4.0-RC1
    test

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

Как правило, рекомендуемая структура проекта такова:

Примечание: Настоятельно рекомендуется импортировать JUnit 5 с помощью модификатора static , это сделает использование предоставленных методов намного чище и удобочитаемее.

Различия между JUnit 4 и JUnit 5

Одна из главных идей, лежащих в основе новой версии JUnit, заключается в использовании функций Java 8, представленных на столе (в основном лямбды), чтобы облегчить жизнь каждому. Некоторые незначительные вещи были изменены – необязательное сообщение о том, что утверждение будет напечатано, если оно не удастся, теперь является последним “необязательным” аргументом, а не неудобным первым.

JUnit 5 состоит из трех проектов (платформа JUnit, JUnit Jupiter и JUnit Vintage), поэтому будет несколько разных импортных проектов, хотя JUnit Jupiter будет нашим основным направлением.

Некоторые другие различия включают:

  • Минимальный JDK для JUnit 4 был JDK 5, в то время как для JUnit 5 требуется не менее JDK8
  • Аннотации @До , @Перед классом , @После и @после класса теперь более читабельны , чем аннотации @до , @до , @после и @после .
  • @Игнорировать теперь @Отключить
  • @Категория теперь @Тег
  • Поддержка вложенных классов тестирования и добавленная фабрика тестов для динамических тестов

Аннотация @Test

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

public class Calculator {
    float add(float a, float b) {
        return a + b;
    }

    int divide(int a, int b) {
        return a/b;
    }
}

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

class CalculatorTest {

    @Test
    void additionTest() {
        Calculator calc = new Calculator();
        assertEquals(2, calc.add(1,1), "The output should be the sum of the two arguments");
    }
}

Аннотация @Test сообщает JVM, что следующий метод является тестом. Эта аннотация необходима перед каждым методом тестирования.

Метод assertEquals() и все методы “утверждения” работают аналогично – они утверждают (т. Е. удостоверяются), что все, что мы проверяем, является истинным . В этом случае мы утверждаем , что два переданных нами аргумента равны (см. Примечание ниже), в случае, если это не так – тест провалится .

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

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

Мы запускаем тесты, просто запустив класс CalculatorTest (мы можем это сделать, даже если у него нет метода main ).:

Если бы мы изменили строку assertEquals() на что-то неправильное, например:

assertEquals(1, calc.add(1,1), "The output should be the sum of the two arguments");

Мы получим соответствующее сообщение об ошибке тестирования:

Примечание: Очень важно понимать, что assertEquals() на самом деле использует метод .equals () , а не оператор == . Существует отдельный метод JUnit под названием assertSame () , который использует == вместо .equals() .

Методы утверждения

JUnit 5 поставляется со многими методами утверждения . Некоторые из них являются просто удобными методами, которые можно легко заменить методом assertEquals() или assertSame () . Однако вместо этого рекомендуется использовать эти удобные методы для удобства чтения и простоты обслуживания.

Например, вызов assertNull(объект, сообщение) можно заменить на assertSame(null, объект, сообщение) , но рекомендуется использовать прежнюю форму.

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

  • assertEquals() и assertNotEquals()

  • assertSame() и assertNotSame()

  • assertFalse() и assertTrue()

  • assert Throws() утверждает, что метод выдаст данное исключение, когда столкнется с возвращаемым значением тестируемого метода

  • assertArrayEquals(ожидаемый массив, фактический массив, необязательный Msg) сравнивает два массива и передает только в том случае, если они содержат одни и те же элементы в одних и тех же позициях, в противном случае это не удается. Если оба массива равны null , они считаются равными.

  • assertIterableEquals(Итерируемые ожидаемые, Итерируемые фактические, необязательные Msg) гарантирует, что ожидаемые и фактические итерируемые значения глубоко равны. Поскольку этот метод принимает Итерируемый в качестве двух аргументов, передаваемые нами итерируемые объекты не обязательно должны быть одного типа (например , мы можем передать LinkedList и ArrayList ). Однако их итераторы должны возвращать одинаковые элементы в том же порядке, что и друг друга. Опять же, если оба равны нулю , они считаются равными.

  • утверждение совпадения строк(Список<Строка> ожидаемый, Список<Строка> фактический, необязательный Msg) является несколько более сложным методом, поскольку он выполняет несколько шагов, прежде чем объявить, что переданные аргументы не равны, и работает только с Строкой s:

    1. Он проверяет , равен ли ожидаемый. (фактический) возвращает истину , если это так, он переходит к следующим записям.
    2. Если шаг 1 не возвращает true , текущая ожидаемая строка обрабатывается как регулярное выражение, поэтому метод проверяет, соответствует ли фактическому. (ожидаемому) , и если это так, он переходит к следующим записям.
    3. Если ни один из двух описанных выше шагов не возвращает true , последняя попытка метода состоит в том, чтобы проверить, является ли следующая строка строкой быстрой перемотки вперед. Строка быстрой перемотки начинается и заканчивается “>>”, между которыми находится либо целое число (пропускается количество обозначенных строк), либо строка.
  • < расширяет выбрасываемый> T утверждает, что выбрасывает(класс Ожидаемый тип, исполняемый файл exec, необязательный Msg) проверяет, что выполнение исполняемого файла вызывает исключение Ожидаемого типа и возвращает это исключение. Если исключение не вызвано или если вызванное исключение не относится к ожидаемому типу – тест завершается неудачно. расширяет выбрасываемый> T утверждает, что выбрасывает(класс Ожидаемый тип, исполняемый файл exec, необязательный Msg)

  • проверяет, что выполнение исполняемого файла вызывает исключение Ожидаемого типа и возвращает это исключение. Если исключение не вызвано или если вызванное исключение не относится к ожидаемому типу - тест завершается неудачно. тайм-аут утверждения(тайм-аут продолжительности, исполняемый файл exec, необязательный Msg) проверяет, что exec

  • завершает выполнение до превышения заданного тайм-аута. Поскольку exec выполняется в том же потоке, что и вызывающий код, выполнение не будет предварительно прервано, если превышен тайм-аут. Другими словами - exec завершает свое выполнение независимо от тайм-аута , метод просто проверяет, достаточно ли быстро он был выполнен. Asserttimeout упреждающе(тайм-аут продолжительности, исполняемый файл exec, optionalMsg) проверяет, что выполнение exec завершается до превышения заданного тайм-аута, но, в отличие от метода assertTimeout

  • , этот метод выполняет exec в другом потоке и будет

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

    тайм-аут

    превышен. assert All(Исполняемые… исполняемые файлы) выдает ошибку множественного сбоя

    и

    assert All(исполняемые файлы<Исполняемый файл>) выдает ошибку множественного сбоя

    делает что-то очень полезное. А именно, если бы мы хотели использовать несколько утверждений в одном тесте (это не обязательно плохо, если мы это сделаем), произошло бы что-то очень раздражающее, если бы все они пошли плохо. Именно: Когда первое утверждение потерпит неудачу, мы не увидим, как прошли два других. Что может быть особенно неприятно, так как вы можете исправить первое утверждение, надеясь, что оно исправит весь тест, только чтобы обнаружить, что второе утверждение также провалилось, только вы этого не видели, так как первое утверждение “скрыло” этот факт: по сути() решает эту проблему, выполняя все утверждения, а затем показывая вам сбой, даже если несколько утверждений не удалось. Переписанная версия будет: Теперь мы получим более информативный результат тестирования:

Примечание: Возможно, вы заметили, что Строка, необязательная для Msg , исключена из объявлений методов. JUnit 5 обеспечивает небольшую оптимизацию для дополнительного Msg . Мы, конечно, можем использовать простую Строку в качестве нашего optionalMsg – однако, независимо от того , как пройдет тест (завершится ли он неудачно или нет), Java все равно сгенерирует эту Строку , даже если она никогда не будет распечатана. Это не имеет значения, когда мы делаем что-то вроде:

assertEquals(expected, actual, "The test failed for some reason");

Но если бы у нас было что-то вроде:

assertEquals(expected, actual, "The test failed because " + (Math.sqrt(50) + Math.scalb(15,7) + Math.cosh(10) + Math.log1p(23)) + " is not a pretty number");

Вы действительно не хотите, чтобы что-то подобное дополнительная загрузка Msg загружалась независимо от того, планирует ли Java ее распечатать.

Решение состоит в том, чтобы использовать Поставщик<Строка> . Таким образом , мы сможем использовать преимущества ленивой оценки , если вы никогда не слышали об этой концепции, это в основном Java, говорящая: “Я не буду вычислять ничего, что мне не нужно. Нужна ли мне эта Строка прямо сейчас? Нет? Тогда я не буду его создавать”. Ленивая оценка появляется несколько раз в Java.

Это можно сделать, просто добавив () -> перед нашим дополнительным сообщением. Так что это становится:

assertEquals(expected, actual, () -> "The test failed because " + (Math.sqrt(50) + Math.scalb(15,7) + Math.cosh(10) + Math.log1p(23)) + " is not a pretty number");

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

Аннотации Testng

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

Это плохая идея объявлять глобальные переменные, которые изменяются в рамках разных методов тестирования, и это особенно плохая идея ожидать любого типа порядка тестирования, нет никаких гарантий, в каком порядке будут выполняться методы тестирования!

Еще одна плохая идея-постоянно инициализировать класс, который мы хотим протестировать, если в этом нет необходимости. Мы скоро увидим, как этого избежать, но перед этим давайте взглянем на доступные аннотации:

Git Essentials

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

  • @beforeEach : Метод с этой аннотацией вызывается перед каждым методом тестирования, что очень полезно, когда мы хотим, чтобы методы тестирования имели общий код. Методы должны иметь тип возврата void , не должны быть частными и не должны быть статическими .
  • @beforeAll : Метод с этой аннотацией вызывается только один раз , перед запуском любого из тестов, в основном используется вместо @beforeEach , когда общий код дорог, например, для установления соединения с базой данных. Метод @beforeAll по умолчанию должен быть статическим ! Он также не должен быть частным и должен иметь тип возврата void .
  • @AfterAll : Метод с этой аннотацией вызывается только один раз после вызова каждого метода тестирования. Обычно используется для закрытия соединений, установленных @beforeAll . Метод должен иметь тип возврата void , не должен быть частным и должен быть статическим .
  • @afterEach : Метод с этой аннотацией вызывается после того, как каждый тестовый метод завершает свое выполнение. Методы должны иметь тип возврата void , не должны быть частными и не должны быть статическими .

Чтобы проиллюстрировать, когда выполняется каждый из этих методов, мы добавим некоторую изюминку в наш класс CalculatorTest , и пока мы этим занимаемся, продемонстрируем использование метода assertThrows() :

class CalculatorTest {

    Calculator calc;

    @BeforeAll
    static void start() {
        System.out.println("inside @BeforeAll");
    }

    @BeforeEach
    void init() {
        System.out.println("inside @BeforeEach");
        calc = new Calculator();
    }

    @Test
    void additionTest() {
        System.out.println("inside additionTest");
        assertAll(
            () -> assertEquals(2, calc.add(1,1), "Doesn't add two positive numbers properly"),
            () -> assertEquals(0, calc.add(-1,1), "Doesn't add a negative and a positive number properly"),
            () -> assertNotNull(calc, "The calc variable should be initialized")
        );
    }

    @Test
    void divisionTest() {
        System.out.println("inside divisionTest");
        assertThrows(ArithmeticException.class, () -> calc.divide(2,0));
    }

    @AfterEach
    void afterEach() {
        System.out.println("inside @AfterEach");
    }

    @AfterAll
    static void close() {
        System.out.println("inside @AfterAll");
    }
}

Что дает нам результат:

inside @BeforeAll

inside @BeforeEach
inside divisionTest
inside @AfterEach


inside @BeforeEach
inside additionTest
inside @AfterEach

inside @AfterAll

Это также показывает нам, что, несмотря на то, что метод addition Test() объявлен первым, это не гарантирует, что он будет выполнен первым.

Другие Аннотации

До JUnit 5 методы тестирования не могли иметь никаких параметров, но теперь они могут. Мы будем использовать их при демонстрации новых аннотаций.

@Отключено

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

@Disabled
@Test
void additionTest() {
    // ...
}

Выдает следующие выходные данные для этого метода тестирования:

void main.CalculatorTest.additionTest() is @Disabled
@Имя дисплея

Еще одна простая аннотация, которая изменяет отображаемое имя метода тестирования.

@DisplayName("Testing addition")
@Test
void additionTest() {
    // ...
}
@Тег

Аннотация @Tag полезна, когда мы хотим создать “пакет тестов” с выбранными тестами. Теги используются для фильтрации выполняемых тестов:

class SomeTest {
    @Tag("a")
    @Test
    void test1() {
        // ...
    }
    @Tag("a")
    @Test
    void test2() {
        // ...
    }
    @Tag("b")
    @Test
    void test3() {
        // ...
    }
}

Поэтому, если бы мы хотели запускать только тесты с тегом “a”, мы бы выбрали “Выполнить” – > “Редактировать конфигурации” и изменили следующие два поля перед запуском теста:

@Повторное тестирование

Эта аннотация работает так же, как и аннотация @Test , но выполняет метод тестирования указанное количество раз. Каждая итерация теста может иметь собственное имя, используя комбинацию динамических заполнителей и статического текста. В настоящее время доступны заполнители:

  • {DisplayName} : отображаемое имя метода @RepeatedTest
  • {{текущее повторение} : текущее количество повторений
  • {{общее количество повторений} : общее количество повторений

Имя по умолчанию каждой итерации – “повторение {текущее повторение} {всего повторений}”.

//@RepeatedTest(5)
@DisplayName("Repeated Test")
@RepeatedTest(value = 5, name = "{displayName} -> {currentRepetition}")
void rptdTest(RepetitionInfo repetitionInfo) {
    int arbitrary = 2;
    System.out.println("Current iteration: " + repetitionInfo.getCurrentRepetition());

    assertEquals(arbitrary, repetitionInfo.getCurrentRepetition());
}

Параметр Информация о повторении не является необходимым, но мы можем получить к нему доступ, если нам нужны эти данные. Мы получаем четкое представление о каждой итерации, когда запускаем это:

@@Параметризованный тест

Параметризованные тесты также позволяют запускать тест несколько раз, но с разными аргументами .

Это работает аналогично @RepeatedTest , поэтому мы не будем повторять все снова, только различия.

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

@ParameterizedTest
@ValueSource(ints = {6,8,2,9})
void lessThanTen(int number) {
    assertTrue(number < 10, "the number isn't less than 10");
}

Метод будет получать элементы массива один за другим:

@@ValueSource – это всего лишь один тип аннотации, который прилагается к @Parameterizedtest . Для получения списка других возможностей ознакомьтесь с документацией .

@Вложенные

Эта аннотация позволяет нам группировать тесты там, где это имеет смысл. Мы могли бы захотеть отделить тесты, которые имеют дело с сложением, от тестов, которые имеют дело с делением, умножением и т. Д.; И это предоставляет нам простой способ @Полностью отключить определенные группы. Это также позволяет нам попытаться сделать полные английские предложения в качестве результатов теста, что делает его чрезвычайно читабельным.

@DisplayName("The calculator class: ")
class CalculatorTest {
    Calculator calc;

    @BeforeEach
    void init() {
        calc = new Calculator();
    }

    @Nested
    @DisplayName("when testing addition, ")
    class Addition {
        @Test
        @DisplayName("with positive numbers ")
        void positive() {
            assertEquals(100, calc.add(1,1), "the result should be the sum of the arguments");
        }

        @Test
        @DisplayName("with negative numbers ")
        void negative() {
            assertEquals(100, calc.add(-1,-1), "the result should be the sum of the arguments");
        }
    }

    @Nested
    @DisplayName("when testing division, ")
    class Division {
        @Test
        @DisplayName("with 0 as the divisor ")
        void throwsAtZero() {
            assertThrows(ArithmeticException.class, () -> calc.divide(2,0), "the method should throw and ArithmeticException");
        }
    }
}
@testInstance

Эта аннотация используется только для аннотирования тестового класса с помощью @testInstance(Жизненный цикл.PER_CLASS) чтобы указать JUnit запускать все методы тестирования в одном экземпляре тестового класса, а не создавать новый экземпляр класса для каждого метода тестирования.

Это позволяет нам использовать переменные уровня класса и делиться ими между методами тестирования (обычно не рекомендуется), например, инициализировать ресурсы за пределами @beforeAll или @beforeEach метода и @beforeAll и @AfterAll больше не нужно быть статическим . Таким образом, режим “для каждого класса” также позволяет использовать @до и @после методы в @вложенных тестовых классах.

Большинство вещей, которые мы можем сделать с @testInstance(Жизненный цикл.ДЛЯ КАЖДОГО КЛАССА) можно сделать с помощью статических переменных. Мы должны быть осторожны , чтобы сбросить все переменные, которые требовали сброса до определенного значения в @beforeEach , которые обычно сбрасывались каждый раз при повторной инициализации класса.

Допущения

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

  • assumeTrue(логическое предположение, необязательно Msg) и предположим, что False(логическое предположение, необязательно Msg) будет выполняться тест только в том случае, если предоставленное предположение является истинным и ложным соответственно. Необязательный Msg будет отображаться только в том случае, если предположение неверно.
  • предполагая, что(логическое предположение, исполняемый файл exec) – если предположение верно, exec будет выполнен, в противном случае этот метод ничего не делает.

Логический поставщик может использоваться вместо обычного логического .

class CalculatorTest {

    Calculator calc;
    boolean bool;

    @BeforeEach
    void init() {
        System.out.println("inside @BeforeEach");
        bool = false;
        calc = new Calculator();
    }

    @Test
    void additionTest() {
        assumeTrue(bool, "Java sees this assumption isn't true -> stops executing the test.");
        System.out.println("inside additionTest");
        assertAll(
                () -> assertEquals(2, calc.add(1,1), "Doesn't add two positive numbers properly"),
                () -> assertEquals(0, calc.add(-1,1), "Doesn't add a negative and a positive number properly"),
                () -> assertNotNull(calc, "The calc variable should be initialized"));
    }

    @Test
    void divisionTest() {
        assumeFalse(0 > 5, "This message won't be displayed, and the test will proceed");
        assumingThat(!bool, () -> System.out.println("\uD83D\uDC4C"));
        System.out.println("inside divisionTest");
        assertThrows(ArithmeticException.class, () -> calc.divide(2,0));
    }
}

Что дало бы нам результат:

inside @BeforeEach
👌
inside divisionTest


inside @BeforeEach


org.opentest4j.TestAbortedException: Assumption failed: Java sees this assumption isn't true -> stops executing the test.

Заключение и советы

Большинство из нас тестируют код, вручную выполняя код, вводя некоторые входные данные или нажимая некоторые кнопки и проверяя вывод. Эти “тесты” обычно представляют собой один общий сценарий и кучу крайних случаев, о которых мы можем подумать. Это относительно нормально для небольших проектов, но становится совершенно расточительным для чего-либо большего. Тестирование конкретного метода особенно плохо – мы либо System.out.println() выводим и проверяем его, либо запускаем его через некоторые операторы if , чтобы увидеть, соответствует ли он ожиданиям, затем мы меняем код всякий раз, когда хотим проверить, что происходит, когда мы передаем другие аргументы методу. Мы визуально, вручную сканируем все необычное.

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

Как правило, вы хотите проверить общую причину всего, что можете. Даже простые, прямолинейные методы, просто чтобы убедиться, что они работают так, как должны. Это может быть даже самой важной частью автоматизированного тестирования – поскольку всякий раз, когда вы что-то меняете в своем коде или добавляете новый модуль, вы можете запускать тесты, чтобы увидеть, нарушили ли вы код или нет, чтобы убедиться, что все по-прежнему работает так, как это было до “улучшения”. Конечно, крайние случаи также важны, особенно для более сложных методов.

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