Тесты в Laravel: когда feature, когда unit и чем платить
Feature-тест поднимает приложение целиком и лезет в базу, unit-тест проверяет один метод в изоляции. Разбираем, где проходит граница, что и когда писать и почему тесты на 400 методов идут минуту.

Проект на Laravel 11.x, 320 тестов, прогон занимает 47 секунд на CI. Половина из них лежит в tests/Unit, но при этом каждый поднимает контейнер приложения, мигрирует SQLite в память и дергает Eloquent. Формально это unit-каталог, фактически — feature-тесты, которые притворяются быстрыми. Разберём, где реально проходит граница между двумя типами, что положить в каждый и чем платит проект, когда граница размыта.
Это разбор для того, кто уже пишет тесты в Laravel и упёрся в вопрос «куда положить этот тест и должен ли он лезть в базу». Про установку PHPUnit и запуск php artisan test не говорим — предполагаем, что прогон у вас уже работает.
Что Laravel называет unit, а что feature
После laravel new вы получаете два каталога: tests/Unit и tests/Feature, и два базовых класса. Разница между ними не в скорости и не в «размере» проверки, а в том, поднимается ли приложение.
Класс из tests/Feature наследует Tests\TestCase, который расширяет Illuminate\Foundation\Testing\TestCase. При старте вызывается createApplication() — грузится bootstrap/app.php, регистрируются провайдеры, поднимается контейнер. Внутри такого теста доступны $this->get('/orders'), фасады, конфиг, DI.
Класс из tests/Unit по умолчанию наследует PHPUnit\Framework\TestCase напрямую. Приложение не поднимается. Фасады не работают, config() недоступен, Eloquent без подключения упадёт. Это чистый PHPUnit: вы создаёте объект руками и проверяете его поведение.
Каталог не делает тест unit-тестом. Unit-тест делает изоляция: если для проверки нужен контейнер, база или HTTP-ядро — это feature-тест, где бы он ни лежал.
Ключевой признак, который путают чаще всего: наличие трейта RefreshDatabase. Как только тест использует базу — он не unit, потому что зависит от внешнего ресурса и от схемы миграций. Даже если он проверяет один метод модели.
Три варианта, куда положить проверку
Возьмём типичную задачу: у заказа считается итоговая сумма со скидкой. Логику можно протестировать тремя способами, и у каждого своя цена.
Вариант 1. Чистый unit — метод в изоляции
Логику скидки выносим в отдельный класс без зависимостей от Eloquent, и тестируем его напрямую. Никакой базы, никакого контейнера.
<?php
// tests/Unit/DiscountCalculatorTest.php
// Laravel 11.x, PHPUnit 11
namespace Tests\Unit;
use App\Services\DiscountCalculator;
use PHPUnit\Framework\TestCase;
class DiscountCalculatorTest extends TestCase
{
public function test_percentage_discount_applied(): void
{
$calculator = new DiscountCalculator();
// 1000 рублей, скидка 15 процентов
$result = $calculator->apply(1000_00, 15);
$this->assertSame(850_00, $result);
}
public function test_discount_over_100_percent_is_capped(): void
{
$calculator = new DiscountCalculator();
// скидка больше 100 процентов не должна уводить сумму в минус
$result = $calculator->apply(1000_00, 150);
$this->assertSame(0, $result);
}
}
Такой тест выполняется за доли миллисекунды, не зависит от схемы базы и падает ровно тогда, когда сломана логика скидки. Цена — вы обязаны держать логику отделённой от Eloquent. Если DiscountCalculator внутри дёргает Order::find(), изоляция кончилась.
Вариант 2. Feature с базой — модель и её связи
Если метод завязан на Eloquent, связи и casts, честный путь — feature-тест с RefreshDatabase.
<?php
// tests/Feature/OrderTotalTest.php
// Laravel 11.x
namespace Tests\Feature;
use App\Models\Order;
use App\Models\OrderItem;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class OrderTotalTest extends TestCase
{
use RefreshDatabase;
public function test_total_sums_items_with_discount(): void
{
$order = Order::factory()
->has(OrderItem::factory()->count(3)->state(['price' => 100_00]))
->create(['discount_percent' => 10]);
// 3 позиции по 100 рублей минус 10 процентов
$this->assertSame(270_00, $order->total());
}
}
Здесь проверяется реальная цепочка: связь items(), каст цены, метод total(). Цена — тест поднимает приложение, накатывает миграции и пишет в базу. Это медленнее unit на порядок и зависит от корректности factory.
Вариант 3. Feature-эндпоинт — весь путь запроса
Когда важно, что цифра доходит до клиента, проверяем HTTP-ответ целиком: роут, middleware, контроллер, сериализацию.
<?php
// tests/Feature/OrderApiTest.php
// Laravel 11.x
namespace Tests\Feature;
use App\Models\Order;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class OrderApiTest extends TestCase
{
use RefreshDatabase;
public function test_order_endpoint_returns_total(): void
{
$user = User::factory()->create();
$order = Order::factory()->for($user)->create(['id' => 42]);
$this->actingAs($user)
->getJson('/api/orders/42')
->assertOk()
->assertJsonPath('data.total', $order->total());
}
}
Это самый дорогой и самый ценный тест: он ловит регрессии в роутинге, авторизации и формате ответа. Но если он падает, диагностика дольше — сломаться могло что угодно на пути.
Сравнение по цене
| Критерий | Unit (изоляция) | Feature + база | Feature-эндпоинт |
|---|---|---|---|
| Поднимает приложение | Нет | Да | Да |
| Трогает базу | Нет | Да | Да |
| Скорость | Доли мс | Десятки мс | Десятки–сотни мс |
| Что ловит | Логику метода | Модель, связи, casts | Роут, auth, формат |
| Точность диагностики | Высокая | Средняя | Низкая |
| Требует отделения логики | Да, строго | Нет | Нет |
Когда что выбирать
- Чистая логика без Eloquent — расчёты, валидация правил, форматирование, конечные автоматы состояний. Пишите unit. Если для этого приходится городить моки на пол-экрана, значит, класс тянет слишком много зависимостей — это сигнал к рефакторингу, а не к переезду в feature.
- Поведение модели: связи, скоупы, casts, аксессоры — feature с базой. Мокать Eloquent руками дороже и хрупче, чем поднять SQLite в памяти.
- Контракт с клиентом: код ответа, структура JSON, авторизация — feature-эндпоинт. Один-два теста на happy path и на отказ доступа стоят больше десятка проверок внутренностей.
Практическое правило пропорции условно и зависит от проекта, но перекос в сторону тяжёлых feature-тестов на всё подряд — самая частая причина, почему прогон разрастается до минут. Мокирование внешних зависимостей1 позволяет часть проверок опустить на уровень unit.
Что ломается на практике
Три ошибки, которые встречаются чаще всего.
- Unit-тест, который на самом деле feature. В классе из
tests/Unitиспользуютconfig()или создают модель — и получаютError: Class "config" not foundлибо падение на подключении к базе. Либо переносите в feature, либо убирайте зависимость от контейнера. - Забыли
RefreshDatabase. Данные от предыдущего теста остаются в базе, и тесты начинают проходить или падать в зависимости от порядка запуска. Симптом: тест зелёный поодиночке и красный в общем прогоне. - SQLite в памяти против MySQL в проде. Тесты гоняют на
:memory:, а прод на MySQL. Различия в типах, чувствительности к регистру и работе с датами приводят к тому, что тест зелёный, а на бою запрос падает. Для критичных мест держите отдельный прогон против той же СУБД, что в проде.
Мокать модели через Mockery в unit-тестах технически можно, но связи и запросы Eloquent так не проверить — вы протестируете мок, а не поведение. Это отдельная тема, здесь только отмечаем: мок Eloquent почти всегда признак того, что тесту место в feature.
Что сделать дальше
Пройдитесь по tests/Unit и найдите классы, которые наследуют Tests\TestCase или используют базу — это кандидаты на переезд в feature либо на отделение логики. Замерьте вклад тяжёлых тестов: php artisan test --profile в Laravel 11.x покажет самые медленные. Настройте параллельный прогон через php artisan test --parallel — он поднимает несколько процессов и отдельные тестовые базы, и на большом наборе feature-тестов даёт ощутимый выигрыш. Точный набор флагов и требования к драйверу сверяйте в актуальной документации Laravel по тестированию для вашей версии.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Laravel Documentation — Testing: Getting Started, Laravel Documentation — Database Testing
Частые вопросы
У меня Laravel 10, а не 11. Разница есть?
Обязательно ли класть unit-тесты именно в tests/Unit?
Тесты проходят поодиночке, но падают в общем прогоне. Что не так?
Стоит ли гонять тесты на SQLite в памяти, если прод на MySQL?
Как понять, что feature-тестов стало слишком много?
Можно ли вообще обойтись без unit-тестов и писать только feature?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.