Привет, Хабр!
Сегодня рассмотрим 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
,toBeFalse
toContain
,toStartWith
,toEndWith
,toHaveCount
,toBeEmpty
,toBeNull
toThrow
,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е...