сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Тестирование на Java

JUnit 5: основы

JUnit: изоляция, которую делают за тебя

Написал класс с двумя тестами и полем-счётчиком, который каждый тест увеличивает на единицу. Оба теста напечатали counter=1. Проверил идентичность объектов - их оказалось ДВА: под каждый тест JUnit создаёт новый экземпляр класса. А ещё второй тест выполнился раньше первого, хотя в коде объявлен ниже.

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

// Формулировки: «опиши жизненный цикл теста», «зачем @BeforeEach, если есть конструктор?», «как проверить, что метод бросает исключение?», «в каком порядке выполняются тесты?».

Жизненный цикл и почему он такой

Порядок из моего замера, полностью: BeforeAll один раз на весь класс, потом на КАЖДЫЙ тест - BeforeEach, сам тест, AfterEach, и в конце AfterAll. Между тестами создаётся новый объект класса, поэтому поля начинаются с чистого листа.

Отсюда ответ на вопрос «зачем @BeforeEach, если есть конструктор». Конструктор тоже вызывается перед каждым тестом, но в нём ещё не отработали расширения фреймворка: не внедрены зависимости, не подставлены моки. @BeforeEach выполняется позже и видит уже собранную обстановку.

// BeforeAll и AfterAll статические именно потому, что относятся ко всему классу, а не к экземпляру - экземпляров-то много. В них кладут дорогое и общее: поднять контейнер с базой, прочитать большой файл. Всё, что должно быть свежим у каждого теста, живёт в BeforeEach.

BeforeAll
  BeforeEach
    тест 2, counter=1
  AfterEach
  BeforeEach
    тест 1, counter=1
  AfterEach
AfterAll

разных экземпляров класса теста: 2
@BeforeEach / @BeforeAll
перед каждым тестом / один раз на класс, поэтому статический
изоляция
новый экземпляр на каждый тест, поля не переносятся

Проверки, которые стоит знать

Сравнение сложных значений работает по содержимому, и сообщение об ошибке само говорит, что не сошлось: у меня сравнение двух списков дало expected: <[1, 2, 3]> but was: <[1, 3, 2]>. Отсюда правило: сравнивай целые объекты, а не разбирай их по полям вручную - тогда провал сразу читаем.

Обычная цепочка проверок останавливается на первой неудаче, и ты видишь одну проблему из трёх. Проверка группой это меняет: в моём замере она выполнила обе проверки и выдала Multiple Failures (2 failures) с обоими сообщениями сразу. Удобно, когда проверяешь несколько полей одного результата.

Исключения проверяют не через try-catch с пометкой о провале, а специальной конструкцией: она выполняет код, требует, чтобы исключение вылетело, и ВОЗВРАЩАЕТ его. Дальше можно проверить сообщение и причину. Без возврата тест ловил бы сам факт исключения, но не его содержание.

var e = assertThrows(IllegalArgumentException.class,
                     () -> service.pay(-100));
assertEquals("сумма должна быть положительной", e.getMessage());

// сравнение списков при провале:
// expected: <[1, 2, 3]> but was: <[1, 3, 2]>

// группа проверок:
// Multiple Failures (2 failures) ... первая ... вторая
assertThrows
требует исключение и возвращает его для дальнейших проверок
assertAll
выполняет все проверки и показывает все провалы разом

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

Мой замер показал, что тесты выполняются не в порядке объявления. Это сделано намеренно: порядок стабилен между запусками, но специально не совпадает с исходным кодом, чтобы никто не начал на него закладываться. Тест, который работает только после соседа, - сломанный тест.

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

// Расширения - способ вынести общую обстановку: поднять базу, подставить фиксированное время, подчистить за тестом. Своё расширение подключается аннотацией к классу и работает у всех тестов внутри, поэтому одинаковую подготовку не приходится копировать по проекту.

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

Как отвечать: «Как проверить, что метод бросает исключение?»

Специальной конструкцией assertThrows: передаю ожидаемый тип и код в виде лямбды. Она проверяет, что исключение действительно вылетело и что оно нужного типа, а главное - возвращает сам объект исключения. Дальше я проверяю сообщение и причину, потому что «упало хоть что-то» это слабая проверка: метод мог свалиться совсем по другому поводу и тест всё равно был бы зелёным. Ловить исключение вручную через try-catch я не стал бы: тогда легко забыть пометить провалом случай, когда исключения НЕ было, и тест начнёт молча проходить всегда. И ещё слежу, чтобы в лямбде была ровно одна операция, которая должна упасть, иначе непонятно, какая именно строчка бросила исключение.

Почему это сильный ответ: назван инструмент, объяснено, зачем нужен возвращаемый объект, и разобрана ошибка ручного try-catch, из-за которой тест перестаёт что-либо проверять.

На чём валят

  • Закладываться на порядок тестов. У меня второй тест выполнился раньше первого - порядок специально не совпадает с исходным кодом.
  • Переносить состояние между тестами через поле. На каждый тест создаётся новый экземпляр, поле обнулится - я это проверял счётчиком.
  • Ловить исключение вручную и забыть про случай, когда его не было. Тест станет зелёным навсегда.
  • Проверять только тип исключения. Метод мог упасть по другой причине, а тест этого не заметит.
  • Разбирать результат по полям вместо сравнения целиком. Сообщение о провале перестаёт объяснять, что именно разошлось.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 15, остальные разбираются в тренажёре.

  1. #junit_basics1 / 5
    Тест кладёт значение в поле объекта, а следующий тест ожидает его там же — но поле пустое. Почему?
    A)Поле следовало объявить volatile, иначе его значение не видно другому тест-методу
    B)JUnit сбрасывает все поля в null между тестами через рефлексию
    C)JUnit по умолчанию создаёт новый экземпляр тест-класса для каждого тест-метода
    D)Тесты выполнились в неправильном порядке — помог бы @Order
    показать ответ и разбор
    +C)JUnit по умолчанию создаёт новый экземпляр тест-класса для каждого тест-метода

    // разбор: По умолчанию JUnit 5 создаёт НОВЫЙ экземпляр тест-класса на каждый тест-метод (per-method) — это изоляция: состояние в полях не течёт между тестами. Рассчитывать на общее поле нельзя; общая подготовка идёт в @BeforeEach (свежая на каждый) или в static-поле/@BeforeAll. Осознанно делить экземпляр — @TestInstance(PER_CLASS).

  2. #junit_basics2 / 5
    Для чего используется @ParameterizedTest?
    A)Чтобы запустить тест в нескольких потоках параллельно
    B)Чтобы пометить тест как медленный и вынести его в отдельную стадию CI
    C)Чтобы передать тесту зависимости через конструктор прямо из Spring-контекста
    D)Чтобы прогнать одну тестовую логику на наборе входных данных без копипасты
    показать ответ и разбор
    +D)Чтобы прогнать одну тестовую логику на наборе входных данных без копипасты

    // разбор: @ParameterizedTest гоняет один метод на множестве входов — данные дают @ValueSource, @CsvSource, @EnumSource или @MethodSource. Вместо пяти почти одинаковых тестов — один с таблицей случаев, включая граничные. Каждый набор — отдельный прогон с понятным именем в отчёте.

  3. #junit_basics3 / 5
    В assertEquals(a, b) какой аргумент трактуется как ожидаемое значение?
    A)Первый (a) — ожидаемое, второй — фактическое; от порядка зависит текст ошибки
    B)Порядок аргументов не важен: assertEquals симметричен и сравнивает через equals
    C)Второй (b) — ожидаемое, первый — фактическое
    D)JUnit сам определяет ожидаемое по типу: константа считается expected
    показать ответ и разбор
    +A)Первый (a) — ожидаемое, второй — фактическое; от порядка зависит текст ошибки

    // разбор: Сигнатура — assertEquals(expected, actual): первым идёт ожидаемое, вторым фактическое. Само сравнение симметрично (equals), но при провале JUnit печатает «expected: X but was: Y» по позициям. Перепутал порядок — сообщение вводит в заблуждение при отладке, хотя тест краснеет там же.

  4. #junit_basics4 / 5
    Тест-класс с per-method жизненным циклом не компилируется/падает на старте. Что не так?
    class OrderTest {
      @BeforeAll
      void initDb() { /* дорогая
         подготовка один раз */ }
      @Test
      void createsOrder() { /* ... */ }
    }
    A)У тест-класса нет модификатора public — JUnit 5 его не увидит
    B)@BeforeAll-метод не static — без @TestInstance(PER_CLASS) он обязан быть static
    C)@Test-метод не бросает Exception, поэтому @BeforeAll не связывается с ним по жизненному циклу
    D)Держать @BeforeAll и @Test в одном классе без @Nested запрещено
    показать ответ и разбор
    +B)@BeforeAll-метод не static — без @TestInstance(PER_CLASS) он обязан быть static

    // разбор: При дефолтном жизненном цикле (PER_METHOD) экземпляра класса ещё нет в момент @BeforeAll, поэтому метод обязан быть static — иначе JUnit не может его вызвать. Либо делаем initDb static, либо ставим на класс @TestInstance(Lifecycle.PER_CLASS) и тогда нестатический @BeforeAll допустим. Public классу в JUnit 5 не нужен.

  5. #junit_basics5 / 5
    Тест проверяет три поля ответа тремя assertEquals подряд. Что даёт замена их на assertAll?
    A)assertAll делает проверки атомарными: при провале откатывает уже прошедшие
    B)assertAll ускоряет тест, выполняя проверки параллельно
    C)assertAll сообщает про ВСЕ провалившиеся поля сразу, а не падает на первом
    D)Ничего: assertAll — устаревший синоним последовательных ассертов
    показать ответ и разбор
    +C)assertAll сообщает про ВСЕ провалившиеся поля сразу, а не падает на первом

    // разбор: Последовательные assertEquals падают на первом же несовпадении — остальные поля остаются непроверенными, и починив одно, узнаёшь про следующее только на новом прогоне. assertAll выполняет все проверки и репортит все провалы разом — полная картина за один запуск. Уместен, когда ассерты независимы и относятся к одному объекту.

дальше

Теорию прочитали. Навык ставится повторением

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