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

Kotest

Выбор библиотеки ассертов для проекта на 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. Надеюсь, что и вам эта статья пригодилась и поможет вам выбрать библиотеку для ассертов, подходящую под ваши нужды.

Подробнее..

Тестирование KotlinJS фреймворки, корутины и все-все-все

17.01.2021 00:16:40 | Автор: admin

Kotlin - блестящий проект. Изначально задуманный как просто JVM-язык, впоследствии он получил поддержку компиляции под все мейнстримные платформы, среди которых -- JavaScript.

Вводная. У меня есть пет-проект -- сайт и API-платформа для комьюнити по игре Elite: Dangerous. Бэкенд - на Kotlin/JVM (Ktor+Hibernate), фронтенд - на Kotlin/JS (KVision+Fomantic UI). О пет-проекте я расскажу как-нибудь потом, а о фронте поподобрнее.

  • KVision - фронтэнд-фреймворк для Kotlin, объединяющий в себе идеи из различных десктопных фреймворков (от Swing и JavaFX до WinForms и Flutter) и синтаксические возможности Kotlin, например, DSL-билдеры.

  • Fomantic-UI - форк Semantic-UI, компонентного веб-фреймворка для HTML/JS, который можно сравнить с Bootstrap, только Fomantic будет поинтереснее.

Не так давно я загорелся мыслью связать эти два мира и написать библиотеку для KVision, которая бы, как минимум, облегчила написание KVision-страниц с Fomantic-элементами. И, как подобается open source проекту, я планировал покрыть библиотеку тестами. Вот об этом приключении и будет эта статья.

Код

В первую очередь определимся с задачей. Есть у нас на руках следующий код простой Fomantic-кнопки:

package com.github.kam1sh.kvision.fomanticimport pl.treksoft.kvision.html.*import pl.treksoft.kvision.panel.SimplePanelopen class FoButton(text: String) : Button(text = text, classes = setOf("ui", "button")) {    var primary: Boolean = false        set(value) {            if (value) addCssClass("primary") else removeCssClass("primary")            field = value        }}fun SimplePanel.foButton(    text: String,    init: (FoButton.() -> Unit)? = null): FoButton {    val btn = FoButton(text)    init?.invoke(btn)    add(btn)    return btn}    

И есть парочка тестов:

package com.github.kam1sh.kvision.fomanticimport kotlin.test.BeforeTestimport kotlin.test.Testimport kotlin.test.assertEqualsimport kotlinx.browser.documentimport kotlinx.coroutines.*import pl.treksoft.kvision.panel.ContainerTypeimport pl.treksoft.kvision.panel.Rootclass FoButtonTest {    lateinit var kvapp: Root    @BeforeTest    fun before() {        kvapp = Root("kvapp", containerType = ContainerType.NONE, addRow = false)            }    @Test    fun genericButtonTest() {        kvapp.foButton("Button")        assertEqualsHtml("""...""")    }    @Test    fun buttonPrimaryTest() {        val btn = kvapp.foButton("Button") { primary = true }        assertEqualsHtml("""...""")        btn.primary = false        assertEqualsHtml("""...""")    }}fun assertEqualsHtml(expected: String, message: String? = null) {    val actual = document.getElementById("kvapp")?.innerHTML    assertEquals(expected, actual, message)}

Другими словами: "вшиваем" KVision в div с id=kvapp, создаём кнопку и потом ассертим её HTML-код.

Вопрос. Откуда должен взяться первоначальный div? Можно просто добавить HTML-код через какой-нибудь document.body?.insertAdjacentHTML(...), но что, если нам очень-очень хочется добавить прямо свою страничку вроде такой?

<source lang="html"><!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js"></script>    <link rel="stylesheet" type="text/css" href="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/fomantic-ui@2.8.7/dist/semantic.min.css">    <script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/fomantic-ui@2.8.7/dist/semantic.min.js"></script></head><body>    <main>        <div id="kvapp">        </div>    </main></body></html></source>

You've lost karma

Давайте сначала обратимся к разделу тестирования документации Kotlin/JS.
For browser projects, it downloads and installs the Karma test runner with other required dependencies; for Node.js projects, the Mocha test framework is used.
Ага. Karma и Mocha. Также говорится, что конфигурируется Karma через js-скрипты в папке karma.config.d.

После поиска по документации Karma по конфигам, приходим методом проб и ошибок к такому конфиг-скрипту:

// karma.config.d/page.jsconfig.set({  customContextFile: "../../../../../src/test/resources/test.html"})

Файл test.html, в моём случае, находится по пути src/test/resources/test.html. Из-за того, что Karma запускается в каталоге build/js/packages/kvision-fomantic-test/node_modules, нам для начала надо подняться на пять каталогов повыше.

Всё готово, верно? Запускаем ./gradlew browserTest, ииии... получаем Disconnected (0 times), because no message in 30000 ms.

Это всё потому, что наша HTML-страница несколько отличается от оригинальной, в которой выполняется особый JS-код. Оригинал можно посмотреть в build/js/node_modules/karma/static/context.html.

Копируем недостающий код в место сразу перед main-блоком:

<!-- The scripts need to be in the body DOM element, as some test running frameworks need the body     to have already been created so they can insert their magic into it. For example, if loaded     before body, Angular Scenario test framework fails to find the body and crashes and burns in     an epic manner. --><script src="context.js"></script><script type="text/javascript">    // Configure our Karma and set up bindings    %CLIENT_CONFIG%    window.__karma__.setupContext(window);    // All served files with the latest timestamps    %MAPPINGS%</script><!-- Dynamically replaced with <script> tags -->%SCRIPTS%<!-- Since %SCRIPTS% might include modules, the `loaded()` call needs to be in a module too.This ensures all the tests will have been declared before karma tries to run them. --><script type="module">    window.__karma__.loaded();</script><script nomodule>    window.__karma__.loaded();</script>

Запускаем, и... прикона, работает.

Корутины мои корутины

Но это всё более-менее просто. А если у нас есть корутины? Типа, HTTP-клиент Ktor или просто нам надо добавить задержку. Вот в Python мы бы просто повесили модификатор async, поставили к pytest плагин pytest-async, и всё.

Попробуем навесить suspend на функцию теста.

> Task :compileTestKotlinJs FAILED

e: ...src/test/kotlin/com/github/kam1sh/kvision/fomantic/FoButtonTest.kt: (44, 5): Unsupported [suspend test functions]

- Gradle

Хоба. Низзя. Тикет ещё не закрыт.

Ладно, тогда выполним весь код теста в runBlocking {}. Однако...

runBlocking эксклюзивен для Kotlin/JVM.

Это проблема, тоже есть тикет, правда, закрытый, мол, это by design. В качестве решения предлагается использовать GlobalScope.promise, и в принципе с ним можно написать runBlocking в одну строку:

fun runBlocking(block: suspend (scope: CoroutineScope) -> Unit) = GlobalScope.promise { block(this) }

Однако этого недостаточно. Если вы напишете такой код, у вас в определённый момент тесты начнут отваливаться по таймауту. Можно в Karma поднять таймаут следующим способом:

config.set({  client: {    mocha: {      timeout: 9000    }  }})

Но вечно поднимать таймауты не получится. Это просто workaround.

Mocha, из которой, на самом деле, и запускается код тестов, имеет два решения:

  • Делать функцию тестов, которые принимают done-колбэк, и дёргать колбэк по завершении теста.

  • Делать функцию теста такой, чтобы она возвращала Promise.

К сожалению, если вы попробуете добавить колбэк в объявление параметров метода, то ничего хорошего вы не получите. Точнее говоря, такой коллбэк в kotlin-test-js не поддерживается.
Второе, увы и ах, тоже не сделать. Ну, тест может возвращать Promise, но в Mocha он как бы не будет передан.

Что нам, собственно, остаётся? Как связать два мира -- мир корутин и мир тестирования?

Встречайте Волшебника из Канзаса - Kotest. Его разработчики всё за нас решили.

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

// build.gradle.ktstestImplementation("io.kotest:kotest-assertions-core-js:4.3.2")testImplementation("io.kotest:kotest-framework-api-js:4.3.2")testImplementation("io.kotest:kotest-framework-engine:4.3.2")
class FoButtonTest : FunSpec({    var kvapp: Root? = null    beforeEach {        kvapp = Root("kvapp", containerType = ContainerType.NONE, addRow = false)    }    test("generic button") {        kvapp!!.foButton("Button")        assertEqualsHtml("""...""")    }    test("primary button") {        val btn = kvapp!!.foButton("Button") { primary = true }        assertEqualsHtml("""...""")        btn.primary = false        delay(200)        assertEqualsHtml("""...""")    }})

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

Если внимательно присмотреться, то можно заметить, что во втором тесте есть delay() из корутин и при этом нигде нет модификатора suspend.

А он есть:

Это пока что всё, что я могу рассказать про kotest. Я ещё буду развивать библиотеку, и когда она будет готова, я анонсирую её выход в дискорде Fomantic и слаке Kotlin/kvision. И параллельно буду познавать kotest.

Если вам этого было мало, то приношу извинения: я хотел ещё здесь показать вывод ./gradlew --debug browserTest, но подготовка этого материала и так достаточно затянулась из-за появления личной жизни, так что если интересно -- созерцайте отладочные логи Gradle сами.

Ну и что? Ну и ничего. Кушайте Kotlin/JS, запивайте kotest-ом и берегите себя.

Подробнее..

Kotlin. Автоматизация тестирования (Часть 2). Kotest. Deep Diving

20.02.2021 10:19:11 | Автор: admin

Kotest


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


В этой части мы углубимся в возможности Kotest:


  • покажу все варианты группировки тесты
  • расскажу про последовательность выполнения тестов и спецификаций
  • изучим возможности параллельного запуска
  • настроим таймауты на выполнение тестов
  • проговорим про ожидания и Flaky-тесты
  • рассмотрим использование Фабрик тестов
  • и напоследок исследуем тему Property Testing

Все части руководства:



О себе


Я являюсь QA Лидом на одном из проектов Мир Plat.Form (НСПК). Проект зародился около года назад и уже вырос до четырех команд, где трудится в общей сложности около 10 разработчиков в тестировании (SDET), без учета остальных участников в лице аналитиков, разработчиков и технологов.
Наша задача автоматизировать функциональные тесты на уровне отдельных сервисов, интеграций между ними и E2E до попадания функционала в master всего порядка 30 микро-сервисов. Взаимодействие между сервисами Kafka, внешний API REST, а также 2 фронтовых Web приложения.
Разработка самой системы и тестов ведется на языке Kotlin, а движок для тестов был выбран Kotest.


В данной статье и в остальных публикациях серии я максимально подробно рассказываю о тестовом Движке и вспомогательных технологиях в формате Руководства/Tutorial.


Мотивация и цели


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


Уже в следующей части я расскажу про интеграцию со сторонними библиотеками:


  • Spring Core/Test. Dependencies Injection и конфигурирование профилей для тестов
  • Spring Data JPA. Работа с БД
  • TestContainers. Управление Docker контейнерами
  • Allure. Отчетность
  • Awaitility. Ожидания

Группировка тестов


В публикации много примеров с пояснениями двух видов: комментарии в коде либо объяснения вне блока кода со ссылками на конкретные места примера.
Куски кода больше половины стандартного экрана скрыты спойлером.
Рассматриваемая версия Kotest 4.3.2

Рекомендую на самых ранних стадиях развития проекта с тестами продумать группировку тестов. Самые базовые критерии группировки, которые первыми приходят на ум:


  1. По уровню. Модульные -> На один сервис (в контексте микро-сервисов) -> Интеграционные -> E2E


  2. По платформе. Windows\Linux\MacOS | Desktop\Web\IOS\Android


  3. По функциональности. Frontend\Backend В контексте целевого приложения: Авторизация\Администрирование\Отчетность...


  4. По релизам. Sprint-1\Sprint-2 1.0.0\2.0.0



Далее описание реализации группировки в Kotest


Теги


Для группировки тестов используются теги объекты класса abstract class io.kotest.core.Tag. Есть несколько вариантов декларирования меток:


  • расширить класс Tag, тогда в качестве имени метки будет использовано simpleName расширяющего класса, либо переопределенное свойство name.
  • использовать объект класса io.kotest.core.NamedTag, где имя метки передается в конструкторе.
  • создать String константу и использовать в аннотации @io.kotest.core.annotation.Tags, однако в прошлых версиях Kotest эта аннотация принимала тип KClass<T> класса тегов, а сейчас String имя тега.

Ниже представлены все варианты декларирования тегов:


/** * TAG for annotation @Tag only. */const val LINUX_TAG = "Linux"/** * Name will be class simple name=Windows */object Windows : Tag()/** * Override name to Linux. */object LinuxTag : Tag() {    override val name: String = LINUX_TAG}/** * Create [NamedTag] object with name by constructor. * Substitute deprecated [io.kotest.core.StringTag] */val regressTag = NamedTag("regress")

Применить тег к тесту можно несколькими путями:


  • через аннотацию @Tags на классе спецификации
  • extension функцию String.config в тесте или контейнере теста
  • переопределив метод tags(): Set<Tag> у спецификации

Однако, последний вариант я не рекомендую, т.к. тестовому движку для получения тега и проверки придется создать экземпляр класса Spec и выполнить init блок, а еще выполнить все инъекции зависимостей, если используется Dependency injection в конструкторе.

Пример с 2-мя классами спецификаций:


@Tags(LINUX_TAG) // Аннотацияclass LinuxSpec : FreeSpec() {    init {        "1-Test for Linux" { }                                        /* конфигурация теста String.config */        "2-Test for Linux and Regress only".config(tags = setOf(regressTag)) { }    }}class WindowsSpec : FreeSpec() {    /** Override tags method */    override fun tags(): Set<Tag> = setOf(Windows) // переопределение метода    init {        "Test for Windows" { }    }}

Остается задать выражение тегов для запуска ожидаемого набора тестов через системную переменную -Dkotest.tags=<выражение для выборки тестов по тегам>.
В выражении можно использовать ограниченный набор операторов: (, ), |, &


Теги чувствительны к регистру

Привожу несколько вариантов запуска в виде Gradle task


Набор Gradle Tasks для запуска групп тестов
// Используется встроенный в Gradle фильтр тестов по пакетам без фильтрации теговtask gradleBuildInFilterTest(type: Test) {    group "test"    useJUnitPlatform()    systemProperties = System.properties    filter { includeTestsMatching("ru.iopump.qa.sample.tag.*") }}// Запустить только тест с тегами regress (в тесте) и Linux (в аннотации спецификации) в LinuxSpectask linuxWithRegressOnlyTest(type: Test) {    group "test"    useJUnitPlatform()    systemProperties = System.properties + ["kotest.tags": "Linux & regress"]}// Запустить 2 теста с тегом Linux (в аннотации спецификации) в LinuxSpectask linuxAllTest(type: Test) {    group "test"    useJUnitPlatform()    systemProperties = System.properties + ["kotest.tags": "Linux"]}// Исключить из запуска тесты с тегом Linux, а также Windows. То есть запуститься 0 тестов task noTest(type: Test) {    group "test"    useJUnitPlatform()    systemProperties = System.properties + ["kotest.tags": "!Linux & !Windows"]}// Запустить тесты, у которых имеется тег Linux либо Windows. То есть все тестыtask linuxAndWindowsTest(type: Test) {    group "test"    useJUnitPlatform()    systemProperties = System.properties + ["kotest.tags": "Linux | Windows"]}

Обращаю внимание на 2 момента:


  • Kotest запускается через Junit5 Runner, поэтому декларирован useJUnitPlatform()
  • Gradle не копирует системные переменные в тест, поэтому необходимо явно указать systemProperties = System.properties

Условный запуск


В коде спецификации можно задать динамические правила включения/выключения тестов двумя путями:


  • на уровне спецификации через аннотацию @io.kotest.core.annotation.EnabledIf и реализацию интерфейса EnabledCondition
  • на уровне теста через расширение String.config(enabledIf = (TestCase) -> Boolean)
    Вот пример:

/** [io.kotest.core.annotation.EnabledIf] annotation with [io.kotest.core.annotation.EnabledCondition] */@EnabledIf(OnCICondition::class) // Аннотация принимает класс с логикой включениеclass CIOnlySpec : FreeSpec() {    init {                            /* Логика включения передается в конфигурацию теста */        "Test for Jenkins".config(enabledIf = jenkinsTestCase) { }    }}/** typealias EnabledIf = (TestCase) -> Boolean */val jenkinsTestCase: io.kotest.core.test.EnabledIf = { testCase: TestCase -> testCase.displayName.contains("Jenkins") }/** Separate class implementation [io.kotest.core.annotation.EnabledCondition] */class OnCICondition : EnabledCondition {    override fun enabled(specKlass: KClass<out Spec>) = System.getProperty("CI") == "true"}

Тест запустится, если:


  1. Среди системных переменных будет переменная CI=true
  2. В отображаемом имени TestCase встретится строка Jenkins

Функционал не самый используемый.
Есть более простой, но менее гибкий вариант через параметр enabled.
Например: "My test".config(enabled = System.getProperty("CI") == "true") { }

Последовательность выполнения


Уровень спецификации


По-умолчанию спецификации выполняются в порядке загрузки классов JVM. Это зависит от платформы, но скорее всего порядок будет алфавитный. Есть возможность задать порядок через конфигурацию уровня проекта используя enum io.kotest.core.spec.SpecExecutionOrder


object ProjectConfig : AbstractProjectConfig() {    override val specExecutionOrder = SpecExecutionOrder.Annotated}

  • SpecExecutionOrder.Undefined. Используется по-умолчанию и зависит от загрузки классов на платформе.
  • SpecExecutionOrder.Lexicographic. В алфавитном порядке имен классов спецификаций
  • SpecExecutionOrder.Random. В случайном порядке.
  • SpecExecutionOrder.Annotated. На основе аннотаций @Order над классами спецификаций с номерным аргументом. Меньше номер раньше выполнение. Не помеченные выполняются в конце по стратегии Undefined
  • SpecExecutionOrder.FailureFirst. Новая стратегия. Сначала выполняет упавшие в предыдущем прогоне тесты, а остальные спецификации по стратегии Lexicographic

Для использования FailureFirst необходимо включить сохранение результатов прогона в конфигурации проекта. По-умолчанию результаты сохраняются по пути на файловой системе ./.kotest/spec_failures в директории проекта.


object ProjectConfig : AbstractProjectConfig() {    override val specExecutionOrder = SpecExecutionOrder.FailureFirst    /**     * Save execution results to file for [SpecExecutionOrder.FailureFirst] strategy.     * File location: [io.kotest.core.config.Configuration.specFailureFilePath] = "./.kotest/spec_failures"     */    override val writeSpecFailureFile = true}

Привожу пример использования стратегии Annotated:


object ProjectConfig : AbstractProjectConfig() {    override val specExecutionOrder = SpecExecutionOrder.Annotated}@Order(Int.MIN_VALUE) // Аннотация на классеclass FirstSpec : FreeSpec() {    init {        "FirstSpec-Test" { }    }}@Order(Int.MIN_VALUE + 1) // Аннотация на классеclass SecondSpec : FreeSpec() {    init {        "SecondSpec-Test" { }    }}@Order(Int.MAX_VALUE) // Аннотация на классеclass LastSpec : FreeSpec() {    init {        "LastSpec-Test" { }    }}

Порядок выполнения будет такой: FirstSpec -> SecondSpec -> LastSpec. Чем больше число в @Order тем позже выполнение.


Уровень тестов


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


Для всего проекта можно настроить последовательность в конфигурации проекта:


object ProjectConfig : AbstractProjectConfig() {    override val testCaseOrder: TestCaseOrder = TestCaseOrder.Random}

В рамках одного класса через переопределение метода testCaseOrder:


class TestOrderingSpec : FreeSpec() {    override fun testCaseOrder(): TestCaseOrder = TestCaseOrder.Lexicographic}

Параллельность


Очень интересная и сложная тема, как в контексте разработки, так и в контексте тестирования.
Рекомендую ознакомиться с книгой Java Concurrency in Practice она как раз недавно появилась на русском языке.
Основой для принятия решения запуска тестов параллельно служит свойство теста быть независимым в контексте Фреймворка тестов и тестируемой системы.
Недостаточно обеспечить очистку состояния тестируемой системы, в которое она перешла после выполнения теста,- необходимо обеспечить неизменное состояние во время выполнения теста, либо гарантию, что изменение состояния не имеет инвариантов с другими воздействиями на систему в других тестах.


Для гарантии изолированности необходимо:


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

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


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


Уровень тестов


Можно задать кол-во потоков для тестов в рамках одного класса спецификации переопределив метод fun threads()


Пример спецификации с 2-мя тестами и 2-мя потоками:


class OneParallelOnTestLevelSpec : FreeSpec() {    private companion object {        private val log = LoggerFactory.getLogger(OneParallelOnTestLevelSpec::class.java)    }    override fun threads(): Int = 2    init {        "parallel on test level 1" {            log.info("test 1 started")            delay(500)            log.info("test 1 finished")        }        "parallel on test level 2" {            log.info("test 2 started")            delay(1000)            log.info("test 2 finished")        }    }}

Запустим с помощью gradle задачи:


task parallelismTest(type: Test) {    group "test"    useJUnitPlatform()    filter { includeTestsMatching("ru.iopump.qa.sample.parallelism.OneParallelOnTestLevelSpec") }}

Имеем вывод:


21:15:44:979 OneParallelOnTestLevelSpec - test 2 started21:15:44:979 OneParallelOnTestLevelSpec - test 1 started21:15:45:490 OneParallelOnTestLevelSpec - test 1 finished21:15:45:990 OneParallelOnTestLevelSpec - test 2 finished

По логу видно, что test 1 и test 2 запустились одновременно в 21:15:44:979 и завершились:
первый в 21:15:45:490, второй через 500мс, все по-плану.


Уровень спецификации


Чтобы запустить классы спецификаций в несколько потоков нужно задать настройки параллельности до их запуска, то есть на уровне конфигурации проекта:


object ProjectConfig : AbstractProjectConfig() {    override val parallelism: Int = 3}

Либо через системную переменную -Dkotest.framework.parallelism=3 или прямо в задаче gradle:


task parallelismTest(type: Test) {    group "test"    useJUnitPlatform()    doFirst { systemProperties = System.properties + ["kotest.framework.parallelism": 3] }    filter { includeTestsMatching("ru.iopump.qa.sample.parallelism.*") }}

Используем одну спецификацию с прошлого раздела OneParallelOnTestLevelSpec и 2 новые, где сами тесты внутри будут выполняться последовательно:


Еще 2 спецификации для параллельного запуска
class TwoParallelSpec : FreeSpec() {    private companion object {        private val log = LoggerFactory.getLogger(TwoParallelSpec::class.java)    }    init {        "sequential test 1" {            log.info("test 1 started")            delay(1000)            log.info("test 1 finished")        }        "sequential test 2" {            log.info("test 2 started")            delay(1000)            log.info("test 2 finished")        }    }}class ThreeParallelSpec : FreeSpec() {    private companion object {        private val log = LoggerFactory.getLogger(ThreeParallelSpec::class.java)    }    init {        "sequential test 1" {            log.info("test 1 started")            delay(1000)            log.info("test 1 finished")        }        "sequential test 2" {            log.info("test 2 started")            delay(1000)            log.info("test 2 finished")        }    }}

Все 3 спецификации должны запуститься параллельно, а также тесты в OneParallelOnTestLevelSpec выполняться в своих 2-ух потоках:


21:44:16:216 [kotest-engine-1] CustomKotestExtension - [BEFORE] prepareSpec class ru.iopump.qa.sample.parallelism.ThreeParallelSpec21:44:16:216 [kotest-engine-2] CustomKotestExtension - [BEFORE] prepareSpec class ru.iopump.qa.sample.parallelism.TwoParallelSpec21:44:16:216 [kotest-engine-0] CustomKotestExtension - [BEFORE] prepareSpec class ru.iopump.qa.sample.parallelism.OneParallelOnTestLevelSpec21:44:18:448 [SpecRunner-3] ThreeParallelSpec - test 2 started21:44:18:448 [SpecRunner-6] OneParallelOnTestLevelSpec - test 1 started21:44:18:448 [SpecRunner-5] TwoParallelSpec - test 1 started21:44:18:448 [SpecRunner-4] OneParallelOnTestLevelSpec - test 2 started21:44:18:959 [SpecRunner-6] OneParallelOnTestLevelSpec - test 1 finished21:44:19:465 [SpecRunner-5] TwoParallelSpec - test 1 finished21:44:19:465 [SpecRunner-3] ThreeParallelSpec - test 2 finished21:44:19:465 [SpecRunner-4] OneParallelOnTestLevelSpec - test 2 finished21:44:19:471 [SpecRunner-5] TwoParallelSpec - test 2 started21:44:19:472 [SpecRunner-3] ThreeParallelSpec - test 1 started21:44:20:484 [SpecRunner-3] ThreeParallelSpec - test 1 finished21:44:20:484 [SpecRunner-5] TwoParallelSpec - test 2 finished

По логу видно, что все 3 спецификации прошли за 2 сек это сумма последовательных тестов одной их спецификаций выше, тесты из OneParallelOnTestLevelSpec выполнились еще быстрее.
В выводе я оставил первые три строки, чтобы продемонстрировать имена потоков:


  • пул потоков для спецификаций NamedThreadFactory("kotest-engine-%d")
  • пул дочерних потоков для тестов NamedThreadFactory("SpecRunner-%d")

Все это запускается с помощью Executors.newFixedThreadPool и особенности работы с корутинами конкретно в этой конфигурации не используются, т.к. suspend функции выполняются через runBlocking:


executor.submit {    runBlocking {        run(testCase)    }}

Подробности реализации в методах io.kotest.engine.KotestEngine.submitBatch и io.kotest.engine.spec.SpecRunner.runParallel

Исключение из параллельного запуска


Если спецификацию по каким-то причинам следует выполнять в единственном потоке, то есть аннотация @DoNotParallelize.
Для отдельного теста на текущий момент подобного не предусмотрено.


Таймауты


Имеется большой набор вариантов для установки таймаутов на время выполнения теста.


Привожу пример спецификации со всеми вариантами таймаута:


Для пояснений в коде я буду использовать метки формата /*номер*/ и после примера рассказывать про каждую в виде: номер -

Варианты описания таймаутов
@ExperimentalTimeclass TimeoutSpec : FreeSpec() {    private companion object {        private val log = LoggerFactory.getLogger(TimeoutSpec::class.java)    }    /*1*/override fun timeout(): Long = 2000 // Spec Level timeout for each TestCase not total    /*2*/override fun invocationTimeout(): Long =        2000 // Spec Level invocation timeout for each TestCase invocation not total    init {        /*3*/timeout = 1500 // Spec Level timeout for each TestCase not total        /*4*/invocationTimeout = 1500 // Spec Level invocation timeout for each TestCase invocation not total        "should be invoked 2 times and will be successful at all".config(            /*5*/invocations = 2,            /*6*/invocationTimeout = 550.milliseconds,            /*7*/timeout = 1100.milliseconds        ) {            log.info("test 1")            delay(500)        }        "should be invoked 2 times and every will fail by invocationTimeout".config(            invocations = 2,            invocationTimeout = 400.milliseconds,            timeout = 1050.milliseconds        ) {            log.info("test 2")            delay(500)        }        "should be invoked 2 times and last will fail by total timeout".config(            invocations = 2,            invocationTimeout = 525.milliseconds,            timeout = 1000.milliseconds        ) {            log.info("test 3")            delay(500)        }    }}

Агрегирующего таймаута на всю спецификацию нет. Все варианты устанавливают таймаут только на тест.

5 invocations = 2 В Kotest есть возможность задать ожидаемое кол-во успешных выполнений одного теста, чтобы считать его успешным.
Например, чтобы проверить требование отсутствия состояния в системы, можно выполнить тест несколько раз, если хотя бы одно выполнение будет неуспешным, то тест будет считаться неуспешным. Не путайте с fluky это обратная ситуация.


1 override fun timeout(): Long = 2000 Через переопределение метода. Установить таймаут на тест в мс.


2 override fun invocationTimeout(): Long = 2000 Через переопределение метода. Установить таймаут на один вызов теста в мс (см 5).


3 timeout = 1500 Через свойство. Установить таймаут на тест в мс и переписать 1


4 invocationTimeout = 1500 Через свойство. Установить таймаут на один вызов теста в мс и переписать 2 (см 5).


6 invocationTimeout = 550.milliseconds Через метод конфигурации теста. Установить таймаут на тест в kotlin.time.Duration и переписать 3.


7 timeout = 1100.milliseconds Через метод конфигурации теста. Установить таймаут на один вызов теста в kotlin.time.Duration и переписать 4 (см 5).


kotlin.time.Duration имеет статус Experimental и при использовании требует установки @ExperimentalTime над классом спецификации

Разберем вывод запуска TimeoutSpec:


08:23:35:183 TimeoutSpec - test 108:23:35:698 TimeoutSpec - test 108:23:36:212 CustomKotestExtension - [AFTER] afterTest. Test case duration: 1047 ms08:23:36:217 TimeoutSpec - test 2Test did not complete within 400msTimeoutException(duration=400)08:23:36:625 TimeoutSpec - test 308:23:37:141 TimeoutSpec - test 3Test did not complete within 1000msTimeoutException(duration=1000)

Первый тест прошел успешно 2 раза, каждое выполнение уместилось invocationTimeout и весь тест в timeout
Второй тест упал на первом выполнении и не стал далее выполняться по invocationTimeout TimeoutException(duration=400)
Третий тест упал на втором выполнении по своему timeout TimeoutException(duration=1000)


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


@ExperimentalTimeobject ProjectConfig : AbstractProjectConfig() {    /** -Dkotest.framework.timeout in ms */    override val timeout = 60.seconds    /** -Dkotest.framework.invocation.timeout in ms */    override val invocationTimeout: Long = 10_000}

Встроенные ожидания


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


  • ожидание HTTP ответа (уже есть на уровне HTTP клиента, достаточно только указать таймаут)
  • ожидание появления запроса на заглушке
  • ожидание появления/изменения записи в БД
  • ожидание сообщения в очереди
  • ожидание реакции UI (для Web реализовано в Selenide)
  • ожидание письма на SMTP сервере

Если не понятно, почему нужно ждать в заглушке или в БД, отвечу в комментариях или сделаю отдельную публикацию

Есть отличная утилита для тонкой настройки ожиданий: Awaitility о ней пойдет речь в будущих частях. Но Kotest из коробки предоставляет простой функционал ожиданий.
В документации движка этот раздел называется Non-deterministic Testing.


Eventually


Подождать пока блок кода пройдет без Исключений, то есть успешно, в случае неуспеха повторять блок еще раз.
Функция из пакета io.kotest.assertions.timing: suspend fun <T, E : Throwable> eventually(duration: Duration, poll: Duration, exceptionClass: KClass<E>, f: suspend () -> T): T


f это блок кода, который может выбросить Исключение и этот метод будет его перезапускать, пока не выполнит успешно либо не пройдет таймаут duration.
poll это промежуток бездействия между попытками выполнения.
exceptionClass класс исключения по которому нужно попытаться еще раз.
Если есть уверенность, что когда код выбрасывает IllegalStateException, то нужно пробовать еще раз, но если, класс исключения другой, то успешно код точно никогда не выполниться и пытаться еще раз выполнить код не нужно, тогда указываем конкретный класс IllegalStateException::class.


Continually


Выполнять переданный блок в цикле, пока не закончится выделенное время либо пока код не выбросит Исключение.
Функция из пакета io.kotest.assertions.timing: suspend fun <T> continually(duration: Duration, poll: Duration, f: suspend () -> T): T?


f блок кода, который будет запускаться в цикле пока не пройдет таймаут duration либо не будет выброшено Исключение.
poll промежуток бездействия между попытками выполнения


Рассмотрим пример из четырех тестов (2 eventually + 2 continually)


Варианты использования ожидания
@ExperimentalTimeclass WaitSpec : FreeSpec() {    private companion object {        private val log = LoggerFactory.getLogger(WaitSpec::class.java)    }    /*1*/    private lateinit var tries: Iterator<Boolean>    private lateinit var counter: AtomicInteger    private val num: Int get() = counter.incrementAndGet()    init {        /*2*/        beforeTest {            tries = listOf(true, true, false).iterator()            counter = AtomicInteger()        }        "eventually waiting should be success" {            /*3*/eventually(200.milliseconds, 50.milliseconds, IllegalStateException::class) {            log.info("Try #$num")            if (tries.next()) /*4*/ throw IllegalStateException("Try #$counter")        }        }        "eventually waiting should be failed on second try" {            /*5*/shouldThrow<AssertionError> {            eventually(/*6*/100.milliseconds, 50.milliseconds, IllegalStateException::class) {                log.info("Try #$num")                if (tries.next()) throw IllegalStateException("Try #$counter")            }        }.toString().also(log::error)        }        "continually waiting should be success" - {            /*7*/continually(200.milliseconds, 50.milliseconds) {            log.info("Try #$num")        }        }        "continually waiting should be failed on third try" {            /*8*/shouldThrow<IllegalStateException> {            continually(200.milliseconds, 50.milliseconds) {                log.info("Try #$num")                if (tries.next()) throw IllegalStateException("Try #$counter")            }        }.toString().also(log::error)        }    }}

1 Блок с полями для подсчета попыток и описания итератора из трех элементов


2 Перед каждым тестовым контейнером сбрасывать счетчики


3 Вызывается функция eventually, которая завершится успешно. Общий таймаут 200 мс, перерыв между попытками 50 мс, игнорировать исключение IllegalStateException


4 Первые две итерации выбрасывают IllegalStateException, а 3-я завершается успешно.


5 Ожидается, что eventually закончится неуспешно и выполняется проверка выброшенного исключения. При неудаче eventually
выбрасывает AssertionError с информацией о причине неудачи и настройках.


6 таймаута 100 мс и перерыв между попытками 50 мс, позволяют выполнить только 2 неудачный попытки, в итоге ожидание завершается неуспешно


7 continually выполнит 4 попытки, все из которых будут успешные и само ожидание завершится успехом


8 Ожидается, что continually закончится неуспешно и выполняется проверка выброшенного исключения. При неудаче continually
перебрасывает последнее Исключение от кода внутри, то есть IllegalStateException, чем отличается от eventually


Лог выполнения с пояснениями
/////////////////////////////////////////////////////////////////// 1 ////////////////////////////////////////////////////////////////////////21:12:14:796 INFO CustomKotestExtension - [BEFORE] test eventually waiting should be success21:12:14:812 INFO WaitSpec - Try #121:12:14:875 INFO WaitSpec - Try #221:12:14:940 INFO WaitSpec - Try #3/////////////////////////////////////////////////////////////////// 2 ////////////////////////////////////////////////////////////////////////21:12:14:940 INFO CustomKotestExtension - [BEFORE] test eventually waiting should be failed on second try21:12:14:956 INFO WaitSpec - Try #121:12:15:018 INFO WaitSpec - Try #2/* Сообщение в ошибке содержит информацию о настройках ожидания */21:12:15:081 ERROR WaitSpec - java.lang.AssertionError: Eventually block failed after 100ms; attempted 2 time(s); 50.0ms delay between attempts/* Сообщение в ошибке содержит первое выброшенное кодом Исключение и последнее */ The first error was caused by: Try #1java.lang.IllegalStateException: Try #1The last error was caused by: Try #2java.lang.IllegalStateException: Try #2//////////////////////////////////////////////////////////////////// 3 ///////////////////////////////////////////////////////////////////////21:12:15:081 INFO CustomKotestExtension - [BEFORE] test continually waiting should be success21:12:15:081 INFO WaitSpec - Try #121:12:15:159 INFO WaitSpec - Try #221:12:15:221 INFO WaitSpec - Try #321:12:15:284 INFO WaitSpec - Try #4///////////////////////////////////////////////////////////////////// 4 //////////////////////////////////////////////////////////////////////21:12:15:346 INFO CustomKotestExtension - [BEFORE] test continually waiting should be failed on third try21:12:15:346 INFO WaitSpec - Try #121:12:15:409 INFO WaitSpec - Try #221:12:15:469 INFO WaitSpec - Try #3/* Здесь выбрасывается Исключение из выполняемого блока в continually */21:12:15:469 ERROR WaitSpec - java.lang.IllegalStateException: Try #3

Flaky тесты


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


  • на уровне CI перезапускать всю Задачу прогона тестов, если она неуспешна
  • на уровне Gradle сборки, используя плагин test-retry-gradle-plugin или аналог
  • на уровне Gradle сборки, с помощью своей реализации, например сохранять упавшие тесты и запускать их в другой задаче

Другие инструменты для сборки Java/Kotlin/Groovy кроме Gradle не рассматриваю

  • на уровне тестового движка
  • на уровне блоков кода в тесте (см. раздел Встроенные ожидания и retry)

На текущей момент в версии Kotest 4.3.2 нет функционала для перезапуска нестабильных тестов. И не работает интеграция с Gradle плагином test-retry-gradle-plugin.


Есть возможность перезапустить отдельный блок кода. Это особенно актуально для UI тестирования, когда клик на некоторые элементы иногда не срабатывает.
Представьте ситуацию с Dropdown внизу страницы, после скролла происходит клик, далее ожидание раскрытия элементов и клик на элемент списка.
Так вот, независимо от Фронтового фреймворка, иногда клик для раскрытия списка может не сработать и список не появляется, нужно еще раз попробовать начиная с клика.


Можно сделать повтор через try/catch, но лучше использовать
функцию suspend fun <T, E : Throwable> retry(maxRetry: Int, timeout: Duration, delay: Duration = 1.seconds, multiplier: Int = 1, exceptionClass: KClass<E>, f: suspend () -> T)::


// using Selenidedropdown.scrollTo()retry(2, 20.seconds, exceptionClass = Throwable::class) {    dropdown.click()    dropdownItems.find(Condition.text("1")).apply {        click()        should(Condition.disappear)    }}

Действие scrollTo повторять не нужно. После перехода к элементу блок с раскрытием списка и выбором повторить 2 раза, но не дольше 20 сек игнорируя все исключения (или более конкретные).


В Kotest возможно создать свое расширение для перезапуска упавших спецификаций/контейнеров теста/тестов.
В 3 части руководства я покажу как создать самый простой вариант для уровня тестов.
Остальные варианты я планирую реализовать в своем GitHub репозиторие и выпустить в MavenCentral, если к тому моменту разработчик Kotest не добавит поддержку 'из коробки'.


Фабрики тестов


Представим, что в каждом тесте нужно выполнять одни и те же действия для подготовки окружения: наполнять БД, очереди, конфиги на файловой системе.
Для этих действий хочется создать шаблон теста с адекватным именем, возможно с описанием в виде javadoc/kdoc и несколькими аргументами, например именем и паролем тестового пользователя.
В Kotest такой подход называется Test Factories и позволяет вставлять куски тестов в корневой тест.
Это ни функции, ни методы, ни абстракции это параметризованные части теста с той же структурой, что основной тест, но используемые в нескольких местах кода.


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

Очень важно не нарушать читабельность теста использованием шаблонов:


  • имя шаблона должно быть понятное и сопровождаться описанием, а так же описанием всех параметров.
  • шаблон должен выполнять одну функцию, например настройка БД (принцип единственной ответственности)
  • шаблон должен быть описан в BDD стиле
  • шаблон не должен быть слишком абстрактным (субъективно)

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


2 вида фабрик тестов. TestFactory и scope-функции
class FactorySpec : FreeSpec() {    init {        /*1.1*/include(containerFactory(1, 2, log))        "root container" - {            /*2.1*/containerTemplate()        }    }}/** Kotest factory */fun containerFactory(argument1: Any, argument2: Any, logger: Logger) =    /*1.2*/freeSpec {    beforeContainer { logger.info("This 'beforeContainer' callback located in the test factory") }    "factory container" - {        "factory test with argument1 = $argument1" { }        "factory test with argument2 = $argument2" { }    }}/** Add [TestType.Container] by scope function extension *//*2.2*/suspend inline fun FreeScope.containerTemplate(): Unit {    "template container with FreeScope context" - {        /*2.3*/testCaseTemplate()    }}/** Add [TestType.Test] by scope function extension *//*2.4*/suspend inline fun FreeScope.testCaseTemplate() {    "nested template testcase with FreeScope context" { }}private val log = LoggerFactory.getLogger(FactorySpec::class.java)

TestFactory

1.1 С помощью метода fun include(factory: TestFactory) включаем шаблон теста в спецификацию в качестве самостоятельного теста.


1.2 Определена функция containerFactory, которая возвращает экземпляр TestFactory и принимает несколько параметров. Для создания объекта TestFactory используем функцию fun freeSpec(block: FreeSpecTestFactoryConfiguration.() -> Unit): TestFactory, которая принимает блок тестов и предоставляет контекст FreeSpecTestFactoryConfiguration.
Внутри этого блока пишем тест, как обычно, в BDD силе. Далее с помощью includeшаблон вставляется как обычный контейнер теста.


Для TestFactory выполняются все обратные вызовы, что определены для спецификации, куда встраивается шаблон.
А также доступны все методы для создания обратных вызовов внутри фабрики, например beforeContainer.
У TestFactory есть большие ограничения:
  • нельзя вставлять include друг в друга
  • встраивать можно только на уровень спецификации, то есть фабрика должна быть полным тестом, а не частью теста.


Шаблоны через scope-функции и функции-расширения.

2.1 Внутри контейнера вызывается пользовательская функция containerTemplate и добавляет к контексту этого контейнера новые шаги теста.


2.2 Функция suspend inline fun FreeScope.containerTemplate(): Unit выполняет свой код в контексте внешнего теста FreeScope и просто изменяет этот контекст, добавляя новый вложенный контейнер.
Функция ничего не возвращает, а именно изменяет (side-effect) переданный контекст. Тесты пишем так, как будто они не в отдельной функции, а в спецификации.
suspend обязателен, т.к. все тесты запускаются в корутине.
inline для скорости, не обязателен. Указывает компилятору на то, что код этой функции нужно просто скопировать вместо вызова, то есть в байт-коде, не будет containerTemplate, а будет вставленный код в спецификации.
FreeScope. внутренности вызываются в контексте этого объекта, в данном случае контейнера


2.3 Внутри шаблона вызывается другой шаблон, который добавлен новые шаги. В реальных тестах так делать не следует


2.4 Функция suspend inline fun FreeScope.testCaseTemplate(): Unit добавляет вложенные шаги теста вместо вызова. Все то же самое, что и 2.2


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



Property тестирование


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


Генераторы данных


В Kotest существует 2 типа генераторов данных:


  • Arb (Arbitrary Случайный) генерирует бесконечные последовательности из которых по-умолчанию в тесте будет используется 1000 значений
  • Exhaustive (Исчерпывающий) служит для полного перебора ограниченного набора значений

Полный список генерируемых типов довольно большой и хорошо описан в официальной документации


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


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


Применение Arb генераторов для генерации случайных данных
/** For string generator with leading zero *//*1*/val numberCodepoint: Arb<Codepoint> = Arb.int(0x0030..0x0039)        .map { Codepoint(it) }/** For english string generator *//*2*/val engCodepoint: Arb<Codepoint> = Arb.int('a'.toInt()..'z'.toInt())    .merge(Arb.int('A'.toInt()..'Z'.toInt()))    .map { Codepoint(it) }class GeneratorSpec : FreeSpec() {    init {        /*3*/"random number supported leading zero" {            Arb.string(10, numberCodepoint).next()                .also(::println)        }        /*4*/"random english string" {            Arb.string(10, engCodepoint).orNull(0.5).next()                .also(::println)        }        /*5*/"random russian mobile number" {            Arb.stringPattern("+7\\(\\d{3}\\)\\d{3}-\\d{2}-\\d{2}").next()                .also(::println)        }        /*6*/"exhaustive collection and enum multiply" {            Exhaustive.ints(1..5).times(Exhaustive.enum<Level>()).values                .also(::println)        }        /*7*/"exhaustive collection and enum merge" {            Exhaustive.ints(1..5).merge(Exhaustive.enum<Level>()).values                .also(::println)        }    }}

1 Класс Codepoint задает набор символов в виде кодов Unicode. Этот класс используется в Arb для генерации строк.
В Kotest есть встроенные наборы символов в файле io.kotest.property.arbitrary.codepoints.
В примере определен набор Unicode цифр (такого набор нет среди встроенных). В дальнейших шагах этот набор будет использован для генерации номеров, которые могут начинаться на 0


2 Определен набор букв английского алфавита. Т.к. в Unicode они идут не по порядку, то сначала создается набор малого регистра, потом merge с набором большого регистра.


3 Использование набора символов цифр numberCodepoint для генерации номеров, где возможен 0 в начале типа String, длина 10 символов.


4 Использование набора символов английского алфавит engCodepoint для генерации слов длиной 10 символов. А также с вероятностью 50% вместо строки сгенерируется null метод orNull(0.5)


5 Метод Arb.stringPattern позволяет генерировать строки на основе RegEx паттерна очень полезная функциональность. Реализация на основе Generex. Производительность оставляет желать лучшего и зависит от сложности регулярного выражения.
Для номера телефона генерация происходит в 10 раз медленнее, чем при использовании Arb.string(10,numberCodepoint) с форматированием. У меня 1000 генераций телефона по паттерну -> 294 мс и 1000 генераций телефона из строки цифр с последующим форматированием -> 32 мс.
Замерять удобно методом measureTimeMillis.


6 Exhaustive фактически просто обертка для перечислимых типов и актуальна только в интеграции с Property-тестами, однако также имеет интересные возможности. Здесь происходит умножение набора Int на объекты enum Level и на выходы получается 25 Pair. Если интегрировать в тест, то будет 25 запусков для каждой пары.


7 Exhaustive объединение в одну коллекцию из 10 разнородных элементов вида: [1, ERROR, 2, WARN ...]


Вся функциональность генераторов доступна не только для Property-тестов, но и для простых тестов, где нужны случайные значения тестовых данных.
Для генерации одного значения используется терминальный метод Arb.next

Результаты запуска:


// Test 12198463900// Test 2tcMPeaTeXG// Test 3+7(670)792-05-16// Test 4[(1, ERROR), (1, WARN), (1, INFO), (1, DEBUG), (1, TRACE), (2, ERROR), (2, WARN), (2, INFO), (2, DEBUG), (2, TRACE), (3, ERROR), (3, WARN), (3, INFO), (3, DEBUG), (3, TRACE), (4, ERROR), (4, WARN), (4, INFO), (4, DEBUG), (4, TRACE), (5, ERROR), (5, WARN), (5, INFO), (5, DEBUG), (5, TRACE)]// Test 5[1, ERROR, 2, WARN, 3, INFO, 4, DEBUG, 5, TRACE]

Написание и конфигурирование Property тестов


Kotest предоставляет две функции для запуска Property-тестов, а также их перегруженные вариации:


  • suspend inline fun <reified A> forAll(crossinline property: PropertyContext.(A) -> Boolean)
  • suspend inline fun <reified A> checkAll(noinline property: suspend PropertyContext.(A) -> Unit)

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


Выполнить 1000 итераций кода со случайным входным числом типа Long:


checkAll<Long> { long: Long ->    val attempt = this.attempts()    println("#$attempt - $long")    long.shouldNotBeNull()}

Обращаю внимание на то, что функциональное выражение в аргументе forAll и checkAll не простое, а с дополнительным контекстом!


suspend PropertyContext.(A) -> Unit имеем аргумент generic типа (в примере это long: Long), но также имеем доступ к контексту PropertyContext, через ключевое слово this (для красоты его можно опустить).


val attempt = this.attempts() здесь возвращается кол-во пройденных попыток из PropertyContext, но там есть и другие полезные методы.


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

У обоих видов генераторов (Arb и Exhaustive) есть терминальные методы forAll и checkAll, которые запускают блок теста, принимающий сгенерированные значения от пользовательских генераторов в качестве аргумента.


Arb

Задача: Рассмотрим написание Property-теста на примере основной теоремы арифметики: каждое натуральное число можно представить в виде произведения простых чисел. Также это называется факторизация.
Допустим имеется реализованный на Kotlin алгоритм разложения и его необходимо протестировать на достаточном наборе данных.
Код алгоритма можно найти в примерах


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


Параметры генератора данных: Кол-во = 1000. Сгенерированное число должно быть больше 1. В сгенерированной последовательности первые два числа должны быть граничные: 2 и 2147483647.


"Basic theorem of arithmetic. Any number can be factorized to list of prime" {    /*1*/Arb.int(2..Int.MAX_VALUE).withEdgecases(2, Int.MAX_VALUE).forAll(1000) { number ->    val primeFactors = number.primeFactors    println("#${attempts()} Source number '$number' = $primeFactors")    /*2*/primeFactors.all(Int::isPrime) && primeFactors.reduce(Int::times) == number}    /*3*/Arb.int(2..Int.MAX_VALUE).checkAll(1000) { number ->    val primeFactors = number.primeFactors    println("#${attempts()} Source number '$number' = $primeFactors")    /*4*/primeFactors.onEach { it.isPrime.shouldBeTrue() }.reduce(Int::times) shouldBe number}}

1 создается генератор на 1000 итераций, множество значений от 2 до Int.MAX_VALUE включительно, отдельным методом withEdgecasesнеобходимо явно указать 2 и Int.MAX_VALUE граничными значениями.
Методом forAll запускаем тестирование, выходной результат блока теста Boolean. AssertionError Движок выбросит за нас, если будет результат false.


2 метод all принимает ссылку на метод и проверяет, что каждый множитель простой, метод reduce выполняет умножение, а также выполняется конъюнкцию двух проверок.


3 все абсолютно аналогично 1. Отличие только в том, что блок кода не должен возвращать Boolean и используются стандартные Assertions, как в обычном тесте.


4 вместо логических операций используем проверки Kotest


Exhaustive

Для исчерпывающего тестирования все аналогично Arb подходу.


Допустим, реализован метод, который в зависимости от enum UUIDVersion генерирует указанный тип UUID. А также же метод должен принимать null и генерировать UUID типа UUIDVersion.ANY.
Сигнатура: fun UUIDVersion?.generateUuid(): UUID


Чтобы проверить этот функционал, нужно перебрать абсолютно все значения UUIDVersion + null.
Не имея знаний о возможностях Exhaustive можно просто перебрать данные в цикле. Однако Exhaustive упрощает и без того несложную задачу:


"UUIDVersion should be matched with regexp" {    /*1*/Exhaustive.enum<UUIDVersion>().andNull().checkAll { uuidVersion ->    /*2*/uuidVersion.generateUuid().toString()    /*3*/.shouldBeUUID(uuidVersion ?: UUIDVersion.ANY)    .also { println("${attempts()} $uuidVersion: $it") }}}

1 reified функция Exhaustive.enum создаст последовательность всех значений UUIDVersion и добавит null, а далее будет вызван Property-тест


2 вызов тестируемой функции-расширения для генерации UUID


3 встроенная в Kotest проверка на соответствие UUID регулярному выражению


Генераторы по-умолчанию

Если использовать функции forAll и checkAll без явного указания генератора типа Arb или Exhaustive, то будет использоваться генератор по-умолчанию в зависимости от Generic-типа.
Например для forAll<String>{ } будет использован генератор Arb.string(). Вот полный набор из внутренностей Kotest:


fun <A> defaultForClass(kClass: KClass<*>): Arb<A>? {    return when (kClass.bestName()) {        "java.lang.String", "kotlin.String", "String" -> Arb.string() as Arb<A>        "java.lang.Character", "kotlin.Char", "Char" -> Arb.char() as Arb<A>        "java.lang.Long", "kotlin.Long", "Long" -> Arb.long() as Arb<A>        "java.lang.Integer", "kotlin.Int", "Int" -> Arb.int() as Arb<A>        "java.lang.Short", "kotlin.Short", "Short" -> Arb.short() as Arb<A>        "java.lang.Byte", "kotlin.Byte", "Byte" -> Arb.byte() as Arb<A>        "java.lang.Double", "kotlin.Double", "Double" -> Arb.double() as Arb<A>        "java.lang.Float", "kotlin.Float", "Float" -> Arb.float() as Arb<A>        "java.lang.Boolean", "kotlin.Boolean", "Boolean" -> Arb.bool() as Arb<A>        else -> null    }}

Примеры кода:


"check 1000 Long numbers" {    checkAll<Long> { long ->        long.shouldNotBeNull()    }}

Seed

Последнее, что хотелось бы добавить про Property-тестирование и генераторы, это воспроизведение ошибки. Для воспроизведения необходимо повторить тестовые данные. Чтобы повторить последовательность, существует такое понятие, как seed.
Используемый алгоритм псевдо случайных чисел для генерации последовательности использует некий порождающий элемент (seed), и для равных seed получаются равные последовательности.
Cемя изначально генерируется случайно, используя внешний порождающий элемент, например, время либо физический процесс. При возникновении ошибки, Kotest печатает используемое семя, которое можно указать в конфигурации теста и воспроизвести результат.


"print seed on fail" {    /*1*/shouldThrow<AssertionError> {    checkAll<Int> { number ->        println("#${attempts()} $number")        /*2*/number.shouldBeGreaterThanOrEqual(0)    }}./*3*/message.shouldContain("Repeat this test by using seed -?\\d+".toRegex())}"test with seed will generate the same sequence" {    Arb.int().checkAll(/*4*/ PropTestConfig(1234567890)) { number ->        /*5*/if (attempts() == 24) number shouldBe 196548668        if (attempts() == 428) number shouldBe -601350461        if (attempts() == 866) number shouldBe 1742824805    }}

1 Ожидаем исключение AssertionError с семенем.


2 Из 1000 чисел последовательности примерно половина будет точно меньше 0 и проверка должна сработать почти сразу


3 Демонстрация, что сообщение исключения содержит информацию о числе seed


4 Для теста явно указано семя в конструкторе PropTestConfig. Ожидаем, что для этого теста будет генерироваться всегда одна последовательность, независимо от платформы


5 attempts() возвращает номер итерации. Для итераций 24, 428, 866 будут всегда сгенерированы одинаковые числа


Заключение


Во-первых, привожу ссылку на все примеры qa-kotest-articles/kotest-second.


Был рассмотрен практически весь функционал Kotest


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


Ресурсы


Kotlin. Автоматизация тестирования (часть 1). Kotest: Начало


Примеры кода


Официальная документация Kotest


Kotest GitHub


Kotlin Lang


Coroutines Tutorial


Gradle Testing

Подробнее..

Категории

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

  • Имя: Макс
    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