Что такое property-based testing и как его реализовать?

Property-based testing — это метод тестирования, в котором проверяются обобщённые свойства кода (properties), справедливые при любых входных данных, а не конкретные примеры. В отличие от традиционного юнит-тестирования, где мы пишем assert(add(2, 3) == 5), property-based подход предполагает: «для любых двух целых чисел результат add(a, b) должен быть равен add(b, a)». То есть, в тестах не указываются конкретные значения, а описываются инварианты, которые всегда должны выполняться.

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

  1. Свойства (properties): утверждения, которые должны быть верны для всех (или большинства) допустимых входов.

  2. Генераторы: автоматически создают случайные данные нужного типа.

  3. Минимизация (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)) — генерация контейнера с элементами

Практика: когда использовать?

  • Когда важно протестировать широкие диапазоны входных данных.

  • Когда ручное перечисление кейсов невозможно или трудоёмко.

  • При создании библиотек или фреймворков, в которых важно гарантировать соблюдение математических свойств (например, моноидов, функторов, аппликативов, монад).