Как писать 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-тесты, но помогают обнаруживать скрытые ошибки.