Привет, Хабр!

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

А если хотите понять, насколько курс подходит именно вам, начните с небольшого входного теста.

Комментарии (1)


  1. roxblnfk
    04.07.2025 19:27

    Вот с тезисом ускорения написания тестов вообще не согласен ¯\_(ツ)_/¯
    В привычном ООПэшом стиле как-то проще, чем выстраивать паровозик методов из магически присунутого контекста.

    Когда пробовал PEST, то нашёл несколько фатальных недостатков:

    • Не работают фичи запуска кейсов в отдельных процессах типа runInSeparateProcess

    • Отвратительная поддержка в IDE, что понижает производительность разработчика. Как пример: невозможно запустить конкретный тест

    • Субъективно, но синтаксис точно не для меня. Поддерживать такое я бы не смог.

    Какое-то время юзал PEST в пакетах параллельно с PHPUnit: две конфигурации, разные папки. Да, PEST умеет запускать тесты PHPUnit, но стоило ему встретить атрибут runInSeparateProcess, как всё ломалось.
    Брал PEST только для одной фичи -- архитектурных тестов. Но потом узнал, что это тоже обёртка над плагином ta-tikoma/phpunit-architecture-test. В итоге установил оригинальный плагин и выкинул PEST нахрен, заменив всего одной функцией.

    Мой вывод такой: PEST был сделан для фронтендеров, которые привыкли к JEST, выгорели от JS и хотят попробовать немного мужицкого PHP, но не так чтоб сразу, а потихоньку, чтобы было немного JSно и комфортно.

    А всё вот это "выразительный", "делает тесты лаконичнее, читаемее и проще в поддержке" -- какой-то маркетинг. Куда проще в классе тест-кейса сделать фабрики, провайдеры, setUp/tearDown... и убрать любое дублирование кода в тестах, сделав их ещё лаконичнее, выразительнее, элегантнее и шелковистее, чем оно было бы в PESTе...