Привет, Хабр!
Сегодня рассмотрим Pest — минималистичный, но выразительный тестовый фреймворк для PHP. Он построен поверх PHPUnit и переосмысляет подход к написанию тестов: делает их лаконичнее, читаемее и проще в поддержке.
Pest — не альтернатива PHPUnit, а надстройка над ним. Он предоставляет декларативный DSL, сохраняя все фичи PHPUnit. Это позволяет использовать существующие PHPUnit-фичи, включая assertions, мок-объекты, аннотации, и при этом писать тесты в более компактной форме.
Как устроен синтаксис
Основные строительные блоки: test, it, expect, хуки (beforeEach, afterEach, beforeAll, afterAll) и fluent-методы, расширяющие возможности через ->with(), ->skip(), ->only(), ->throws() и др.
test() и it(): базовые единицы
test('2 + 2 равно 4', function () {
expect(2 + 2)->toBe(4);
});
it('возвращает true для положительного числа', function () {
$value = 10;
expect($value > 0)->toBeTrue();
});
Функции test() и it() идентичны по функциональности. Разница — только в стилистике описания. test() чаще используют для unit и feature-тестов, it() — для BDD-стиля.
Аргументы:
string $description — описание теста (обязателен)
Closure $closure — логика теста, опционально с параметрами
expect(): хелпер-обёртка над assertions
В Pest отсутствует привычный assert*-синтаксис. Вместо этого используется fluent-интерфейс expect(...), в основе которого лежат matchers.
Примеры:
expect($value)->toBe(42); // ===
expect($array)->toContain('foo'); // in_array
expect($text)->toStartWith('Hello'); // str_starts_with
expect($response)->toThrow(SomeException::class); // expectException
Также доступно not():
expect($list)->not()->toContain('bar');
Полный список встроенных матчеров:
toBe,toEqual,toMatchArray,toBeInstanceOf,toBeTrue,toBeFalsetoContain,toStartWith,toEndWith,toHaveCount,toBeEmpty,toBeNulltoThrow,toThrow(fn($e) => $e->getCode() === 403)— для кастомной проверки исключенийnot()— инвертирует любой следующий матч
Можно легко писать собственные matchers.
Хуки: beforeEach, beforeAll и другие
Pest предлагает familiar-интерфейс для инициализации окружения через хуки. Они заменяют необходимость переопределять setUp() в каждом классе.
beforeEach(function () {
$this->user = User::factory()->create();
});
afterEach(function () {
// clean up
});
Есть четыре типа хуков:
beforeEach()— перед каждым тестом в пределах файлаafterEach()— после каждого тестаbeforeAll()/afterAll()— аналогично, но один раз на весь файл
Контекст внутри Closure передаётся как $this, то есть доступны свойства и методы, объявленные в классе TestCase, если вы используете uses(...)->in(...).
Группировка: describe() и dataset()
Для логической группировки тестов можно использовать describe():
describe('User API', function () {
beforeEach(function () {
$this->user = User::factory()->create();
});
it('возвращает 200', function () {
$response = $this->getJson("/api/users/{$this->user->id}");
$response->assertOk();
});
it('содержит имя пользователя', function () {
$response = $this->getJson("/api/users/{$this->user->id}");
expect($response['name'])->toBe($this->user->name);
});
});
Функция describe() создаёт скоуп с shared-хуками и переменными.
Параметризация
Pest предлагает нативную поддержку параметризованных тестов через метод ->with(...).
it('делится на 2', function ($number) {
expect($number % 2)->toBe(0);
})->with([2, 4, 6]);
Кейсы можно именовать:
->with([
'двойка' => 2,
'четвёрка' => 4,
'шестёрка' => 6,
])
Для повторного использования: dataset(...)
dataset('even numbers', [2, 4, 6]);
it('делится на 2', function ($number) {
expect($number % 2)->toBe(0);
})->with('even numbers');
Поддерживаются генераторы:
dataset('слайды', function () {
yield 'слайд 1' => ['title' => 'Intro'];
yield 'слайд 2' => ['title' => 'Overview'];
});
Фильтрация и управление выполнением
Pest предоставляет fluent-интерфейс для управления выполнением тестов:
->skip()— пропустить тест->only()— запускать только этот тест->throws(...)— проверка на исключение->repeat(n)— запускать тест n раз->depends(...)— зависимость от других тестов
Пример:
test('не реализован')->skip();
test('бросает исключение', function () {
throw new InvalidArgumentException();
})->throws(InvalidArgumentException::class);
Кастомные matchers и expectations
Собственный DSL можно расширять через expect()->extend():
expect()->extend('toBeEven', function () {
return $this->toBeInt()->and($this->value % 2 === 0);
});
test('42 — чётное', function () {
expect(42)->toBeEven();
});
Это позволяет наращивать выразительность тестов в стиле документации.
Где и как это применяют
Юнит-тест бизнес-логики без зависимостей
Задача: проверить, что метод User->isAdult() возвращает true при возрасте ≥18.
test('пользователь совершеннолетний', function () {
$user = new User(age: 20);
expect($user->isAdult())->toBeTrue();
});
Такой юнит легко поддерживать и рефакторить.
Тест API-эндпоинта через Laravel HTTP Kernel
Задача: проверить, что /api/posts возвращает 200 OK и содержит JSON-массив.
test('GET /api/posts возвращает список', function () {
Post::factory()->count(3)->create();
$response = $this->getJson('/api/posts');
$response->assertOk();
$response->assertJsonIsArray();
});
$this — это Laravel TestCase, если предварительно указан uses(Tests\TestCase::class)->in(...). Pest умеет в DI и Laravel-контекст.
Параметризованный тест алгоритма
Задача: проверить функцию isPalindrome(string $input) на разных кейсах.
function isPalindrome(string $input): bool
{
return strrev($input) === $input;
}
it('распознаёт палиндромы', function ($word, $expected) {
expect(isPalindrome($word))->toBe($expected);
})->with([
['level', true],
['racecar', true],
['hello', false],
['radar', true],
]);
Поддерживается передача нескольких аргументов в with(), включая именование кейсов.
Проверка исключений и ошибок
Задача: метод Account->withdraw() должен выбрасывать InsufficientFundsException, если баланс < суммы списания.
test('выбрасывает исключение при недостатке средств', function () {
$account = new Account(balance: 100);
$account->withdraw(200);
})->throws(InsufficientFundsException::class);
Поддерживается throws(Class::class) и throws(fn(Exception $e) => $e->getCode() === 403).
Тест с зависимостями через Laravel DI
Задача: проверить, что TimeService возвращает текущий объект Carbon.
test('TimeService возвращает Carbon', function (TimeService $service) {
$now = $service->now();
expect($now)->toBeInstanceOf(Carbon::class);
});
Если TimeService зарегистрирован в Laravel-контейнере — он будет внедрён в тест как аргумент.
Хуки и шаринг состояния между тестами
Задача: создать пост один раз и использовать в нескольких тестах одного файла.
beforeEach(function () {
$this->post = Post::factory()->create([
'title' => 'Hello World',
]);
});
test('пост существует', function () {
expect($this->post)->not()->toBeNull();
});
test('заголовок корректный', function () {
expect($this->post->title)->toBe('Hello World');
});
Упрощает работу с общим состоянием и избавляет от дублирования фабрик.
Тестирование кастомного matcher'а
Задача: проверить, что число чётное, используя кастомный DSL.
expect()->extend('toBeEven', function () {
return $this->value % 2 === 0;
});
test('42 — чётное', function () {
expect(42)->toBeEven();
});
Переиспользуемость и читаемость улучшаются на уровне DSL.
Тестирование взаимодействия через mock-объект
Задача: убедиться, что Mailer->send() вызывается с нужными аргументами.
test('отправка уведомления', function () {
$mailer = Mockery::mock(Mailer::class);
$mailer->shouldReceive('send')
->once()
->with('user@example.com', Mockery::type(Message::class));
$notifier = new Notifier($mailer);
$notifier->notify('user@example.com');
});
Pest совместим с Mockery и любыми сторонними библиотеками.
UI и Browser тесты
Задача: проверить, что кнопка на главной странице присутствует.
test('кнопка "Войти" есть на главной', function () {
$this->browse(function ($browser) {
$browser->visit('/')
->assertSee('Войти');
});
});
Pest совместим с Laravel Dusk — важно просто подключить соответствующую TestCase.
Заключение
Pest — лаконичный DSL-слой поверх PHPUnit, который убирает шаблонный код, ускоряет написание тестов и повышает читаемость за счёт выразительных матчеров, хуков и параметризации; инструмент подходит как для unit-, так и для интеграционных и e2e-сценариев, легко встраивается в Laravel-экосистему и CI-конвейеры, совместим с Mockery, Dusk и параллельным запуском; если у вас уже есть кейсы или грабли, которые вы разгребали с Pest, делитесь опытом в комментариях.
Если вы уже знакомы с PHPUnit и хотите писать тесты быстрее, чище и выразительнее — возможно, вам стоит обратить внимание не только на Pest, но и на системный рост в профессии PHP‑разработчика.
16 июля пройдёт открытый урок «Что нужно знать, чтобы стать тимлидом на PHP» в рамках курса PHP Developer. Professional. Разберём, как выстраивать архитектуру, управлять командой, автоматизировать процессы и оставаться в курсе современных инструментов разработки — от тестов до деплоя.
А если хотите понять, насколько курс подходит именно вам, начните с небольшого входного теста.
roxblnfk
Вот с тезисом ускорения написания тестов вообще не согласен ¯\_(ツ)_/¯
В привычном ООПэшом стиле как-то проще, чем выстраивать паровозик методов из магически присунутого контекста.
Когда пробовал PEST, то нашёл несколько фатальных недостатков:
Не работают фичи запуска кейсов в отдельных процессах типа
runInSeparateProcessОтвратительная поддержка в IDE, что понижает производительность разработчика. Как пример: невозможно запустить конкретный тест
Субъективно, но синтаксис точно не для меня. Поддерживать такое я бы не смог.
Какое-то время юзал PEST в пакетах параллельно с PHPUnit: две конфигурации, разные папки. Да, PEST умеет запускать тесты PHPUnit, но стоило ему встретить атрибут
runInSeparateProcess, как всё ломалось.Брал PEST только для одной фичи -- архитектурных тестов. Но потом узнал, что это тоже обёртка над плагином
ta-tikoma/phpunit-architecture-test. В итоге установил оригинальный плагин и выкинул PEST нахрен, заменив всего одной функцией.Мой вывод такой: PEST был сделан для фронтендеров, которые привыкли к JEST, выгорели от JS и хотят попробовать немного мужицкого PHP, но не так чтоб сразу, а потихоньку, чтобы было немного JSно и комфортно.
А всё вот это "выразительный", "делает тесты лаконичнее, читаемее и проще в поддержке" -- какой-то маркетинг. Куда проще в классе тест-кейса сделать фабрики, провайдеры, setUp/tearDown... и убрать любое дублирование кода в тестах, сделав их ещё лаконичнее, выразительнее, элегантнее и шелковистее, чем оно было бы в PESTе...