Основы написания property-based тестов на ScalaCheck

27 июля 2015

Property-based тесты — довольно простая, но очень полезная штука. Идея в следующем. Вы описываете инвариант в стиле «для любых данных, таких, что … выполняется условие …». При этом, в отличие от обычных тестов, вы не задаете явно все тестовые примеры, а только описываете свойства, которым они должны удовлетворять. Сами же примеры генерируются автоматически фреймворком для property-based тестирования. Если после определенного числа прогонов со случайными данными, удовлетворяющих описанию, условие действительно выполняется, тест считается пройденным. Иначе фреймворк пытается как можно сильнее сжать (shrink) пример, на котором тест завалился, после чего выводит его и завершает тест с ошибкой.

Примечание: Это в некотором роде продолжение заметки про ScalaTest, так что, если вдруг вы ее пропустили, ознакомьтесь: Тестирование в Scala с помощью ScalaTest и определение степени покрытия кода тестами.

Как и многие дргие хорошие вещи, property-based тесты изначально появились в мире Haskell, во фреймворке под названием QuickCheck. Аналог для языка Scala называется ScalaCheck и о нем пойдет речь далее в этом посте.

В чем преимущества property-based тестов перед обычными тестами:

  • Код получается короче, а тестов — намного больше;
  • Так как входные данные не контролируются, покрытие кода тестами обычно получается выше, ну или по крайней мере не ниже;
  • В тесты всегда включаются специальные случаи, о которых вы могли не подумать — пустые списки, максимальные значения чисел, строки из иероглифов, и так далее;
  • Многие находят property-based тесты более читаемыми и понятными;
  • Благодаря shrinking тест падает не просто на каких-то входных данных, а на минимальном примере;

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

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

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

В build.sbt дописываем зависимости:

"org.scalatest" %% "scalatest" % "2.2.4" % "test",
"org.scalacheck" %% "scalacheck" % "1.12.2" % "test"
// ^^^ keep this or property-based tests will fail with strange errors!

На момент написания этих строк последней версией ScalaCheck была 1.12.4, но в ней что-то сломали, как и в версии 1.12.3, из-за чего пользоваться пока что приходится версией 1.12.2.

ScalaCheck имеет собственный DSL для написания тестов. Кроме того, его можно использовать в качестве движка из ScalaTest. Мы пойдем именно этим путем, чтобы не плодить в проекте диалекты для написания тестов.

Простейший пример property-based теста:

import org.scalatest._
import org.scalatest.prop._

class MySimpleSpec extends FunSpec with Matchers
                   with GeneratorDrivenPropertyChecks {
  describe("MySimpleSpec") {
    it("runs") {
      forAll { (alpha: String, beta: String) =>
        whenever(!alpha.isEmpty && !beta.isEmpty) {
          (alpha.length + beta.length) should be > alpha.length
          (alpha.length + beta.length) should be > beta.length
        }
      }
    }
  }
}

Что тут есть для нас нового:

import org.scalatest.prop._

Импорт, дающий нам все, что касается property-based тестов.

with GeneratorDrivenPropertyChecks {

Подмешиваем трейт, который позволит нам использовать метод forAll и другие.

forAll { (alpha: String, beta: String) =>
  // ...
}

Почти вся магия сосредоточена здесь. Метод принимает лямбдочку, ожидающую аргументы определенных типов. Эта лямбдочка будет вызвана некоторое количество раз (по умолчанию 100) со случайными аргументами нужных типов. Помимо String из коробки также успешно генерируются Option[Long], Set[Double], List[(String, Long)] и подобные вещи. Кроме того, при желании можно самостоятельно определять генераторы для произвольных типов.

whenever(!alpha.isEmpty && !beta.isEmpty) {
  // ...
}

Это называется precondition и позволяет наложить ограничение на данные. Если входные данные не удовлетворяют условию, будет брошено специальное исключение. Метод forAll поймает это исключение и сгенерирует новые данные. Чтобы тест не зацикливался от whenever(false){} количество повторных генераций ограничено. Но это все гибко настраивается.

Если сейчас запустить приведенный тест, вы увидите, что он успешно проходит. Давайте попробуем изменить его как-то так:

whenever(/* !alpha.isEmpty &&*/ !beta.isEmpty) {

… и посмотрим, что будет. У меня тест упал с такой ошибкой:

TestFailedException was thrown during property evaluation.
  Message: 1 was not greater than 1
  Location: (MySimpleSpec:123)
  Occurred when passed generated values (
    arg0 = "",
    arg1 = "?"
  )

… что полностью соответствует нашим ожиданиям. Чтобы в сообщении об ошибке аргументы назывались не arg0 и arg1, тест можно переписать так:

forAll ("alpha", "beta") { (alpha: String, beta: String) =>
  // ...
}

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

forAll(minSuccessful(10)) { ... }

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

Также ScalaCheck помимо квантора всеобщности forAll имеет еще и квантор существования exists, но на практике он не очень полезен. Есть еще метод classify, позволяющий при прогоне сотен тестов посмотреть на распределение данных. Удобно, если хочется убедиться, что случайно не были пропущены какие-то сценарии. Как уже отмечалось, можно писать свои генераты. А также делать другие умопомрачительные вещи, которые, скорее всего, на практике вам никогда не понадобятся. Желающим глубже разобраться в ScalaCheck я настоятельно советую ознакомиться с документацией на официальном сайте.

Метки: , , .


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