PHPUnit: типичные ошибки в тестах и как их избежать
Тесты, которые зелёные всегда, тесты, которые падают через раз, и тесты, которые ловят вас на рефакторинге. Разбираем частые ошибки в PHPUnit и показываем, как их переписать.

Набор тестов на 1200 методов проходит за 40 секунд, покрытие 85%, все зелёные. А продакшн ломается на ровном месте. Знакомая картина: тесты есть, а уверенности нет. Чаще всего дело не в том, что тестов мало, а в том, что они написаны так, что проверяют не то, что нужно, зависят друг от друга или ловят реализацию вместо поведения. Разберём приёмы на PHPUnit 11.x (актуальная ветка на конец 2024 года; для 10.x всё описанное тоже верно, различия отмечу отдельно).
Тесты зависят друг от друга через общее состояние
Самая дорогая ошибка обнаруживается не сразу. Тесты проходят, если запускать весь класс целиком, но падают при запуске одного метода или в случайном порядке. Причина — общее состояние между тестами: статические свойства, синглтоны, записи в базе, которые не откатились.
PHPUnit по умолчанию не рандомизирует порядок, и это усыпляет бдительность. Тест A создаёт пользователя, тест B рассчитывает на то, что этот пользователь уже есть. Пока порядок стабилен — всё работает. Стоит вставить новый метод между ними или включить параллельный запуск — каскад падений.
Тест, который зависит от порядка выполнения, — это не тест, а ловушка для того, кто будет чинить его через полгода. И этим человеком, скорее всего, будете вы.
Лечится это принудительной рандомизацией порядка — тогда зависимости всплывут сразу, а не в проде. В phpunit.xml:
<!-- phpunit.xml, PHPUnit 11.x -->
<phpunit
executionOrder="random"
resolveDependencies="true">
<!-- остальная конфигурация -->
</phpunit>
Статические свойства сбрасывайте явно в tearDown(), а состояние базы изолируйте на уровне транзакций или отдельной тестовой БД, которую пересоздаёте перед прогоном. Если используете Laravel, для этого есть трейт с откатом транзакций после каждого теста; в чистом PHPUnit транзакцию открываете в setUp() и откатываете в tearDown().
Мокается всё подряд, тест проверяет мок
Разработчик, который недавно узнал про createMock(), начинает мокать любую зависимость. В итоге тест выглядит убедительно, но проверяет только то, что мок был настроен так, как его настроили. Реальная логика не задействована.
Классический признак: в тесте больше строк на настройку моков, чем на проверку результата. И когда вы меняете внутреннюю реализацию метода, не меняя его поведения, тест падает — потому что вы прописали в нём конкретную последовательность вызовов.
Разграничьте, что мокать стоит, а что нет:
- Мокайте то, что медленно, недетерминированно или дорого: HTTP-клиенты, отправку писем, платёжные шлюзы, текущее время.
- Не мокайте чистые функции, DTO, простые вэлью-объекты и то, что тестируете. Мок тестируемого класса — верный знак, что что-то пошло не так.
Сравните два подхода к проверке взаимодействия:
<?php
// Хрупкий: тест знает про внутренний порядок вызовов
$repository = $this->createMock(UserRepository::class);
$repository->expects($this->once())
->method('findById')
->with(42)
->willReturn($user);
// при рефакторинге, где findById вызовется дважды, тест упадёт
// Устойчивый: подставляем данные, проверяем результат
$repository = $this->createStub(UserRepository::class);
$repository->method('findById')->willReturn($user);
$service = new ProfileService($repository);
$this->assertSame('Иван', $service->getDisplayName(42));
createStub() задаёт возвращаемые значения без ожиданий по количеству вызовов. createMock() с expects() оставляйте для случаев, когда сам факт вызова — это и есть проверяемое поведение (например, письмо действительно отправлено).
assertTrue вместо конкретных assert-ов
Строка $this->assertTrue($result === $expected) работает, но когда тест упадёт, вы увидите только Failed asserting that false is true. Ни ожидаемого, ни фактического значения. Отладка превращается в добавление var_dump.
PHPUnit даёт специализированные ассерты, которые в сообщении об ошибке показывают дифф:
| Вместо | Пишите | Что покажет при падении |
|---|---|---|
assertTrue($a === $b) | assertSame($b, $a) | ожидаемое и фактическое значения |
assertTrue($a == $b) | assertEquals($b, $a) | дифф без учёта типов |
assertTrue(in_array($x, $arr)) | assertContains($x, $arr) | искомое и содержимое массива |
assertTrue(count($a) === 0) | assertCount(0, $a) | ожидаемое и реальное количество |
assertTrue($obj instanceof Foo) | assertInstanceOf(Foo::class, $obj) | ожидаемый и фактический класс |
Порядок аргументов у assertSame и assertEquals — сначала ожидаемое, потом фактическое. Перепутаете — тест отработает верно, но сообщения об ошибке будут вводить в заблуждение: PHPUnit назовёт expected то, что на самом деле фактическое.
Разница assertEquals и assertSame, о которой забывают
assertEquals сравнивает нестрого: '1' и 1 для него равны, объекты равны при совпадении свойств. assertSame требует идентичности: тот же тип, а для объектов — тот же экземпляр.
Ошибка выглядит так: тест на метод, который должен вернуть int, написан через assertEquals(5, $result). Метод по багу возвращает строку '5', тест зелёный, баг в проде. Для проверки скалярных значений, где важен тип, берите assertSame.
Тесты со случайными данными и текущим временем
Тест использует new DateTime() или time() внутри проверяемого кода и падает в полночь, в конце месяца или 29 февраля. Или использует rand() и падает раз в сто прогонов на CI, где его никто не может воспроизвести.
Время нельзя мокать напрямую в чистом PHPUnit — time() глобальная функция. Варианта два:
- Пробросить источник времени как зависимость (интерфейс
ClockInterfaceиз PSR-20) и подставить фиксированный в тесте. - Взять библиотеку, которая подменяет глобальное время. Проверьте её совместимость с вашей версией PHP по документации пакета, прежде чем добавлять в проект.
<?php
use Psr\Clock\ClockInterface;
final class FixedClock implements ClockInterface
{
public function __construct(private \DateTimeImmutable $now) {}
public function now(): \DateTimeImmutable
{
return $this->now;
}
}
// в тесте фиксируем момент — результат детерминирован
$clock = new FixedClock(new \DateTimeImmutable('2024-01-15 10:00:00'));
$service = new SubscriptionService($clock);
$this->assertFalse($service->isExpired($subscription));
Один тест проверяет пять вещей сразу
Метод testUserFlow() на 80 строк: создаёт пользователя, логинит, меняет пароль, проверяет письмо. Когда он падает на строке 60, вы не знаете, что именно сломалось, пока не прочитаете весь метод. И если ломается первая проверка, до остальных дело вообще не доходит — вы чините по одной за прогон.
Дробите на отдельные тесты с говорящими именами: testPasswordChangeSendsNotification(). Общую подготовку выносите в setUp() или в дата-провайдер. Дата-провайдеры вообще недооценивают — они превращают десять почти одинаковых тестов в один параметризованный:
<?php
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class PriceCalculatorTest extends TestCase
{
#[DataProvider('discountCases')]
public function testDiscount(int $amount, int $percent, int $expected): void
{
$calculator = new PriceCalculator();
$this->assertSame($expected, $calculator->apply($amount, $percent));
}
public static function discountCases(): array
{
return [
'без скидки' => [1000, 0, 1000],
'скидка 10%' => [1000, 10, 900],
'скидка 100%' => [1000, 100, 0],
];
}
}
Атрибут #[DataProvider] — это PHPUnit 10+. В 9.x провайдер задавался аннотацией @dataProvider в докблоке; при переезде на 10.x старый способ ещё работает с предупреждением, в 11.x аннотации помечены как устаревшие. Проверяйте версию, прежде чем копировать примеры из старых статей.
Что ломается на CI, но не локально
Локально всё зелёное, на CI падает — почти всегда одна из трёх причин:
- Порядок тестов. Локально запускаете один файл, CI гоняет всё. Всплывают скрытые зависимости через состояние. Лечится
executionOrder="random"локально тоже. - Часовой пояс и локаль. На вашей машине один
date.timezone, на раннере другой. Фиксируйте таймзону вphpunit.xmlчерез<ini name="date.timezone" value="UTC"/>и не полагайтесь на системную. - Незакоммиченные фикстуры или база. Локально в БД остались данные от прошлых прогонов, тест на них опирается. На чистой CI-базе их нет.
Что настроить дальше
Первый шаг после чтения — включить executionOrder="random" и прогнать набор несколько раз. Плавающие падения покажут, какие тесты связаны состоянием. Второй — включить failOnRisky и failOnWarning в конфиге: PHPUnit подсветит тесты без ассертов и с рискованным поведением, которые вы могли считать рабочими. Мутационное тестирование (проверка, что тесты вообще ловят изменения кода) — отдельная большая тема, здесь её не разворачиваю, но именно она честно отвечает на вопрос, ловят ли ваши тесты хоть что-то.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: PHPUnit Documentation — Writing Tests for PHPUnit, PHPUnit Documentation — Test Doubles
Частые вопросы
У меня PHPUnit 9.x, атрибуты не работают. Что делать?
Чем createStub отличается от createMock?
Тесты зелёные, но баги всё равно доходят до прода. Тесты бесполезны?
Как тестировать код, который зависит от текущего времени?
Что делать, если тест падает только на CI, а локально проходит?
Обязательно ли гнаться за 100% покрытием?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.