Привет, Хабр!
Сегодня рассмотрим 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. Разберём, как выстраивать архитектуру, управлять командой, автоматизировать процессы и оставаться в курсе современных инструментов разработки — от тестов до деплоя.
А если хотите понять, насколько курс подходит именно вам, начните с небольшого входного теста.
