Как писать property-based тесты в Elixir?

Property-based тестирование в Elixir позволяет проверять общие свойства функций с использованием случайно сгенерированных входных данных, а не фиксированных значений. Это позволяет находить баги, которые не покрываются традиционными примерами.

Для реализации property-based тестирования в Elixir используется библиотека StreamData, которая предоставляет генераторы данных и макросы для написания тестов.

Установка StreamData

Добавь зависимость в mix.exs:

def deps do
\[
{:stream_data, "~> 0.6", only: :test}
\]
end

Затем выполни:

mix deps.get

Суть property-based тестирования

Вместо того чтобы проверять «для входа X результат должен быть Y», ты описываешь свойство, которое должно выполняться для всех допустимых X.

Пример: функция List.reverse/1 должна сохранять длину списка.

Пример property-based теста

defmodule MyTest do
use ExUnit.Case
use ExUnitProperties
property "reversing a list preserves its length" do
check all list <- list_of(integer()) do
assert length(list) == length(Enum.reverse(list))
end
end
end

Объяснение:

  • check all — основной макрос StreamData для генерации и проверки свойств.

  • list <- list_of(integer()) — генерирует случайные списки целых чисел.

  • assert ... — проверяется инвариант, который должен выполняться для каждого сгенерированного значения.

Генераторы

StreamData предоставляет множество генераторов данных:

  • integer() — случайные целые числа;

  • float() — вещественные;

  • boolean() — true/false;

  • binary() — строки;

  • list_of(generator) — список из случайных элементов;

  • map_of(key_gen, value_gen) — случайные мапы;

  • tuple({g1, g2, g3}) — генерация кортежей;

  • one_of([g1, g2, g3]) — выбрать случайный генератор из списка;

  • constant(value) — всегда одно и то же значение.

Сложные генерации

Можно комбинировать генераторы:

property "summing a list is the same as reducing it" do
check all list <- list_of(integer(), min_length: 1) do
sum1 = Enum.sum(list)
sum2 = Enum.reduce(list, 0, &+/2)
assert sum1 == sum2
end
end

Также можно строить свои генераторы:

defp my_struct_generator do
gen all x <- integer(1..10), y <- float(0.0..1.0) do
%{x: x, y: y}
end
end

Когда тест падает

Если тест падает, StreamData автоматически минимизирует (shrink) входные данные, чтобы найти наименьший случай, который вызывает сбой. Это делает отладку удобнее.

Пример:

property "division by non-zero" do
check all {a, b} <- tuple({integer(), integer()}) do
assume b != 0
assert a / b != :error
end
end

Функция assume/1 используется, чтобы отфильтровать недопустимые входы (в данном случае — деление на ноль).

Множественные свойства

В одном property можно проверять несколько утверждений:

property "map over list keeps length and types" do
check all list <- list_of(integer()) do
mapped = Enum.map(list, fn x -> x \* 2 end)
assert length(list) == length(mapped)
assert Enum.all?(mapped, &is_integer/1)
end
end

Настройки генерации

Можно настроить:

  • число генераций: ExUnit.configure(property_based: [max_runs: 1000])

  • ограничение по времени: ExUnit.configure(property_based: [max_run_time: 30_000])

  • ограничение на длину: list_of(..., max_length: 100)

Где это полезно

  • Проверка работы функций сортировки;

  • Проверка сериализации/десериализации;

  • Проверка коммутативных и идемпотентных операций;

  • Обнаружение edge-case’ов: пустые списки, нули, отрицательные числа.

Совместное использование с обычными тестами

Можно использовать обычные test и property в одном модуле:

defmodule MyApp.MathTest do
use ExUnit.Case
use ExUnitProperties
test "basic addition" do
assert 1 + 1 == 2
end
property "addition is commutative" do
check all a <- integer(), b <- integer() do
assert a + b == b + a
end
end
end

Property-based тесты дают более широкое покрытие за счёт обобщённого подхода. Они не заменяют unit-тесты, но помогают обнаруживать скрытые ошибки.