LaravelMiddleCoding
Как писать unit-тесты и feature-тесты в Laravel?
Unit-тесты в Laravel изолируют класс без загрузки контейнера; feature-тесты загружают приложение и тестируют HTTP-слой через $this->get/post. Оба типа используют PHPUnit и трейты Laravel.
Unit-тесты и Feature-тесты в Laravel
Laravel поставляется с PHPUnit и надстройкой Illuminate\Testing. Тесты живут в tests/Unit/ и tests/Feature/. Unit-тесты не загружают приложение — быстрые и изолированные. Feature-тесты загружают полный стек Laravel, включая маршруты, middleware и БД.
Настройка окружения
php artisan make:test UserServiceTest --unit
php artisan make:test UserRegistrationTest # feature по умолчанию
Файл phpunit.xml задаёт переменные окружения для тестов:
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="CACHE_STORE" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
</php>
Unit-тест: изоляция сервиса
namespace Tests\Unit;
use App\Services\InvoiceCalculator;
use PHPUnit\Framework\TestCase; // НЕ Tests\TestCase!
class InvoiceCalculatorTest extends TestCase
{
public function test_vat_is_applied_correctly(): void
{
$calc = new InvoiceCalculator(vatRate: 0.20);
$result = $calc->calculate(baseAmount: 100.00);
$this->assertSame(120.00, $result->total);
$this->assertSame(20.00, $result->vat);
}
public function test_zero_amount_returns_zero(): void
{
$calc = new InvoiceCalculator(vatRate: 0.20);
$result = $calc->calculate(0);
$this->assertSame(0.0, $result->total);
}
}
Feature-тест: HTTP и база данных
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UserRegistrationTest extends TestCase
{
use RefreshDatabase; // откат транзакции после каждого теста
public function test_user_can_register(): void
{
$response = $this->postJson('/api/register', [
'name' => 'Alice',
'email' => 'alice@example.com',
'password' => 'secret1234',
'password_confirmation' => 'secret1234',
]);
$response->assertStatus(201)
->assertJsonStructure(['data' => ['id', 'email']]);
$this->assertDatabaseHas('users', ['email' => 'alice@example.com']);
}
public function test_duplicate_email_is_rejected(): void
{
User::factory()->create(['email' => 'alice@example.com']);
$response = $this->postJson('/api/register', [
'name' => 'Alice2',
'email' => 'alice@example.com',
'password' => 'secret1234',
'password_confirmation' => 'secret1234',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['email']);
}
public function test_authenticated_user_can_get_profile(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->getJson('/api/profile')
->assertOk()
->assertJsonPath('data.email', $user->email);
}
}
Моки и Fake-сервисы
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use App\Mail\WelcomeMail;
use App\Jobs\SendWelcomeEmail;
public function test_welcome_email_queued_on_register(): void
{
Queue::fake();
$this->postJson('/api/register', $this->validPayload());
Queue::assertPushed(SendWelcomeEmail::class, function ($job) {
return $job->user->email === 'alice@example.com';
});
}
public function test_mail_sent(): void
{
Mail::fake();
$user = User::factory()->create();
(new \App\Services\Mailer)->sendWelcome($user);
Mail::assertSent(WelcomeMail::class, fn($m) => $m->hasTo($user->email));
}
Запуск тестов
# Все тесты
php artisan test
# Только unit
php artisan test --testsuite=Unit
# Конкретный файл
php artisan test tests/Feature/UserRegistrationTest.php
# С покрытием (требует Xdebug или PCOV)
php artisan test --coverage --min=80
Подводные камни
- Использование
Tests\TestCase(загружает контейнер) вместоPHPUnit\Framework\TestCaseв Unit-тестах — тест становится медленнее и зависит от конфига. RefreshDatabaseзапускает все миграции заново при каждом запуске suite — на больших схемах используйтеDatabaseTransactionsвместо него.- Fakes (
Mail::fake(),Queue::fake()) нужно вызывать ДО кода, который запускает отправку, иначе реальный драйвер уже сработал. actingAs($user)не устанавливает сессию для stateless API — если тест проверяет cookie-сессию, нуженwithSession()или другой подход.- Factory с
->create()пишет в БД даже в unit-тестах, если случайно унаследоваться отTests\TestCaseс in-memory SQLite — забытыйRefreshDatabaseоставляет мусор. - Параллельный запуск (
php artisan test --parallel) требует изолированных БД на каждый процесс; без токена базы конфликтуют. - Моки через
$this->mock()сбрасываются после каждого теста, но если мок создан вsetUp()безtearDown(), утечка может влиять на следующий тест.
Common mistakes
- Сводить testing к названию метода без lifecycle и failure path.
- Игнорировать модель runtime: Laravel 13 поверх PHP: request проходит через HTTP kernel, middleware, routing, controller/action и response pipeline.
- Не отделять validation, authorization, transaction boundary и business logic.
What the interviewer is testing
- Объясняет testing через конкретную точку lifecycle в Laravel.
- Приводит корректный минимальный пример без вымышленных методов или callbacks.
- Называет edge cases: пустые значения, ошибки, транзакции, безопасность или concurrency.