Что такое property-based testing и как его реализовать?
Property-based testing — это метод тестирования, в котором проверяются обобщённые свойства кода (properties), справедливые при любых входных данных, а не конкретные примеры. В отличие от традиционного юнит-тестирования, где мы пишем assert(add(2, 3) == 5), property-based подход предполагает: «для любых двух целых чисел результат add(a, b) должен быть равен add(b, a)». То есть, в тестах не указываются конкретные значения, а описываются инварианты, которые всегда должны выполняться.
Основные понятия property-based тестирования
-
Свойства (properties): утверждения, которые должны быть верны для всех (или большинства) допустимых входов.
-
Генераторы: автоматически создают случайные данные нужного типа.
-
Минимизация (shrinking): при обнаружении ошибки фреймворк находит наименьший пример, на котором свойство нарушается — это облегчает отладку.
Примеры свойств
Если у нас есть функция сортировки:
def sort(xs: List\[Int\]): List\[Int\] = xs.sorted
Свойства, которые можно проверить:
-
результат отсортирован: isSorted(sort(xs)) == true
-
длина не изменилась: xs.length == sort(xs).length
-
содержимое не изменилось: xs.toSet == sort(xs).toSet
Библиотеки для property-based тестов в Scala
Самые популярные:
-
ScalaCheck — отдельная библиотека, может использоваться самостоятельно или с ScalaTest
-
ScalaTest + ScalaCheck — интеграция property-тестов в общий DSL
-
Discipline — используется в Cats, поверх ScalaCheck
Установка ScalaCheck
Добавление в build.sbt:
libraryDependencies += "org.scalacheck" %% "scalacheck" % "1.17.0" % Test
Либо если используется ScalaTest:
libraryDependencies += "org.scalatestplus" %% "scalacheck-1-17" % "3.2.18.0" % Test
Простой пример с ScalaCheck
import org.scalacheck.Properties
import org.scalacheck.Prop.forAll
object ListProperties extends Properties("List") {
property("reversing twice yields original list") = forAll { l: List\[Int\] =>
l.reverse.reverse == l
}
property("length after map is same") = forAll { l: List\[Int\] =>
l.map(_ + 1).length == l.length
}
property("sorted list is ordered") = forAll { l: List\[Int\] =>
val sorted = l.sorted
sorted.zip(sorted.drop(1)).forall {
case (a, b) => a <= b
}
}
}
Интеграция с ScalaTest
import org.scalatest.propspec.AnyPropSpec
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
class MyProps extends AnyPropSpec with ScalaCheckPropertyChecks {
property("reversing twice yields original list") {
forAll { xs: List\[Int\] =>
assert(xs.reverse.reverse == xs)
}
}
property("concatenation length") {
forAll { (a: List\[Int\], b: List\[Int\]) =>
(a ++ b).length == a.length + b.length
}
}
}
Создание собственных генераторов
import org.scalacheck.Gen
val evenIntGen: Gen\[Int\] = Gen.choose(0, 1000).suchThat(_ % 2 == 0)
val stringGen: Gen\[String\] = Gen.alphaStr
val pairGen: Gen\[(Int, String)\] =
for {
i <- Gen.posNum\[Int\]
s <- Gen.alphaStr
} yield (i, s)
Пользовательские генераторы в ScalaTest
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
import org.scalacheck.Arbitrary.arbitrary
import org.scalacheck.Gen
val positiveList = Gen.listOf(Gen.posNum\[Int\])
forAll(positiveList) { xs =>
assert(xs.forall(_ > 0))
}
Минимизация (Shrinking)
Когда свойство не выполняется, ScalaCheck автоматически уменьшает данные, чтобы найти минимальный пример, на котором тест не проходит. Например:
property("division") = forAll { (x: Int, y: Int) =>
(y != 0) ==> {
(x / y) \* y == x
}
}
Если выражение не выполняется, ScalaCheck найдёт минимальные значения x и y, чтобы продемонстрировать ошибку, например x = 1, y = 2.
Property-based тестирование в библиотеках
-
Cats и Cats Effect активно используют property-based тестирование для доказательства инвариантов алгебраических структур.
-
Scalaz и ZIO также используют этот подход в своих тестовых стратегиях.
-
http4s проверяет корректность сериализации и парсинга HTTP-запросов на тысячах случайных строк.
Примеры свойств из реальной практики
-
Коммутативность: a + b == b + a
-
Идемпотентность: normalize(normalize(x)) == normalize(x)
-
Консервативность: transform(x) содержит все элементы x
-
Инвертируемость: parse(render(x)) == x
Полезные утилиты в ScalaCheck
-
Gen.oneOf(...) — генерация одного значения из набора
-
Gen.frequency(...) — генерация с разным весом
-
Gen.const(...) — генерация константы
-
Gen.option(...) — генерация Some(x) или None
-
Gen.containerOf[List, Int](Gen.choose(0, 100)) — генерация контейнера с элементами
Практика: когда использовать?
-
Когда важно протестировать широкие диапазоны входных данных.
-
Когда ручное перечисление кейсов невозможно или трудоёмко.
-
При создании библиотек или фреймворков, в которых важно гарантировать соблюдение математических свойств (например, моноидов, функторов, аппликативов, монад).