Тесты в PHP: PHPUnit, Pest, Codeception — что выбрать
Три подхода к тестированию PHP-кода дают разный порог входа и разную цену сопровождения. Разбираем, где юнит-тесты, где интеграционные, а где приёмочные, и чем платишь за каждый выбор.

Проект на PHP растёт, ручная проверка форм после каждого релиза съедает по полдня, а баги всё равно всплывают в проде. Вопрос не в том, писать ли тесты, а в том, каким инструментом и на каком уровне. В экосистеме PHP три заметных игрока: PHPUnit (стандарт де-факто), Pest (надстройка над ним с другим синтаксисом) и Codeception (комбайн для юнит-, функциональных и приёмочных тестов). Выбор влияет не на «покрытие ради галочки», а на то, сколько времени команда тратит на поддержку тестов через полгода.
Разберём на конкретной задаче: есть сервис OrderService, который считает итог корзины со скидкой, и HTTP-эндпоинт POST /orders, который его дёргает. Покажем, чем отличается тест одного и того же поведения в каждом инструменте и во что это обходится.
Что очевидное решение делает не так
Соблазн — покрыть всё end-to-end тестами через браузер: запустил, кликнул, проверил результат на странице. Такие тесты ближе всего к тому, что видит пользователь, но они медленные (секунды на сценарий против миллисекунд у юнита), хрупкие (сменилась вёрстка — тест красный, хотя логика цела) и не говорят, где сломалось. Прогон из трёхсот браузерных сценариев в CI легко разрастается до двадцати минут, и команда начинает их пропускать.
Обратная крайность — только юнит-тесты на изолированные классы. Быстро и точно, но не ловит проблемы на стыках: сериализация, роутинг, валидация запроса, реальный ответ базы. Рабочая стратегия — пирамида: много быстрых юнит-тестов, меньше интеграционных, совсем немного приёмочных. Инструмент выбирается под уровень, а не наоборот.
PHPUnit: базовый уровень, на котором стоит остальное
PHPUnit (актуальна ветка 11.x) — стандарт, на который опираются и Laravel, и Symfony. Тест — это метод класса, наследующего TestCase, с ассертами и провайдерами данных.
<?php
// tests/Unit/OrderServiceTest.php — PHPUnit 11.x
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
final class OrderServiceTest extends TestCase
{
#[DataProvider('discountCases')]
public function testTotalWithDiscount(int $sum, int $percent, int $expected): void
{
$service = new OrderService();
// считаем итог корзины с процентом скидки
$this->assertSame($expected, $service->total($sum, $percent));
}
public static function discountCases(): array
{
return [
'без скидки' => [1000, 0, 1000],
'скидка 10%' => [1000, 10, 900],
'скидка 100%' => [1000, 100, 0],
];
}
}
Обратите внимание: с PHPUnit 10 провайдеры и группы задаются через PHP-атрибуты (#[DataProvider]), а старые аннотации в докблоках объявлены устаревшими. Если копируете пример из старой статьи с @dataProvider — на 11.x он выдаст депрекейшн. Точный список поддерживаемых атрибутов сверяйте в документации вашей версии.
Pest: тот же движок, меньше церемоний
Pest (2.x) работает поверх PHPUnit — под капотом тот же прогонщик и те же ассерты, но синтаксис функциональный, без классов и наследования. Тот же тест выглядит так:
<?php
// tests/Unit/OrderServiceTest.php — Pest 2.x
it('считает итог со скидкой', function (int $sum, int $percent, int $expected) {
expect((new OrderService())->total($sum, $percent))->toBe($expected);
})->with([
'без скидки' => [1000, 0, 1000],
'скидка 10%' => [1000, 10, 900],
'скидка 100%' => [1000, 100, 0],
]);
Меньше шаблонного кода, читается как список утверждений. Расплата: Pest — это ещё одна зависимость поверх PHPUnit, свой набор плагинов и стектрейсы, которые иногда уводят внутрь фреймворка Pest, а не в ваш код. Для команды, где половина людей впервые видит функциональный синтаксис, порог входа выше, чем кажется.
Codeception: приёмочные и функциональные тесты из коробки
Codeception (5.x) — не про юниты (хотя умеет и их), а про сценарии выше уровнем. Его сила — читаемые acceptance-тесты через один объект-актёр:
<?php
// tests/Acceptance/OrderCest.php — Codeception 5.x
class OrderCest
{
public function createOrder(AcceptanceTester $I): void
{
$I->amOnPage('/cart');
$I->fillField('promo', 'SALE10');
$I->click('Оформить');
$I->see('Итого: 900');
}
}
Такой тест понятен и не-разработчику, но за это платишь скоростью и настройкой: нужен запущенный веб-драйвер или встроенный модуль, тестовая база, фикстуры. Один упавший acceptance-тест не показывает, в каком классе баг, — только что страница показала не то.
Сравнение
| Критерий | PHPUnit 11.x | Pest 2.x | Codeception 5.x |
|---|---|---|---|
| Порог входа | средний, знаком многим | низкий синтаксис, но новый стиль | высокий, много модулей |
| Основной уровень | юнит + интеграция | юнит + интеграция | приёмочные + функциональные |
| Скорость | высокая | высокая (тот же движок) | ниже, особенно acceptance |
| Зависимости | одна | PHPUnit + Pest сверху | тяжёлый набор |
| Ясность «где сломалось» | высокая | высокая | низкая на acceptance |
Инструмент не заменяет стратегию. Хрупкий браузерный тест на неверном уровне вреднее его отсутствия: он краснеет от смены кнопки, а не от бага в расчёте.
Когда что выбирать
- Библиотека или пакет без вебморды — PHPUnit. Меньше зависимостей, максимальная совместимость, любой контрибьютор разберётся.
- Laravel-проект, команда любит лаконичность — Pest. В Laravel он ставится почти без настройки, синтаксис сокращает шум. Но выигрыш реален, только если вся команда пишет в одном стиле.
- Нужны сценарии «пользователь прошёл по шагам» — Codeception для acceptance, но держите их числом в десятки, а не сотни, и оставляйте юниты на PHPUnit/Pest.
- Legacy без тестов вообще — начните с PHPUnit на самой болезненной бизнес-логике (расчёты денег, скидки, права доступа), не с браузера.
Что ломается на практике
- Тесты зависят от порядка запуска. Общее состояние между тестами (статические свойства, синглтоны, недочищенная БД) даёт «зелёный локально, красный в CI». Каждый тест должен готовить и убирать своё окружение.
- Мокается всё подряд. Тест, где замокан и репозиторий, и сервис, и хелпер, проверяет моки, а не код. Такой тест переживёт любой баг.
- Приёмочные тесты цепляются к вёрстке. Селектор по классу CSS ломается при редизайне. Привязывайтесь к
data-атрибутам или ролям, а не к оформлению. - Дата и время в тестах.
new DateTime()внутри проверяемого кода делает тест зависимым от момента запуска. Пробрасывайте часы (clock) через аргумент.
Что делать дальше
Начните с одной команды в CI: vendor/bin/phpunit (или ./vendor/bin/pest) и включите отчёт о покрытии. Смотрите не на общий процент, а на то, покрыты ли деньги, права и скидки — места, где баг стоит дороже всего. Настройте прогон на каждый пуш и запретите мёрж красной ветки. Инвалидацию флаки-тестов и настройку параллельного прогона (paratest) оставьте на потом — сначала пусть будет хоть какой-то надёжный набор.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: PHPUnit Documentation, Pest — The elegant PHP testing framework
Частые вопросы
А если у меня старая версия PHPUnit, атрибуты не работают?
Pest конфликтует с обычными PHPUnit-тестами в одном проекте?
Тесты зелёные локально, но красные в CI — почему?
Сколько приёмочных тестов на Codeception держать?
С чего начать в проекте, где тестов вообще нет?
Нужен ли Xdebug для замера покрытия?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.