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.

Sources

Related topics