Русский
Русский
English
Статистика
Реклама

Junit5

Выбор библиотеки ассертов для проекта на Kotlin

09.07.2020 10:15:09 | Автор: admin

В одном из старых проектов в кучу были навалены ассерты из JUnit, kotlin.test и AssertJ. Это было не единственной его проблемой: его вообще писали как письмо Дяди Федора, а времени остановиться и привести к единому виду не было. И вот это время пришло.


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


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



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


Немного бэкграунда


Долгое время моим основным ЯП была Scala, а фреймворком для тестов ScalaTest. Не то чтобы это был лучший фреймворк, но я как-то к нему привык, поэтому мое мнение может быть искажено из-за этого.


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


Требования


Сразу оговорюсь, требования весьма субъективны и предвзяты, а часть вообще вкусовщина. Требования получились такие:


  1. Бесшовная интеграция с Kotlin и IntelliJ Idea. Scala-библиотеки по этому принципу отпадают извращаться с настройкой двух рантаймов нет желания. Несмотря на это, ScalaTest будет присутствовать в сравнении как отправная точка, просто потому что с ним много работал. Под интеграцией с IntelliJ я подразумеваю возможность клика на <Click to see difference>, чтобы увидеть сравнение реального значения и ожидаемого. Эта фича, вообще говоря, работает в кишках IntelliJ Idea но ведь разработчики Kotlin-библиотек наверно про нее все-таки слышали и могут решить эту проблему, да?
  2. Возможность быстро понять проблему. Чтобы было не 1 != 2 и стектрейс, а нормальное сообщение, содержащее в идеале название переменной и разделение на "expected" и "actual". Чтобы для коллекций было сообщение не просто "два множества на 100 элементов не равны, вот тебе оба в строковом представлении, ищи разницу сам", а подробности, например " эти элементы должны быть, а их нет, а вот эти не должны, но они есть". Можно конечно везде описания самому писать, но зачем тогда мне библиотека? Как выяснилось чуть позже, название переменной это была влажная мечта, и при недолгих раздумьях будет очевидно, что это не так-то просто сделать.
  3. Адекватность записи. assertEquals(expected, actual) Йоды стиль читать сложно мне, вкусовщина однако это большая. Кроме того, я не хочу задумываться о тонких нюансах библиотеки в идеале должен быть ограниченный набор ключевых слов/конструкций, и чтобы не надо было вспоминать особенности из серии "это массив, а не коллекция, поэтому для него нужен особый метод" или помнить, что строка не contains, а includes. Другими словами это одновременно читаемость и очевидность как при чтении, так и при написании тестов.
  4. Наличие проверки содержания подстроки. Что-то вроде assertThat("Friendship").contains("end").
  5. Проверка исключений. В качестве контр-примера приведу JUnit4, в котором исключение ловится либо в аннотацию, либо в переменную типа ExpectedException с аннотацией @Rule.
  6. Сравнение коллекций и содержание элемента(ов) в них.
  7. Поддержка отрицаний для всего вышеперечисленного.
  8. Проверка типов. Если ошибка будет выявлена компилятором то это гораздо круче, чем если она будет выявлена при запуске теста. Как минимум, типизация не должна мешать: если мы знаем тип ожидаемого значения, то тип реального значения, возвращенного generic-функцией, должен быть выведен. Контр-пример: assertThat(generic<Boolean>(input)).isEqualTo(true). <Boolean> тут лишний. Третий вариант заключается в игнорировании типов при вызове ассерта.
  9. Сравнение сложных структур, например двух словарей с вложенными контейнерами. И даже если в них вложен массив примитивов. Все же знают про неконсистентность их сравнения? Так вот ассерты это последнее место, где я хочу об этом задумываться, даже если это отличается от поведения в рантайме. Для сложных структур по-хорошему должен быть рекурсивный обход дерева объектов с полезной информацией, а не тупо вызов equals. Кстати в той недо-библиотеке на старой работе так и было сделано.

Не будем рассматривать сложные поведенческие паттерны или очень сложные проверки, где надо у объекта человек прям сразу проверить, что он и швец, и жнец, и на дуде играет, и вообще хороший парень в нужном месте и время. Повторюсь, что цель подобрать ассерты, а не фреймворк для тестов. И ориентир на разработчика, который пишет юнит-тесты, а не на полноценного QA-инженера.


Конкурсанты


Перед написанием этой статьи я думал, что достаточно будет сравнить штук 5 библиотек, но оказалось, что этих библиотек пруд пруди.


Я сравнивал следующие библиотеки:


  1. ScalaTest как опорная точка для меня.
  2. JUnit 5 как опорная точка для сферического Java-разработчика.
  3. kotlin.test для любителей multiplatform и официальных решений. Для наших целей это обертка над JUnit, но есть нюансы.
  4. AssertJ довольно популярная библиотека с богатым набором ассертов. Отпочковалась от FestAssert, на сайте которого по-японски торгуют сезамином по всем старым ссылкам на документацию.
  5. Kotest он же KotlinTest, не путать с kotlin.test. Разработчики пишут, что их вдохновлял ScalaTest. В кишках есть даже сгенерированные функции и классы для 1-22 аргументов в лучших традициях scala.
  6. Truth библиотека от Гугла. По словам самих создателей, очень напоминает AssertJ.
  7. Hamсrest фаворит многих автотестировщиков по мнению Яндекса. Поверх нее еще работает valid4j.
  8. Strikt многим обязан AssertJ и по стилю тоже его напоминает.
  9. Kluent автор пишет, что это обертка над JUnit (хотя на самом деле над kotlin.test), по стилю похож на Kotest. Мне понравилась документация куча примеров по категориям, никаких стен текста.
  10. Atrium по словам создателей, черпали вдохновение из AssertJ, но потом встали на свой путь. Оригинальная особенность локализация сообщений ассертов (на уровне импорта в maven/gradle).
  11. Expekt черпали вдохновение из Chai.js. Проект заброшен: последний коммит 4 года назад.
  12. AssertK как AssertJ, только AssertK (но есть нюансы).
  13. HamKrest как Hamсrest, только HamKrest (на самом деле от Hamcrest только название и стиль).

Если вы хотите нарушить прекрасное число этих конкурсантов пишите в комментах, какую достойную сравнения библиотеку я упустил или делайте пулл-реквест.


Эволюция методики оценки


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


Сначала я создал набор тестов, которые всегда валятся, 1 тест 1 ассерт. Несмотря на то, что я люблю автоматизировать всякую дичь, писать какую-то сложную лабуду для проверки требований мне было откровенно лень, поэтому я планировал проверять все почти вручную и вставить шутку "то самое место, где вы можете влепить минус за "низкий технический уровень материала"".


Потом решил, что все-таки надо защититься от банальных ошибок, и средствами JUnit сделал валидатор, который проверяет, что все тесты, которые должны были завалиться, завалились, и что нет неизвестных тестов. Когда наткнулся на баг в ScalaTest, решил сделать две вариации: одна, где все тесты проходят, вторая где ничего не проходит и дополнил валидатор. Внимательный читатель может спросить: а кто стрижет брадобрея и какие ассерты использованы там? Отчасти для объективности, отчасти для переносимости ассертов там вообще нет:). Заодно будет демо/аргумент для тех, кто считает, что ассерты не нужны вообще.


Затем я оказался на распутье: выносить ли или нет константы типа listOf(1,2,3)? Если да то это упоротость какая-то, если нет то при переписывании теста на другие ассерты обязательно ошибусь. Составив список библиотек, которые стоит проверить для претензии на полноту исследования, я плюнул и решил решить эту проблему наследованием: написал общий скелет для всех тестов и сделал интерфейс для ассертов, который нужно переопределить для каждой библиотеки. Выглядит немного страшновато, зато можно использовать как розеттский камень.


Однако есть проблема с параметризованными проверками и type erasure. Reified параметры могут быть только в inline-функциях, а их переопределять нельзя. Поэтому хорошо заиспользовать конструкции типа


assertThrows<T>{...}

в коде не получится, пришлось использовать их дополнения без reified параметра:


assertThrows(expectedClass){...}

Я честно немного поковырялся в этой проблеме и решил на нее забить. В конце концов, в kotlin.test есть похожая проблема с интерфейсом Asserter: ассерт на проверку исключения в него не включен, и является внешней функцией. Чего мне тогда выпендриваться, если у создателей языка та же проблема?:)


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


Результаты


Дальше тупо суммируем баллы по требованиям: 0 если требование не выполнено, 0.5 если выполнено частично, 1 если все в целом ок. Максимум 9 баллов.


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


Библиотека Интеграция Описание ошибки Читабельность Подстрока Исключения Коллекции Отрицания Вывод типов Сложные структуры Итого
Kotest + + + + + нет - 6.0
Kluent + + + + + нет - 6.0
AssertJ + + + + нет 6.0
Truth + + + - + + нет - 5.5
Strikt + + + + нет - 5.5
ScalaTest + + + + нет - 5.5
HamKrest - + + + да - 5.5
AssertK + + + нет - 5.0
Atrium + + + нет - 5.0
Hamсrest + - + да - 5.0
JUnit + + - + - игнор - 4.5
kotlin.test + - - + - - да - 3.5
Expekt - + - + нет - 3.5

Примечания по каждой библиотеке:


Kotest
  • Чтобы подключить только ассерты, надо немного поковыряться. На мой взгляд сходу это понять тяжело.
  • Не имеет варианта ловли исключения с явным параметром, а не reified, но это на самом деле не особо и нужно: мало кто будет заниматься такими извращениями.
  • Сложные структуры: тест с вложенными массивами не прошел. Я завел на это тикет.
  • Интеграция: <Click to see difference> есть только для простых ассертов.
  • Типизация: иногда при использовании дженериков надо писать им явный тип.
  • Описание ошибок: почти идеальное, не хватило только подробностей отличия двух множеств.

Kluent
  • Можно писать как "hello".shouldBeEqualTo("hello"), так и "hello" `should be equal to` "hello". Любителям DSL понравится.
  • Интересная запись для ловли исключения:
    invoking { block() } shouldThrow expectedClass.kotlin withMessage expectedMessage
    
  • Описания ошибок в целом отличные, не нет подробностей отличия двух коллекций, что не так. Еще расстроила ошибка в формате Expected Iterable to contain none of "[1, 3]" непонятно, что на самом деле проверяемый Iterable содержит.
  • Интеграция: <Click to see difference> есть только для простых ассертов.
  • Сложные структуры: тест с вложенными массивами не прошел.

AssertJ
  • Впечатляющее количество методов для сравнения еще бы запомнить их Надо знать, что списки надо сравнивать через containsExactly, множества через hasSameElementsAs, а словари через .usingRecursiveComparison().isEqualTo.


  • Интеграция: нет <Click to see difference>.


  • Исключения: ловится просто какое-то исключение, а не конкретное. Сообщение об ошибке, соответственно, не содержит название класса.


  • Сложные структуры: есть .usingRecursiveComparison(), который почти хорошо сравнивает. Однако ошибку хотелось бы иметь поподробнее: ассерт определяет, что значения по одному ключу не равны, но не говорит по какому. И несмотря на то, что он корректно определил, что два словаря с массивами равны, отрицание этого ассерта


    assertThat(actual)    .usingRecursiveComparison()    .isNotEqualTo(unexpected)
    

    сработало некорректно: для одинаковых структур не завалился тест на их неравенство.


  • Типизация: иногда при использовании дженериков надо писать им явный тип. Видимо, это ограничение DSL.



Truth
  • Подробные сообщения об ошибках, иногда даже многословные.
  • Исключения: не поддерживаются, пишут, что надо использовать assertThrows из JUnit5. Интересно, а если ассерты не через JUnit запускают, то что?
  • Читаемость: кроме прикола с исключением, странное название для метода, проверяющего наличие всех элементов в коллекции: containsAtLeastElementsIn. Но я думаю на общем фоне это незначительно, благо тут можно для сравнения коллекций не задумываясь писать assertThat(actual).isEqualTo(expected).
  • Интеграция: <Click to see difference> только для примитивного ассерта.
  • Сложные структуры: тест с вложенными массивами не прошел.
  • Типизация: тип не выводится из ожидаемого значения, пришлось писать явно.
  • Веселый стектрейс с сокращалками ссылок "для повышения читаемости":
    expected: 1but was : 2at asserts.truth.TruthAsserts.simpleAssert(TruthAsserts.kt:10)at common.FailedAssertsTestBase.simple assert should have descriptive message(FailedAssertsTestBase.kt:20)at [[Reflective call: 4 frames collapsed (http://personeltest.ru/aways/goo.gl/aH3UyP)]].(:0)at [[Testing framework: 27 frames collapsed (http://personeltest.ru/aways/goo.gl/aH3UyP)]].(:0)at java.base/java.util.ArrayList.forEach(ArrayList.java:1540)at [[Testing framework: 9 frames collapsed (http://personeltest.ru/aways/goo.gl/aH3UyP)]].(:0)at java.base/java.util.ArrayList.forEach(ArrayList.java:1540)at [[Testing framework: 9 frames collapsed (http://personeltest.ru/aways/goo.gl/aH3UyP)]].(:0)at java.base/java.util.ArrayList.forEach(ArrayList.java:1540)at [[Testing framework: 17 frames collapsed (http://personeltest.ru/aways/goo.gl/aH3UyP)]].(:0)at org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestClassProcessor$CollectAllTestClassesExecutor.processAllTestClasses(JUnitPlatformTestClassProcessor.java:99)...
    

Strikt
  • Не имеет варианта ловли исключения с явным параметром, а не reified, но это на самом деле не особо и нужно: мало кто будет заниматься такими извращениями.


  • Отрицание для содержания подстроки в строке выглядит неконсистентно: expectThat(haystack).not().contains(needle), хотя для коллекций есть нормальный expectThat(collection).doesNotContain(items).


  • Читаемость: для массивов надо использовать contentEquals. Та же проблема с отрицанием: expectThat(actual).not().contentEquals(unexpected). Более того, надо еще думать о типе, потому что для Array<T> Strikt почему-то не смог определить нужный ассерт сам. Для списков containsExactly, для множеств containsExactlyInAnyOrder.


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


    val actual: Array<String> = arrayOf("1")val expected: Array<String> = arrayOf("2")expectThat(actual).contentEquals(expected)
    

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


    infix fun <T> Assertion.Builder<Array<out T>>.contentEquals(other: Array<out T>)
    

    Из-за этого надо писать


    val actual: Array<out String> = arrayOf("1")val expected: Array<String> = arrayOf("2")expectThat(actual).contentEquals(expected)
    

  • Интеграция: нет <Click to see difference>.


  • Описание ошибки: нет подробностей для словаря и массивов, а целом довольно подробно.


  • Сложные структуры: тест с вложенными массивами не прошел.



ScalaTest
  • Интеграция: при сравнении коллекций нельзя открыть сравнение.
  • Описание ошибки: в коллекциях написано, что просто не равны. Для словаря тоже подробностей нет.
  • Читабельность: надо помнить об особенностях DSL при отрицании и contains, отличии contains и include, а также необходимости theSameElementsAs.
  • Сложные структуры: тест с вложенными массивами не прошел, на это есть тикет.
  • Типизация: тип не выводится из ожидаемого значения, пришлось писать явно.

HamKrest
  • Проект, судя по тикетам, в полузаброшенном состоянии. Вдобавок документация весьма жиденькая пришлось читать исходный код библиотеки, чтобы угадать название нужного матчера.
  • Ожидал, что достаточно сменить импорты Hamcrest, но не тут-то было: довольно многое тут по-другому.
  • Запись ловли исключений зубодробительная:
    assertThat( {    block()}, throws(has(RuntimeException::message, equalTo(expectedMessage))))
    
  • Коллекции: нет проверки наличия нескольких элементов. Пулл-реквест висит 3,5 года. Написал так: assertThat(collection, allOf(items.map { hasElement(it) })).
  • Поддержки массивов нет.
  • Сложные структуры: тест с вложенными массивами не прошел.
  • Интеграция: нет <Click to see difference>.
  • Описание ошибки как-то ни о чем:
    expected: a value that not contains 1 or contains 3but contains 1 or contains 3
    

AssertK
  • Как можно догадаться из названия почти все совпадает с AssertJ. Однако синтаксис иногда немного отличается (нет некоторых методов, некоторые методы называются по-другому).


  • Читаемость: Если в AssertJ написано assertThat(collection).containsAll(items), то в AssertK та же конструкция сработает неправильно, потому что в нем containsAll принимает vararg. Понятно, что цель на containsAll(1,2,3), но продумать альтернативный вариант стоило бы. В некоторых других библиотеках есть похожая проблема, но в них она вызывает ошибку компиляции, а тут нет. Причем разработчикам проблема известна это один из первых тикетов. Вдобавок, нужно отличать containsOnly и containsExactly.


  • Интеграция: нет <Click to see difference>.


  • Исключения: ловится просто какое-то исключение, а не конкретное, потом его тип надо отдельно проверять.


  • Сложные структуры: аналога .usingRecursiveComparison() нет.


  • Описания ошибок подробности есть (хоть и не везде), но местами странные:


    expected to contain exactly:<[3, 4, 5]> but was:<[1, 2, 3]>at index:0 unexpected:<1>at index:1 unexpected:<2>at index:1 expected:<4>at index:2 expected:<5>
    

    Вот почему тут на первый индекс два сообщения?


  • Типизация: иногда при использовании дженериков надо писать им явный тип.



Atrium
  • Поставляется в двух вариантах стиля: fluent и infix. Я ожидал отличий вида assertThat(x).isEqualTo(y) против x shouldBe y, но нет, это expect(x).toBe(y) против expect(x) toBe y. На мой взгляд весьма сомнительная разница, с учетом того, что инфиксный метод можно вызвать без "инфиксности". Однако для инфиксной записи иногда нужно использовать объект-заполнитель o: expect(x) contains o atLeast 1 butAtMost 2 value "hello". Вроде объяснено, зачем так, но выглядит странно. Хотя в среднем по больнице мне нравится infix-ассерты (вертолеты из-за скаловского прошлого), для Atrium я писал во fluent-стиле.
  • Читабельность: странные отрицания: notToBe, но containsNot. Но это не критично. Пришлось думать, как сделать проверку наличия нескольких элементов в коллекции: contains принимает vararg, а containsElementsOf не может вывести тип, сделал тупой каст. Понятно, что цель на contains(1,2,3), но продумать альтернативный вариант стоило бы. Отрицание наличия нескольких элементов записывается как expect(collection).containsNot.elementsOf(items).
  • Поддержки работы с массивами нет, рекомендуют преобразовывать через toList.
  • Не имеет варианта ловли исключения с явным параметром, а не reified, но это на самом деле не особо и нужно: мало кто будет заниматься такими извращениями.
  • Сложные структуры: тест с вложенными массивами не прошел.
  • Интеграция: нет <Click to see difference>.
  • Описание ошибки: местами нет подробностей (при сравнении словарей, например), местами описание довольно запутанное:
    expected that subject: [4, 2, 1]        (java.util.Arrays.ArrayList <938196491>)does not contain: an entry which is: 1        (kotlin.Int <211381230>)  number of such entries: 1      is: 0        (kotlin.Int <1934798916>)  has at least one element: true      is: true
    
  • Типизация: иногда при использовании дженериков надо писать им явный тип.

Hamcrest
  • Читабельность: странный синтаксис для отрицаний (либо


    assertThat(actual, `is`(not(unexpected)))
    

    либо


    assertThat(actual, not(unexpected))
    

    Надо знать нюанс containsString vs contains vs hasItem vs hasItems. Пришлось думать, как сделать проверку наличия нескольких элементов в коллекции: hasItems принимает vararg, а Set<T> без знания T просто так не преобразуешь в массив. Понятно, что цель на hasItems(1,2,3), но продумать альтернативный вариант стоило бы. Получилось в итоге


    assertThat(collection, allOf(items.map { hasItem(it) }))
    

    С отрицанием еще веселее:


    assertThat(collection, not(anyOf(items.map { hasItem(it) })))
    

  • В продолжение этой вакханалии с hasItems, я поставил в графу "коллекции", потому что лучше б не было ассертов, чем такие.


  • Исключения: отдельной проверки нет.


  • Интеграция: нет <Click to see difference>.


  • Описание ошибки: для коллекций нет подробностей.


  • Сложные структуры: тест с вложенными массивами не прошел.



JUnit
  • Читабельность: Йода-стиль assertEquals(expected, actual), надо помнить нюансы и отличия методов: что массивы надо сравнивать через assertArrayEquals, коллекции через assertIterableEquals и т.п.
  • Описание ошибок: для тех случаев, когда у JUnit все-таки были методы, оно было вполне нормальным.
  • Подстрока: через assertLinesMatch(listOf(".*$needle.*"), listOf(haystack)) конечно можно, но выглядит это не очень.
  • Отрицания: нет отрицания для assertLinesMatch, что логично, нет отрицания для assertIterableEquals.
  • Коллекции: нет проверки содержания элемента, assertIterableEquals для Map и Set не подходит совсем, потому что ему важен порядок.
  • Сложные структуры: тупо нет.

kotlin.test
  • Очень бедно. Вроде как это должна быть обертка над JUnit, но методов там еще меньше. Очевидно, что это расплата за кроссплатформенность.
  • Проблемы те же, что и у JUnit, и плюс к этому:
  • Нет проверки подстроки.
  • Нет даже намека на сравнения коллекций в лице assertIterableEquals, нет сравнения массивов.
  • Типизация: JUnit'у пофиг на типы в assertEquals, а kotlin.test ругнулся, что не может вывести тип.
  • Описание ошибок: не по чему оценивать.

Expekt
  • Можно писать в двух стилях expect(x).equal(y) и x.should.equal(y), причем второй вариант не инфиксный. Разница тут ничтожна, выбрал второй.
  • Читабельность: contains(item) против should.have.elements(items) и should.contain.elements(items). Причем есть приватный метод containsAll. Пришлось думать, как сделать проверку наличия нескольких элементов в коллекции: should.have.elements принимает vararg. Понятно, что цель на should.have.elements(1,2,3), но продумать альтернативный вариант стоило бы. Для отрицания нужно еще вспомнить про any: .should.not.contain.any.elements.
  • Типизация: тип не выводится из ожидаемого значения, пришлось писать явно.
  • Поддержки исключений нет.
  • Поддержки массивов нет.
  • Сложные структуры: тест с вложенными массивами не прошел.
  • Описание ошибки: просто разный текст для разных ассертов без подробностей.
  • Интеграция: нет <Click to see difference>.

Заключение


Лично мне из всего этого разнообразия понравились Kotest, Kluent и AssertJ. В целом я в очередной раз опечалился тому, как фигово работать с массивами в Kotlin и весьма удивился, что нигде кроме AssertJ нет нормального рекурсивного сравнения словарей и коллекций (да и там отрицание этого не работает). До написания статьи я думал, что в библиотеках ассертов эти моменты должны быть продуманы.


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

Подробнее..

Перевод Передовой опыт тестирования в Java

07.09.2020 10:18:05 | Автор: admin

Чтобы покрытие кода было достаточным, а создание нового функционала и рефакторинг старого проходили без страха что-то сломать, тесты должны быть поддерживаемыми и легко читаемыми. В этой статье я расскажу о множестве приёмов написания юнит- и интеграционных тестов на Java, собранных мной за несколько лет. Я буду опираться на современные технологии: JUnit5, AssertJ, Testcontainers, а также не обойду вниманием Kotlin. Некоторые советы покажутся вам очевидными, другие могут идти вразрез с тем, что вы читали в книгах о разработке ПО и тестировании.

Вкратце


  • Пишите тесты кратко и конкретно, используя вспомогательные функции, параметризацию, разнообразные примитивы библиотеки AssertJ, не злоупотребляйте переменными, проверяйте только то, что относится к тестируемому функционалу и не засовывайте все нестандартные случаи в один тест
  • Пишите самодостаточные тесты, раскрывайте все релевантные параметры, вставляйте тестовые данные прямо внутрь тестов и вместо наследования пользуйтесь композицией
  • Пишите прямолинейные тесты, чтобы не переиспользовать продакшн-код, сравнивайте выдачу тестируемых методов с константами прямо в коде теста
  • KISS важнее DRY
  • Запускайте тесты в среде, максимально похожей на боевую, тестируйте максимально полную связку компонентов, не используйте in-memory-базы данных
  • JUnit5 и AssertJ очень хороший выбор
  • Вкладывайтесь в простоту тестирования: избегайте статических свойств и методов, используйте внедрение в конструкторы, используйте экземпляры класса Clock и отделяйте бизнес-логику от асинхронной.


Общие положения


Given, When, Then (Дано, Когда, То)


Тест должен содержать три блока, разделённых пустыми строками. Каждый блок должен быть максимально коротким. Используйте локальные методы для компактности записи.

Given / Дано (ввод): подготовка теста, например, создание данных и конфигурация моков.
When / Когда (действие): вызов тестируемого метода
Then / То (вывод): проверка корректности полученного значения

// Правильно@Testpublic void findProduct() {    insertIntoDatabase(new Product(100, "Smartphone"));    Product product = dao.findProduct(100);    assertThat(product.getName()).isEqualTo("Smartphone");}


Используйте префиксы actual* и expected*



// НеправильноProductDTO product1 = requestProduct(1);ProductDTO product2 = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))assertThat(product1).isEqualTo(product2);


Если вы собираетесь использовать переменные в проверке на совпадение значений, добавьте к этим переменным префиксы actual и expected. Так вы улучшите читаемость кода и проясните назначение переменных. Кроме того, так их сложнее перепутать при сравнении.

// ПравильноProductDTO actualProduct = requestProduct(1);ProductDTO expectedProduct = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))assertThat(actualProduct).isEqualTo(expectedProduct); // ясно и красиво


Используйте заданные значения вместо случайных


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

// НеправильноInstant ts1 = Instant.now(); // 1557582788Instant ts2 = ts1.plusSeconds(1); // 1557582789int randomAmount = new Random().nextInt(500); // 232UUID uuid = UUID.randomUUID(); // d5d1f61b-0a8b-42be-b05a-bd458bb563ad


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

// ПравильноInstant ts1 = Instant.ofEpochSecond(1550000001);Instant ts2 = Instant.ofEpochSecond(1550000002);int amount = 50;UUID uuid = UUID.fromString("00000000-000-0000-0000-000000000001");

Вы можете записать это ещё короче, используя вспомогательные функции (см. ниже).

Пишите краткие и конкретные тесты


Где можно, используйте вспомогательные функции


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

// Неправильно@Testpublic void categoryQueryParameter() throws Exception {    List<ProductEntity> products = List.of(            new ProductEntity().setId("1").setName("Envelope").setCategory("Office").setDescription("An Envelope").setStockAmount(1),            new ProductEntity().setId("2").setName("Pen").setCategory("Office").setDescription("A Pen").setStockAmount(1),            new ProductEntity().setId("3").setName("Notebook").setCategory("Hardware").setDescription("A Notebook").setStockAmount(2)    );    for (ProductEntity product : products) {        template.execute(createSqlInsertStatement(product));    }    String responseJson = client.perform(get("/products?category=Office"))            .andExpect(status().is(200))            .andReturn().getResponse().getContentAsString();    assertThat(toDTOs(responseJson))            .extracting(ProductDTO::getId)            .containsOnly("1", "2");}


// Правильно@Testpublic void categoryQueryParameter2() throws Exception {    insertIntoDatabase(            createProductWithCategory("1", "Office"),            createProductWithCategory("2", "Office"),            createProductWithCategory("3", "Hardware")    );    String responseJson = requestProductsByCategory("Office");    assertThat(toDTOs(responseJson))            .extracting(ProductDTO::getId)            .containsOnly("1", "2");}


  • используйте вспомогательные функции для создания данных (объектов) (createProductWithCategory()) и сложных проверок. Передавайте во вспомогательные функции только те параметры, которые релевантны в этом тесте, для остальных используйте адекватные значения по умолчанию. В Kotlin для этого есть дефолтные значения параметров, а в Java можно использовать цепочки вызова методов и перегрузку для имитации дефолтных параметров
  • список параметров переменной длины сделает ваш код ещё изящнее (nsertIntoDatabase())
  • вспомогательные функции также можно использовать для создания простых значений. В Kotlin это сделано ещё лучше через функции-расширения


// Правильно (Java)Instant ts = toInstant(1); // Instant.ofEpochSecond(1550000001)UUID id = toUUID(1); // UUID.fromString("00000000-0000-0000-a000-000000000001")


// Правильно (Kotlin)val ts = 1.toInstant()val id = 1.toUUID()


Вспомогательные функции на Kotlin можно реализовать так:

fun Int.toInstant(): Instant = Instant.ofEpochSecond(this.toLong())fun Int.toUUID(): UUID = UUID.fromString("00000000-0000-0000-a000-${this.toString().padStart(11, '0')}")


Не злоупотребляйте переменными


Условный рефлекс у программиста вынести часто используемые значения в переменные.

// Неправильно@Testpublic void variables() throws Exception {    String relevantCategory = "Office";    String id1 = "4243";    String id2 = "1123";    String id3 = "9213";    String irrelevantCategory = "Hardware";    insertIntoDatabase(            createProductWithCategory(id1, relevantCategory),            createProductWithCategory(id2, relevantCategory),            createProductWithCategory(id3, irrelevantCategory)    );    String responseJson = requestProductsByCategory(relevantCategory);    assertThat(toDTOs(responseJson))            .extracting(ProductDTO::getId)            .containsOnly(id1, id2);}


Увы, это очень перегружает код. Хуже того, увидев значение в сообщении об ошибке, его будет невозможно проследить до места, где ошибка возникла.

KISS важнее DRY


// Правильно@Testpublic void variables() throws Exception {    insertIntoDatabase(            createProductWithCategory("4243", "Office"),            createProductWithCategory("1123", "Office"),            createProductWithCategory("9213", "Hardware")    );    String responseJson = requestProductsByCategory("Office");    assertThat(toDTOs(responseJson))            .extracting(ProductDTO::getId)            .containsOnly("4243", "1123");}


Если вы стараетесь писать тесты максимально компактно (что я, в любом случае, горячо рекомендую), то переиспользуемые значения хорошо видны. Сам код становится более убористым и хорошо читаемым. И, наконец, сообщение об ошибке приведёт вас точно к той строке, где ошибка возникла.

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


// Неправильноpublic class ProductControllerTest {    @Test    public void happyPath() {        // здесь много кода...    }}


Всегда есть соблазн добавить частный случай к существующему тесту, проверяющему базовую функциональность. Но в результате тесты становятся больше и сложнее для понимания. Частные случаи, раскиданные по большой простыне кода, легко не заметить. Если тест сломался, вы не сразу поймёте, что именно послужило причиной.

// Правильноpublic class ProductControllerTest {    @Test    public void multipleProductsAreReturned() {}    @Test    public void allProductValuesAreReturned() {}    @Test    public void filterByCategory() {}    @Test    public void filterByDateCreated() {}}

Вместо этого напишите новый тест с наглядным названием, из которого сразу будет понятно, какого поведения он ожидает от тестируемого кода. Да, придётся набрать больше букв на клавиатуре (против этого, напомню, хорошо способствуют вспомогательные функции), но зато вы получите простой и понятный тест с предсказуемым результатом. Это, кстати, отличный способ документировать новый функционал.

Проверяйте только то, что хотите протестировать


Думайте о том функционале, который тестируется. Избегайте делать лишние проверки просто потому, что есть такая возможность. Более того, помните о том, что уже проверялось в ранее написанных тестах и не проверяйте это повторно. Тесты должны быть компактными и их ожидаемое поведение должно быть очевидным и лишённым ненужных подробностей.

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

1. Один большой тест маппинга, который проверяет, что все значения из БД корректно возвращаются в JSON-ответе и правильно присваиваются в нужном формате. Мы легко можем написать это при помощи функций isEqualTo() (для единичного элемента) или containsOnly() (для множества элементов) из пакета AssertJ, если вы правильно реализуете метод equals().

String responseJson = requestProducts();ProductDTO expectedDTO1 = new ProductDTO("1", "envelope", new Category("office"), List.of(States.ACTIVE, States.REJECTED));ProductDTO expectedDTO2 = new ProductDTO("2", "envelope", new Category("smartphone"), List.of(States.ACTIVE));assertThat(toDTOs(responseJson))        .containsOnly(expectedDTO1, expectedDTO2);


2. Несколько тестов, проверяющих корректное поведение параметра ?category. Здесь мы хотим проверить только правильную работу фильтров, а не значения свойств, потому что мы сделали это раньше. Следовательно, нам достаточно проверить совпадения полученных id товаров:

String responseJson = requestProductsByCategory("Office");assertThat(toDTOs(responseJson))        .extracting(ProductDTO::getId)        .containsOnly("1", "2");


3. Ещё пару тестов, проверяющих особые случаи или особую бизнес-логику, например что определённые значения в ответе вычислены корректно. В этом случае нас интересуют только несколько полей из всего JSON-ответа. Тем самым мы своим тестом документируем именно эту специальную логику. Понятно, что ничего кроме этих полей нам здесь не нужно.

assertThat(actualProduct.getPrice()).isEqualTo(100);


Самодостаточные тесты


Не прячьте релевантные параметры (во вспомогательных функциях)



// НеправильноinsertIntoDatabase(createProduct());List<ProductDTO> actualProducts = requestProductsByCategory();assertThat(actualProducts).containsOnly(new ProductDTO("1", "Office"));


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

// ПравильноinsertIntoDatabase(createProduct("1", "Office"));List<ProductDTO> actualProducts = requestProductsByCategory("Office");assertThat(actualProducts).containsOnly(new ProductDTO("1", "Office"));


Держите тестовые данные внутри самих тестов


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

Используйте композицию вместо наследования


Не выстраивайте сложных иерархий тестовых классов.

// Неправильноclass SimpleBaseTest {}class AdvancedBaseTest extends SimpleBaseTest {}class AllInklusiveBaseTest extends AdvancedBaseTest {}class MyTest extends AllInklusiveBaseTest {}


Такие иерархии усложняют понимание и вы, скорее всего, быстро обнаружите себя пишущим очередного наследника базового теста, внутри которого зашито множество хлама, который текущему тесту вовсе не нужен. Это отвлекает читателя и приводит к трудноуловимым ошибкам. Наследование не гибко: как вы сами думаете, можно ли использовать все методы класса AllInclusiveBaseTest, но ни одного из его родительского AdvancedBaseTest? Более того, читателю придётся постоянно прыгать между различными базовыми классами, чтобы понять общую картину.

Лучше продублировать код, чем выбрать неправильную абстракцию (Sandi Metz)


Вместо этого я рекомендую использовать композицию. Напишите маленькие фрагменты кода и классы для каждой задачи, связанной с фикстурами (запустить тестовую базу данных, создать схему, вставить данные, запустить мок-сервер). Переиспользуйте эти запчасти в методе @BeforeAll или через присвоение созданных объектов полям тестового класса. Таким образом, вы сможете собирать каждый новый тестовый класс из этих заготовок, как из деталей Лего. В результате каждый тест будет иметь свой собственный понятный набор фикстур и гарантировать, что в нём не происходит ничего постороннего. Тест становится самодостаточным, потому что содержит в себе всё необходимое.

// Правильноpublic class MyTest {    // композиция вместо наследования    private JdbcTemplate template;    private MockWebServer taxService;    @BeforeAll    public void setupDatabaseSchemaAndMockWebServer() throws IOException {        this.template = new DatabaseFixture().startDatabaseAndCreateSchema();        this.taxService = new MockWebServer();        taxService.start();    }}


// В другом файлеpublic class DatabaseFixture {    public JdbcTemplate startDatabaseAndCreateSchema() throws IOException {        PostgreSQLContainer db = new PostgreSQLContainer("postgres:11.2-alpine");        db.start();        DataSource dataSource = DataSourceBuilder.create()                .driverClassName("org.postgresql.Driver")                .username(db.getUsername())                .password(db.getPassword())                .url(db.getJdbcUrl())                .build();        JdbcTemplate template = new JdbcTemplate(dataSource);        SchemaCreator.createSchema(template);        return template;    }}


И ещё раз:

KISS важнее DRY


Прямолинейные тесты это хорошо. Сравнивайте результат с константами


Не переиспользуйте продакшн-код


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

// Неправильноboolean isActive = true;boolean isRejected = true;insertIntoDatabase(new Product(1, isActive, isRejected));ProductDTO actualDTO = requestProduct(1);


// переиспользование боевого кодаList<State> expectedStates = ProductionCode.mapBooleansToEnumList(isActive, isRejected);assertThat(actualDTO.states).isEqualTo(expectedStates);


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

// DoassertThat(actualDTO.states).isEqualTo(List.of(States.ACTIVE, States.REJECTED));


Не копируйте бизнес-логику в тесты


Маппинг объектов яркий пример случая, когда тесты тащат в себя логику из боевого кода. Предположим наш тест содержит метод mapEntityToDto(), результат выполнения которого используется для проверки, что полученный DTO содержит те же значения, что и элементы, которые были добавлены в базу в начале теста. В этом случае вы, скорее всего, скопируете в тест боевой код, который может содержать ошибки.

// НеправильноProductEntity inputEntity = new ProductEntity(1, "envelope", "office", false, true, 200, 10.0);insertIntoDatabase(input);ProductDTO actualDTO = requestProduct(1); // mapEntityToDto() содержит ту же логику, что и продакшн-кодProductDTO expectedDTO = mapEntityToDto(inputEntity);assertThat(actualDTO).isEqualTo(expectedDTO);


Правильным будет решение, при котором actualDTO сравнивается с созданным вручную эталонным объектом с заданными значениями. Это предельно просто, понятно и защищает от потенциальных ошибок.

// ПравильноProductDTO expectedDTO = new ProductDTO("1", "envelope", new Category("office"), List.of(States.ACTIVE, States.REJECTED))assertThat(actualDTO).isEqualTo(expectedDTO);


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

Не пишите слишком много логики


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

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


Тестируйте максимально полную связку компонентов


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

Изолированное юнит-тестирование каждого класса

Вместо этого я рекомендую сосредоточиться на интеграционном тестировании. Под интеграционным тестированием я подразумеваю сбор всех классов воедино (как на продакшене) и тестирование всей связки, включая инфраструктурные компоненты (HTTP-сервер, базу данных, бизнес-логику). В этом случае вы тестируете поведение вместо реализации. Такие тесты более аккуратны, близки к реальному миру и устойчивы к рефакторингу внутренних компонентов. В идеале, вам будет достаточно одного класса тестов.


Интеграционное тестирование (= собрать все классы вместе и тестировать связку)

Не используйте in-memory-базы данных для тестов



С in-memory базой вы тестируете не в той среде, где будет работать ваш код

Используя in-memory базу (H2, HSQLDB, Fongo) для тестов, вы жертвуете их достоверностью и рамками применимости. Такие базы данных часто ведут себя иначе и выдают отличающиеся результаты. Такой тест может пройти успешно, но не гарантирует корректной работы приложения на проде. Более того, вы можете запросто оказаться в ситуации, когда вы не можете использовать или протестировать какое-то характерное для вашей базы поведение или фичу, потому в in-memory БД они не реализованы или ведут себя иначе.

Решение: использовать такую же БД, как и в реальной эксплуатации. Замечательная библиотека Testcontainers предоставляет богатый API для Java-приложений, позволяющий управлять контейнерами прямо из кода тестов.

Java/JVM


Используйте -noverify -XX:TieredStopAtLevel=1


Всегда добавляйте опции JVM -noverify -XX:TieredStopAtLevel=1 в вашу конфигурацию для запуска тестов. Это сэкономит вам 1-2 секунды на старте виртуальной машины перед тем, как начнётся выполнение тестов. Это особенно полезно на начальной стадии работы над тестами, когда вы часто запускаете их из IDE.

Обратите внимание, что начиная с Java 13 -noverify объявлен устаревшим.

Совет: добавьте эти аргументы к шаблону конфигурации JUnit в IntelliJ IDEA, чтобы не делать это каждый раз при создании нового проекта.


Используйте AssertJ


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

assertThat(actualProduct)        .isEqualToIgnoringGivenFields(expectedProduct, "id");assertThat(actualProductList).containsExactly(        createProductDTO("1", "Smartphone", 250.00),        createProductDTO("1", "Smartphone", 250.00));assertThat(actualProductList)        .usingElementComparatorIgnoringFields("id")        .containsExactly(expectedProduct1, expectedProduct2);assertThat(actualProductList)        .extracting(Product::getId)        .containsExactly("1", "2");assertThat(actualProductList)        .anySatisfy(product -> assertThat(product.getDateCreated()).isBetween(instant1, instant2));assertThat(actualProductList)        .filteredOn(product -> product.getCategory().equals("Smartphone"))        .allSatisfy(product -> assertThat(product.isLiked()).isTrue());


Избегайте использовать assertTrue() и assertFalse()


Использование простых assertTrue() или assertFalse() приводит к загадочным сообщениям об ошибках тестов:

// НеправильноassertTrue(actualProductList.contains(expectedProduct));assertTrue(actualProductList.size() == 5);assertTrue(actualProduct instanceof Product);expected: <true> but was: <false>


Используйте вместо них вызовы AssertJ, которые из коробки возвращают понятные и информативные сообщения.

// ПравильноassertThat(actualProductList).contains(expectedProduct);assertThat(actualProductList).hasSize(5);assertThat(actualProduct).isInstanceOf(Product.class);Expecting: <[Product[id=1, name='Samsung Galaxy']]>to contain: <[Product[id=2, name='iPhone']]>but could not find: <[Product[id=2, name='iPhone']]>


Если вам надо проверить boolean-значение, сделайте сообщение более информативным при помощи метода as() AssertJ.

Используйте JUnit5


JUnit5 превосходная библиотека для (юнит-)тестирования. Она находится в процессе постоянного развития и предоставляет программисту множество полезных возможностей, таких, например, как параметризованные тесты, группировки, условные тесты, контроль жизненного цикла.

Используйте параметризованные тесты


Параметризация тесты позволяют запускать один и тот же тест с набором различных входных значений. Это позволяет проверять несколько кейсов без написания лишнего кода. В JUnit5 для этого есть отличные инструменты @ValueSource, @EnumSource, @CsvSource и @MethodSource.

// Правильно@ParameterizedTest@ValueSource(strings = ["ed2d", "sdf_", "123123", "_sdf__dfww!"])public void rejectedInvalidTokens(String invalidToken) {    client.perform(get("/products").param("token", invalidToken))            .andExpect(status().is(400))}@ParameterizedTest@EnumSource(WorkflowState::class, mode = EnumSource.Mode.INCLUDE, names = ["FAILED", "SUCCEEDED"])public void dontProcessWorkflowInCaseOfAFinalState(WorkflowState itemsInitialState) {    // ...}


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

Наконец, я хочу обратить ваше внимание на @CsvSource и @MethodSource, которые можно использовать для более сложной параметризации, где также надо контролировать результат: вы можете передать его в одном из параметров.

@ParameterizedTest@CsvSource({    "1, 1, 2",    "5, 3, 8",    "10, -20, -10"})public void add(int summand1, int summand2, int expectedSum) {    assertThat(calculator.add(summand1, summand2)).isEqualTo(expectedSum);}


@MethodSource особенно эффективен в связке с отдельным тестовым объектом, содержащим все нужные параметры и ожидаемые результаты. К сожалению, в Java описание таких структур данных (т.н. POJO) очень громоздки. Поэтому я приведу пример с использованием дата-классов Kotlin.

data class TestData(    val input: String?,    val expected: Token?)@ParameterizedTest@MethodSource("validTokenProvider")fun `parse valid tokens`(data: TestData) {    assertThat(parse(data.input)).isEqualTo(data.expected)}private fun validTokenProvider() = Stream.of(    TestData(input = "1511443755_2", expected = Token(1511443755, "2")),    TestData(input = "151175_13521", expected = Token(151175, "13521")),    TestData(input = "151144375_id", expected = Token(151144375, "id")),    TestData(input = "15114437599_1", expected = Token(15114437599, "1")),    TestData(input = null, expected = null))


Группируйте тесты


Аннотация @Nested из JUnit5 удобна для группировки тестовых методов. Логически имеет смысл группировать вместе определённые типы тестов (типа InputIsXY, ErrorCases) или собрать в свою группу методы каждого теста (GetDesign и UpdateDesign).

public class DesignControllerTest {    @Nested    class GetDesigns {        @Test        void allFieldsAreIncluded() {}        @Test        void limitParameter() {}        @Test        void filterParameter() {}    }    @Nested    class DeleteDesign {        @Test        void designIsRemovedFromDb() {}        @Test        void return404OnInvalidIdParameter() {}        @Test        void return401IfNotAuthorized() {}    }}




Читаемые названия тестов при помощи @DisplayName или обратных кавычек в Kotlin



В Java можно использовать аннотацию @DisplayName, чтобы дать тестам более читаемые названия.

public class DisplayNameTest {    @Test    @DisplayName("Design is removed from database")    void designIsRemoved() {}    @Test    @DisplayName("Return 404 in case of an invalid parameter")    void return404() {}    @Test    @DisplayName("Return 401 if the request is not authorized")    void return401() {}}




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

@Testfun `design is removed from db`() {}


Имитируйте внешние сервисы


Для тестирования HTTP-клиентов нам необходимо имитировать сервисы, к которым они обращаются. Я часто использую в этих целях WebMockServer из OkHttp. Альтернативами могут служить WireMock или Mockserver из Testcontainers.

MockWebServer serviceMock = new MockWebServer();serviceMock.start();HttpUrl baseUrl = serviceMock.url("/v1/");ProductClient client = new ProductClient(baseUrl.host(), baseUrl.port());serviceMock.enqueue(new MockResponse()        .addHeader("Content-Type", "application/json")        .setBody("{\"name\": \"Smartphone\"}"));ProductDTO productDTO = client.retrieveProduct("1");assertThat(productDTO.getName()).isEqualTo("Smartphone");


Используйте Awaitility для тестирования асинхронного кода



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

private static final ConditionFactory WAIT = await()        .atMost(Duration.ofSeconds(6))        .pollInterval(Duration.ofSeconds(1))        .pollDelay(Duration.ofSeconds(1));@Testpublic void waitAndPoll(){    triggerAsyncEvent();    WAIT.untilAsserted(() -> {        assertThat(findInDatabase(1).getState()).isEqualTo(State.SUCCESS);    });}


Не надо резолвить DI-зависимости (Spring)


Инициализация DI-фреймворка занимает несколько секунд перед тем, как тесты могут стартовать. Это замедляет цикл обратной связи, особенно на начальном этапе разработки.

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

Более того, начиная с версии 2.2, Spring Boot поддерживает ленивую инициализацию бинов, что заметно ускоряет тесты, использующие DI.

Ваш код должен быть тестируемым


Не используйте статический доступ. Никогда


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

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

// Неправильноpublic class ProductController {    public List<ProductDTO> getProducts() {        List<ProductEntity> products = ProductDAO.getProducts();        return mapToDTOs(products);    }}


// Правильноpublic class ProductController {    private ProductDAO dao;    public ProductController(ProductDAO dao) {        this.dao = dao;    }    public List<ProductDTO> getProducts() {        List<ProductEntity> products = dao.getProducts();        return mapToDTOs(products);    }}


К счастью, DI-фреймворки типа Spring дают предоставляют инструменты, делающие статический доступ ненужным, автоматически создавая и связывая объекты без нашего участия.

Параметризуйте


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

Представьте, например, что ваш DAO имеет фиксированный лимит в 1000 объектов на запрос. Чтобы проверить этот лимит, вам надо будет перед тестом добавить в тестовую БД 1001 объект. Используя аргумент конструктора, вы можете сделать это значение настраиваемым: в продакшене оставить 1000, в тестировании сократить до 2. Таким образом, чтобы проверить работу лимита вам будет достаточно добавить в тестовую БД всего 3 записи.

Используйте внедрение в конструктор


Внедрение полей зло, оно ведёт к плохой тестируемости кода. Вам необходимо инициализировать DI перед тестами или заниматься стрёмной магией рефлексий. Поэтому предпочтительно использовать внедрение через конструктор, чтобы легко контролировать зависимые объекты в процессе тестирования.

На Java придётся написать немного лишнего кода:
// Правильноpublic class ProductController {    private ProductDAO dao;    private TaxClient client;    public CustomerResource(ProductDAO dao, TaxClient client) {        this.dao = dao;        this.client = client;    }}


В Kotlin тоже самое пишется намного лаконичнее:
// Правильноclass ProductController(    private val dao: ProductDAO,    private val client: TaxClient){}


Не используйте Instant.now() или new Date()


Не надо получать текущее время вызовами Instant.now() или new Date() в продакшн-коде, если вы хотите тестировать это поведение.

// Неправильноpublic class ProductDAO {    public void updateDateModified(String productId) {        Instant now = Instant.now(); // !        Update update = Update()            .set("dateModified", now);        Query query = Query()            .addCriteria(where("_id").eq(productId));        return mongoTemplate.updateOne(query, update, ProductEntity.class);    }}


Проблема в том, что полученное время не может контролироваться со стороны теста. Вы не сможете сравнить полученный результат с конкретным значением, потому что он всё время разный. Вместо этого используйте класс Clock из Java.

// Правильноpublic class ProductDAO {    private Clock clock;     public ProductDAO(Clock clock) {        this.clock = clock;    }    public void updateProductState(String productId, State state) {        Instant now = clock.instant();        // ...    }}


В этом тесте вы можете создать мок-объект для Clock, передать его в ProductDAO и сконфигурировать мок-объект так, чтобы он возвращал одно и то же время. После вызовы updateProductState() мы сможем проверить, что в базу данных попало именно заданное нами значение.

Разделяйте асинхронное выполнение и собственно логику


Тестирование асинхронного кода непростая штука. Библиотеки типа Awaitility оказывают большую помощь, но процесс всё равно запутан, и мы можем получить моргающий тест. Есть смысл разделять бизнес-логику (обычно синхронную) и асинхронный инфраструктурный код, если такая возможность имеется.

Например, вынеся бизнес-логику в ProductController мы сможем запросто протестировать её синхронно. Вся асинхронная и параллельная логика останутся в ProductScheduler, который можно протестировать изолированно.

// Правильноpublic class ProductScheduler {    private ProductController controller;    @Scheduled    public void start() {        CompletableFuture<String> usFuture = CompletableFuture.supplyAsync(() -> controller.doBusinessLogic(Locale.US));        CompletableFuture<String> germanyFuture = CompletableFuture.supplyAsync(() -> controller.doBusinessLogic(Locale.GERMANY));        String usResult = usFuture.get();        String germanyResult = germanyFuture.get();    }}


Kotlin


Моя статья Best practices for unit-testing in Kotlin (англ.) содержит множество специфических для Kotlin приёмов юнит-тестирования. (Прим. перев.: пишите в комментариях, если вам интересен русский перевод этой статьи).
Подробнее..

Модульное тестирование, детальное рассмотрение параметризованных тестов. Часть I

04.11.2020 12:10:51 | Автор: admin
Доброго времени суток, коллеги.
Я решил поделиться своим видением на параметризованные юнит-тесты, как делаем это мы, и как возможно не делаете(но захотите делать) вы.
Хочется написать красивую фразу, о том что тестировать надо правильно, и тесты это важно, но до меня сказано и написано уже много материала, я лишь попробую резюмировать и осветить то, что по моему мнению людьми редко используется(понимается), на что в основном задвигается.

Основная цель статьи показать, как можно(и нужно) перестать захламлять свой юнит-тест кодом для создания объектов, и как декларативно создать тестовые данные, если одних mock(any()) не хватает, а таких ситуаций полно.

Создадим maven проект, добавим в него junit5, junit-jupiter-params и mokito

Чтоб не было совсем скучно начнем писать сразу с теста, как любят апологеты TDD, нам нужен сервис, который мы будем декларативно тестировать, подойдет любой, пускай это будет HabrService.

Создадим тест HabrServiceTest. В поле класса теста добавим ссылку на HabrService:
public class HabrServiceTest {    private HabrService habrService;    @Test    void handleTest(){    }}


создадим сервис через ide(легким нажатием шортката), добавим на поле аннотацию @InjectMocks.

Приступаем непосредственно к тесту: HabrService в нашем небольшом приложении будет иметь один единственный метод hande(), который будет принимать один единственный аргумент HabrItem, и теперь наш тест выглядит так:
public class HabrServiceTest {    @InjectMocks    private HabrService habrService;    @Test    void handleTest(){        HabrItem item = new HabrItem();        habrService.handle(item);    }}


Добавим в HabrService метод handle(), который будет возвращать id нового поста на хабре после его модерации и сохранения в БД, и принимает тип HabrItem, так же создадим наш HabrItem, и теперь тест компилируется, но падает.

Дело в том что мы добавили проверку, на ожидаемое возвращаемое значение.
public class HabrServiceTest {    @InjectMocks    private HabrService habrService;    @BeforeEach    void setUp(){        initMocks(this);    }    @Test    void handleTest() {        HabrItem item = new HabrItem();        Long actual = habrService.handle(item);        assertEquals(1L, actual);    }}


Также, я хочу убедиться, что в ходе вызова метода handle(), были вызваны ReviewService и PersistanceService, вызвались они строго друг за другом, отработали ровно по 1 разу, и никакие другие методы уже не вызывались. Иными словами вот так:
public class HabrServiceTest {    @InjectMocks    private HabrService habrService;    @BeforeEach    void setUp(){        initMocks(this);    }    @Test    void handleTest() {        HabrItem item = new HabrItem();                Long actual = habrService.handle(item);                InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);        inOrder.verify(reviewService).makeRewiew(item);        inOrder.verify(persistenceService).makePersist(item);        inOrder.verifyNoMoreInteractions();        assertEquals(1L, actual);    }}


Добавим в поля класса класса reviewService и persistenceService, создадим их, добавим им методы makeRewiew() и makePersist() соответственно. Теперь все компилируется, но конечно же тест красный.
В контексте данной статьи, реализации ReviewService и PersistanceService не так уж важны, важна реализация HabrService, сделаем его чуть интересней чем он есть сейчас:
public class HabrService {    private final ReviewService reviewService;    private final PersistenceService persistenceService;    public HabrService(final ReviewService reviewService, final PersistenceService persistenceService) {        this.reviewService = reviewService;        this.persistenceService = persistenceService;    }    public Long handle(final HabrItem item) {        HabrItem reviewedItem = reviewService.makeRewiew(item);        Long persistedItemId = persistenceService.makePersist(reviewedItem);        return persistedItemId;    }}


и с помощью конструкций when().then() замокируем поведение вспомогательных компонентов, в итоге наш тест стал вот таким и теперь он зеленый:
public class HabrServiceTest {    @Mock    private ReviewService reviewService;    @Mock    private PersistenceService persistenceService;    @InjectMocks    private HabrService habrService;    @BeforeEach    void setUp() {        initMocks(this);    }    @Test    void handleTest() {        HabrItem source = new HabrItem();        HabrItem reviewedItem = mock(HabrItem.class);        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);        Long actual = habrService.handle(source);        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);        inOrder.verify(reviewService).makeRewiew(source);        inOrder.verify(persistenceService).makePersist(reviewedItem);        inOrder.verifyNoMoreInteractions();        assertEquals(1L, actual);    }}


Макет для демонстрации мощи параметризованных тестов готов.

Добавим в нашу модель запроса к сервису HabrItem поле с типом хаба, hubType, создадим enum HubType и включим в него несколько типов:
public enum HubType {    JAVA, C, PYTHON}


а модели HabrItem добавим геттер и сеттер, на созданное поле HubType.
Предположим, что в недрах нашего HabrService спрятался switch, который в зависимости от типа хаба делает с запросом неведомое что-то, и в тесте мы хотим протестировать каждый из кейсов неведомого, наивная реализация метода выглядела бы так:
            @Test    void handleTest() {        HabrItem reviewedItem = mock(HabrItem.class);        HabrItem source = new HabrItem();        source.setHubType(HubType.JAVA);        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);        Long actual = habrService.handle(source);        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);        inOrder.verify(reviewService).makeRewiew(source);        inOrder.verify(persistenceService).makePersist(reviewedItem);        inOrder.verifyNoMoreInteractions();        assertEquals(1L, actual);    }


Можно сделать ее чуть красивей и удобней, сделав тест параметризированным и добавить в него в качестве параметр случайное значение из нашего enum, в итоге декларация теста станет выглядеть так:
@ParameterizedTest    @EnumSource(HubType.class)    void handleTest(final HubType type) 


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

Но возможно я вас не убедил, что параметризованные тесты это хорошо. Добавим в
исходный запрос HabrItem новое поле editCount, в которое будет записано количество тысяч раз, которое пользователи Хабра редактируют свою статью, перед тем как запостить, чтоб она вам хоть немного понравилась, и положим что где то в недрах HabrService есть какая то логика, которая делает неведомое что-то, в зависимости от того, насколько попыток автор постарался, что если я не хочу писать 5 или 55 тестов на все возможные варианты editCount, а хочу протестировать декларативно, и где то в одном месте сразу обозначить все значения которые я хотел бы проверить. Нет ничего проще, и воспользовавшись api параметризованных тестов получим в декларации метода что то такое:
    @ParameterizedTest    @ValueSource(ints = {0, 5, 14, 23})    void handleTest(final int type) 


Налицо проблема, мы хотим собирать в параметрах тестового метода сразу два значения декларативно, можно использовать еще один прекрасный метод параметризованных тестов @CsvSource, отлично подойдет для того чтоб протестировать несложные параметры, с несложным выходным значением(крайне удобен в тестировании утилитных классов), но что если объект станет гораздо сложней? Скажем, в нем будет порядка 10 полей, причем не только примитивы и джава-типы.
На помощь приходит аннотация @MethodSource, наш тестового метода стал ощутимо короче и в нем нет больше сеттеров, а источник входящего запроса подается в тестовый метод параметром:
    @ParameterizedTest    @MethodSource("generateSource")    void handleTest(final HabrItem source) {        HabrItem reviewedItem = mock(HabrItem.class);        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);        Long actual = habrService.handle(source);        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);        inOrder.verify(reviewService).makeRewiew(source);        inOrder.verify(persistenceService).makePersist(reviewedItem);        inOrder.verifyNoMoreInteractions();        assertEquals(1L, actual);    }

в аннотации @MethodSource указана строка generateSource, что это? это название метода, который соберет для нас нужную модель, его декларация будет выглядеть так:
   private static Stream<Arguments> generateSource() {        HabrItem habrItem = new HabrItem();        habrItem.setHubType(HubType.JAVA);        habrItem.setEditCount(999L);                return nextStream(() -> habrItem);    }


для удобства формирование стрима аргументов nextStream я вынесес в отдельный утилитный тестовый класс
public class CommonTestUtil {    private static final Random RANDOM = new Random();    public static <T> Stream<Arguments> nextStream(final Supplier<T> supplier) {        return Stream.generate(() -> Arguments.of(supplier.get())).limit(nextIntBetween(1, 10));    }    public static int nextIntBetween(final int min, final int max) {        return RANDOM.nextInt(max - min + 1) + min;    }}

Теперь в при запуске теста, в параметр тестового метода декларативно будет добавляться модель запроса HabrItem, причем запускаться тест будет столько раз, сколько аргументов сгененрирует наша тестовая утилита, в нашем случае от 1 до 10.

Это может быть особо удобно, если модель в стриме аргументов собирается не хардкодом, как в нашем примере, а с помощью рандомайзеров.(да здравствует плавающие тесты, но ведь если они есть есть и проблема).
По моему уже все супер, в тесте теперь описано только поведение наших заглушек, и ожидаемые результаты.
Но вот незадача, в модель HabrItem добавляется новое поле, text, массив строк, которое может быть очень большим или не очень, неважно, главное то, что мы не хотим захламять наши тесты, нам не нужны рандомные данные, мы хотим строго определенную модель, с конкретными данными, собирать ее в тесте или где либо еще мы не хотим. Было бы круто, если бы можно было взять тело json запроса откуда угодно, например из постмана, сделать на его основе моковый файл и в тесте формировать модель декларативно, указав лишь путь к json файлу с данными.

Отлично. Используем аннотацию @JsonSource, которая будет принимать параметр path, с относительным путем к файлу и целевой класс. Черт! В параметризованных тестах нет такой аннотации, а хотелось бы.
Давайте напишем сами.
Обработкой всех аннотаций идущих в комплекте с @ParametrizedTest в junit занимаются ArgumentsProvider, мы напишем свой собственный JsonArgumentProvider:
public class JsonArgumentProvider implements ArgumentsProvider, AnnotationConsumer<JsonSource> {    private String path;    private MockDataProvider dataProvider;    private Class<?> clazz;    @Override    public void accept(final JsonSource jsonSource) {        this.path = jsonSource.path();        this.dataProvider = new MockDataProvider(new ObjectMapper());        this.clazz = jsonSource.clazz();    }    @Override    public Stream<Arguments> provideArguments(final ExtensionContext context) {        return nextSingleStream(() -> dataProvider.parseDataObject(path, clazz));    }}

MockDataProvider, это класс, для парсинга моковых json файлов, его реализация крайне простая:

public class MockDataProvider {    private static final String PATH_PREFIX = "json/";    private final ObjectMapper objectMapper;    @SneakyThrows    public <T> List<T>  parseDataList(final String name, final Class<T> clazz) {        return objectMapper.readValue(                new ClassPathResource(PATH_PREFIX + name).getInputStream(),                objectMapper.getTypeFactory().constructCollectionType(List.class, clazz)        );    }    @SneakyThrows    public <T> T parseDataObject(final String name, final Class<T> clazz) {        return objectMapper.readValue(new ClassPathResource(PATH_PREFIX + name).getInputStream(), clazz);    }}


Моковый провайдер готово, провайдер аргументов для нашей аннотации тоже, осталось добавить саму аннотацию
/** * Source-аннотация для параметризированных тестов, * использует в качестве источника json-файл */@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})@Retention(RetentionPolicy.RUNTIME)@ArgumentsSource(JsonArgumentProvider.class)public @interface JsonSource {    /**     * Путь к json-файлу, по умолчанию classpath:/json/     *     * @return относительный путь к моковому файлу     */    String path() default "";    /**     * Целевой тип, к которому будет приведен аргумент в результирующем стриме     *     * @return целевой тип     */    Class<?> clazz();}


Ура. Наша аннотация готова к употреблению, декларация тестового метода станет такой:

@ParameterizedTest    @JsonSource(path = MOCK_FILE_PATH, clazz = HabrItem.class)    void handleTest(final HabrItem source)


в моковом json мы можем наплодить сколь угодно и очень быстро пачку нужным нам объектов, и нигде отныне нет отвлекающего от сути теста кода, по формированию тестовых данных, конечно часто можно обойтись моками, но далеко не всегда.

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

p.s. Статья не является покушением на знание TDD концепций, хотелось накидать тестовые данные походу повествования, чтоб было чуть понятней и интересней.
Подробнее..
Категории: Java , Testing , Junit5 , Tdd

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru