Вступление: почему ваши тесты врут, а вы им верите
Представьте: вы написали 50 юнит-тестов, покрыли все if-else, прогнали через CI — всё зелёное. Через неделю прод падает с ошибкой "Division by zero". Как? Ведь тесты же проходили! Знакомо?
Проблема классических юнит-тестов (примерных, с конкретными числами) — они проверяют то, что вы уже знаете. Вы выбираете аргументы, которые, по вашему мнению, приведут к ошибке. Но разум программиста предсказуем: мы все пишем testDivideByZero и забываем про testDivideByNegativeZero или testDivideByVeryLargeFloat.
Здесь на сцену выходит property-based тестирование в PHP — метод, который не проверяет конкретные сценарии, а доказывает свойства вашего кода для тысяч случайных входных данных. Сегодня мы разберём, как внедрить этот подход в ваш PHP-проект, используя Eris и PHPUnit. Вы узнаете, как находить баги, которые обычные тесты пропускают, и почему это не магия, а инженерия.
Что такое property-based тестирование? (и почему это не рандом)
Обычный юнит-тест выглядит так: assert(2 + 2 === 4). Вы проверяете одно конкретное утверждение. Property-based тестирование (PBT) работает иначе: вы формулируете свойство — утверждение, которое должно быть истинным для любых корректных входных данных.
Пример свойства для функции сложения:
- Коммутативность:
a + b === b + aдля любых чисел. - Идемпотентность: повторное применение функции не меняет результат.
- Инвариантность: результат всегда положителен (если это свойство).
PBT генерирует сотни случайных значений (включая граничные, нулевые, отрицательные, NaN) и проверяет, не нарушается ли свойство. Если находится контрпример — тест падает с конкретными входными данными, которые вызвали сбой. Вы получаете не просто "тест упал", а воспроизводимый баг.
В PHP эта техология особенно полезна из-за динамической типизации: вы никогда не знаете, какой тип прилетит в функцию на самом деле.
Инструменты для property-based тестирования в PHP
Главный инструмент — библиотека Eris (github.com/giorgiosironi/eris). Она интегрируется с PHPUnit и предоставляет генераторы случайных данных. Альтернатива — встроенные возможности PHPUnit 10+ (но они пока сыроваты). Мы будем использовать Eris, так как это зрелый и документированный инструмент.
Установка через Composer:
composer require --dev giorgiosironi/eris
Также пригодится Faker для генерации сложных структур (email, имена, адреса).
composer require --dev fakerphp/faker
Пишем первый property-based тест: ломаем сортировку
Допустим, у нас есть функция sortArray(), которая использует quicksort. Напишем свойство: "массив после сортировки не должен содержать элементов меньше предыдущего".
use Eris\TestTrait;
use Eris\Generator\IntegerGenerator;
use PHPUnit\Framework\TestCase;
class SortTest extends TestCase
{
use TestTrait;
public function testSortedArrayProperty(): void
{
$this->forAll(
Generator\seq(Generator\int())
)->then(function (array $input) {
$sorted = sortArray($input);
for ($i = 0; $i < count($sorted) - 1; $i++) {
$this->assertLessThanOrEqual(
$sorted[$i + 1],
$sorted[$i],
"Массив не отсортирован: " . json_encode($sorted)
);
}
});
}
}
Запускаем тест. Eris сгенерирует 100 случайных массивов (по умолчанию). Если хотя бы один массив нарушит свойство — тест упадёт с конкретными данными. Вы увидите что-то вроде:
Eris\Exception\LogicException:
ForAll terminated with a falsifying value: [0, -1, 2]
Вуаля! Вы нашли баг: функция не обрабатывает отрицательные числа. Без PBT вы бы, вероятно, тестировали только положительные целые.
Генерация сложных данных: Faker + Eris
Часто нужно тестировать не просто числа, а объекты, email-ы, строки с ограничениями. Eris умеет комбинировать генераторы, а Faker помогает создавать реалистичные данные.
Пример: тестируем валидатор email-адресов. Свойство: "после валидации email не должен содержать пробелов".
use Eris\TestTrait;
use Eris\Generator\StringGenerator;
use Faker\Factory;
class EmailValidatorTest extends TestCase
{
use TestTrait;
public function testEmailHasNoSpacesAfterValidation(): void
{
$faker = Factory::create();
$this->forAll(
Generator\constant($faker->email()) // генерируем валидные email
)->then(function (string $email) {
$validated = validateEmail($email);
$this->assertStringNotContainsString(' ', $validated);
});
}
public function testEmailValidationFailsForInvalidInput(): void
{
$this->forAll(
Generator\string() // любые строки, включая "abc@@.com"
)->then(function (string $input) {
$result = validateEmail($input);
// Если вход невалиден, функция должна вернуть false или null
if (!filter_var($input, FILTER_VALIDATE_EMAIL)) {
$this->assertFalse($result);
}
});
}
}
Второй тест — мощный: он проверяет, что валидатор не пропускает мусор. Eris сгенерирует строки вроде "a\0b@c" или " @ ". Если ваш валидатор скажет "ок" — тест упадёт.
Продвинутые техники: shrink и custom generators
Когда PBT находит контрпример, он пытается уменьшить (shrink) его до минимального набора данных, который всё ещё вызывает ошибку. Это упрощает отладку. Eris делает это автоматически.
Но вы можете настроить собственные генераторы. Например, для тестирования функции деления:
use Eris\Generator\IntegerGenerator;
use Eris\Generator\Filter;
$nonZeroInt = Filter::skip(
fn($v) => $v === 0,
IntegerGenerator::default()
);
$this->forAll(
IntegerGenerator::default(), // числитель
$nonZeroInt // знаменатель, не равный 0
)->then(function ($a, $b) {
$result = safeDivide($a,