Про ScalaCheck

в 10:18, , рубрики: scala, scalacheck, unit-testing, Программирование, Тестирование IT-систем, функциональное программирование

Про ScalaCheck

Часть 1. Введение.

ScalaCheck — это комбинáторная библиотека, значительно облегчающая написание модульных тестов на Scala. В ней используется подход property-based тестирования, впервые реализованный в библиотеке QuickCheck для языка Haskell. Существует множество реализаций QuickCheck: есть реализации для Java, C, а так же других языков и платформ. Использование данного подхода позволяет значительно сократить время на разработку тестов.

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

Структура цикла

  • Введение
  • Генераторы
  • Свойства
  • Минимизация
  • Интеграция и настройки

Введение

Модульное тестирование является одним из важнейших подходов в разработке программного обеспечения. Даже если ваша программа при компиляции проходит проверки на уровне системы типов, это еще не означает, что в ней отсутствуют логические ошибки. Это значит, что каким бы мощным ни был ваш язык программирования, без тестирования кода не обойтись. Однако, стоимость тестирования весьма высока: помимо потраченных человеко-часов требуется тратить нечеловеко-yсилия на рутинное написание модульных тестов. Из-за этого многие заказчики экономят на тестировании, чем многие программисты пользуются с превеликой радостью: модульные тесты писать скучно (но нужно!).

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

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

Свойство-ориентированное тестирование

Что такое свойства

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

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

∀x ∈ ℝ:  x ≠ 0 ⟹ x² > 0

Если вы не понимаете лейбницкий, не отчаивайтесь: дальше все будет на Scala.

ScalaCheck позволяет записать его практически в оригинальной математической форме:

forall { x: Double => (x != 0) ==> x * x > 0 }

По-русски это читается примерно так: «Для любого вещественного числа x, не равного нулю, x² всегда больше нуля».

Автор прекрасно понимает, что тип Double — это ни капельки не ℝ, однако для
упрощения изложения абсолютной корректностью формулировок придется пожертвовать.

Как видите, свойство представляет собой более высокий уровень абстракции, чем традиционные assertion-тесты в JUnit. Однако, в конечном итоге все сводится к ним: на основе абстрактного описания свойств ScalaCheck генерирует вполне конкретные тесты отдельных значений, сопоставимые по качеству с теми, которые бы вы писали руками.

Свойства — не теории

В JUnit4 появился интересный механизм под общим названием theories. (Ох, они бы ещё гипотезами их назвали…) Теории работают весьма похожим на ScalaCheck образом, вот только не умеют генерировать случайные входные данные и выполнять минимизацию (shrinking).

Увы, я не придумал для вполне понятного термина «shrinking» лучшего перевода
на русский, чем «минимизация». Звучит не совсем корректно, но всяко лучше,
чем другие мои попытки.

Итак, что же такое теория в представлении JUnit? Прежде всего, это специальный тип модульного теста. Теория проверяет, действительно ли некоторое условие истинно для каждого элемента из заданного множества тестовых данных (они называются data points). Это позволяет один раз запрограммировать логику проверки, а затем быстро прогнать её на различных наборах данных.

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

@RunWith(Theories.class)
public class UserTest {

   // Первый тестовый набор — хороший.
   @DataPoint
   public static String GOOD_USERNAME = "optimus";

   // Второй тестовый набор — плохой, на нем тест и упадет.
   @DataPoint
   public static String USERNAME_WITH_SLASH = "optimus/prime";

   @Theory
   public void filenameIncludesUsername(String username) {
       assumeThat(username, not(containsString("/")));
       assertThat(new User(username).configFileName(),
                  containsString(username));
   }
}

Этот механизм похож на тот, что использует ScalaCheck. Есть лишь два маленьких отличия:

  • ScalaCheck сам генерирует тестовые данные;
  • если на некотором наборе данных тест падает, ScalaCheck пытается добить его контр-примером попроще, подобрать какой-нибудь более простой частный случай для облегчения дальнейшей отладки.

Именно эти отличия позволяет ScalaCheck одержать победу над теориями как по объему кода, так и по количеству тестов. Но если вам всё же интересно узнать больше о теориях, вы можете
почитать про них здесь.

Плюсы и минусы property-based подхода

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

  • Лаконичность: меньше кода — выше покрытие в сравнении со стандартным (assertion-based) подходом
  • Высокоуровневость: мы фокусируемся на входных данных вообще, а не на частных случаях.
  • Минимизация: когда что-то сломалось, нам помогут найти, где именно (или мы поможем себе сами).
  • Наличие сущностей, которые проще тестировать как единое целое, нежели тестировать их покомпонентно.

Тем не менее, свойство-ориентированное тестирование — не серебряная пуля.
У этого подхода есть и недостатки:

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

Когда использовать?

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

  • код, крайне чувствительный к входным данным;
  • конечные автоматы или любые системы, зависящие от состояния;
  • парсеры (это то, для чего использую ScalaCheck лично я);
  • разнообразные преобразователи данных:
    • валидаторы;
    • классификаторы;
    • агрегаторы;
    • сортировщики, и т.д.
  • Spark RDD, а также маперы и редьюсеры для Hadoop.

ScalaCheck

Особенности библиотеки

ScalaCheck — это:

  • компактная библиотека (менее двадцати файлов с кодом);
  • отсутствие дополнительных зависимостей;
  • поддержка тестов с внутренним состоянием (stateful testing);
  • отказ от использования java.util.Random в качестве генератора псевдослучайных чисел (более того, ScalaCheck внимательно следит за тем, чтобы случайные тестовые наборы не повторялись, используя для этого [поиск с возвратом][backtracking]).
  • поддержка scala-js и Dotty.

Внутри java.util.Random используется линейный конгруэнтный метод
генерации псевдослучайных последовательностей (далее — LCG). Подробнее вы можете
прочитать в официальной документации. LCG не обеспечивают достаточного
качества генерации псевдослучайных чисел, и используются в большинстве
библиотек исключительно за счет простоты и высокой производительности.
ScalaCheck использует свой генератор, который гораздо лучше ведет себя в
серьезных статистических применениях. Подробнее о генераторе вы можете узнать
здесь.

Подготовительные работы

После того, как мы определились с тем, нужно ли нам свойство-ориентированное тестирование и ScalaCheck в частности, давайте приступим к подготовительным работам. Добавьте следующую зависимость в ваш проект (я рассчитываю что вы, уважаемый читатель, уже перешли на Scala 2.12):

<!-- Пользователи sbt и gradle, скорее всего, знают о Maven. -->
<dependency>
    <groupId>org.scalacheck</groupId>
    <artifactId>scalacheck_2.12</artifactId>
    <version>1.13.4</version>
</dependency>

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

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

Немного о свойствах

Свойство представляет собой минимальный тестируемый модуль. Представлено экземпляром класса org.scalacheck.Prop. Простейший пример:

import org.scalacheck.Prop

val propStringLengthAfterConcat = Prop forAll { s: String =>
  val len = s.length
  (s + s).length == len + len
}

// Только при наличии конкретного типа исключения, свойство будет считаться
// успешным
val propDivByZero = Prop.throws(classOf[ArithmeticException] {1/0})

// Для любого целочисленного списка, при доступе к элементу индекс которого
// на единицу больше его длины, всегда будет выброшено
// исключение IndexOutOfBoundsException
val propListIndexOutOfBounds = Prop forAll { xs: List[Int] =>
  Prop.throws(classOf[IndexOutOfBoundsException]) {
    xs(xs.length + 1)
  }

Немного о генераторах

На практике генераторы приходится писать не реже, чем свойства. Для того чтобы воспользоваться ими, вам следует проимпортировать org.scalacheck.Gen.

import org.scalacheck.Gen

// Генераторы, которые случайно выбирает значение из равномерно
// распределенного диапазона.
val binaryDigit = Gen.choose(0, 1)
val octDigit    = Gen.choose(0, 7)

// Генератор, который с равной вероятностью выбирает значение
// из предоставленного списка.
val vowel = Gen.oneOf('a', 'e', 'i', 'o', 'u')

Также в ScalaCheck имеется набор готовых генераторов для стандартных типов:

// Генератор, возвращающий случайную строчную букву.
val alphaLower = Gen.alphaLowerChar

// Генератор, возвращающий случайный идентификатор (строку случайной
// длины, первый символ которой всегда строчная буква, а дальнейшие
// могут быть только цифрами или буквами).
val identifier = Gen.identifier

// Генератор, возвращающий случайное положительное число типа Long.
val natural = Gen.posNum[Long]

Вы также можете объединять имеющиеся генераторы, применяя к ним map и for comprehension:

val personGen = for {
  charValue <- Gen.oneOf("Jason", "Oliver", "Jessica", "Olivia")
  ageValue  <- Gen.posNum[Int]
} yield Person (name = nameValue, age = ageValue)

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

Автор: ppopoff

Источник

Поделиться

* - обязательные к заполнению поля