Про тестирование в Laravel обычно пишут «как», и почти никогда — «что». А это и есть основной вопрос: инструментов в фреймворке достаточно, проблема в том, что команды пишут не те тесты. Сотня тестов, проверяющих геттеры моделей, даёт красивую цифру покрытия и ноль уверенности при выкладке.
Разберём инструменты, а в конце — критерий, по которому я решаю, писать тест или нет.
Настройка
# Запуск тестов
php artisan test
# С покрытием
php artisan test --coverage
# Конкретный тест
php artisan test --filter=OrderTest
# Параллельно
php artisan test --parallel<!-- phpunit.xml -->
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="MAIL_MAILER" value="array"/>
</php>Feature tests — тестируют HTTP-запросы, полный flow. Unit tests — изолированные тесты классов/методов.
Factories
php artisan make:factory OrderFactory// database/factories/OrderFactory.php
namespace Database\Factories;
use App\Models\User;
use App\Models\Order;
use Illuminate\Database\Eloquent\Factories\Factory;
class OrderFactory extends Factory
{
protected $model = Order::class;
public function definition(): array
{
return [
'user_id' => User::factory(),
'order_number' => 'ORD-' . $this->faker->unique()->randomNumber(8),
'status' => $this->faker->randomElement(['pending', 'paid', 'shipped']),
'total' => $this->faker->numberBetween(1000, 100000),
'notes' => $this->faker->optional()->sentence(),
];
}
// States
public function pending(): static
{
return $this->state(['status' => 'pending']);
}
public function paid(): static
{
return $this->state(['status' => 'paid']);
}
public function shipped(): static
{
return $this->state(['status' => 'shipped']);
}
public function withItems(int $count = 3): static
{
return $this->has(
OrderItem::factory()->count($count),
'items'
);
}
}// Использование
// Один заказ
$order = Order::factory()->create();
// С состоянием
$order = Order::factory()->paid()->create();
// С связями
$order = Order::factory()
->withItems(5)
->for(User::factory()->state(['is_vip' => true]))
->create();
// Несколько
$orders = Order::factory()->count(10)->create();
// Без сохранения в БД
$order = Order::factory()->make();Feature Tests
// tests/Feature/OrderTest.php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
class OrderTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_view_their_orders(): void
{
$user = User::factory()->create();
$orders = Order::factory()->count(3)->for($user)->create();
// Чужие заказы
Order::factory()->count(2)->create();
$response = $this->actingAs($user)
->getJson('/api/orders');
$response
->assertOk()
->assertJsonCount(3, 'data')
->assertJsonStructure([
'data' => [
'*' => ['id', 'order_number', 'status', 'total'],
],
]);
}
public function test_user_can_create_order(): void
{
$user = User::factory()->create();
$product = Product::factory()->create(['price' => 1000, 'stock' => 10]);
$response = $this->actingAs($user)
->postJson('/api/orders', [
'items' => [
['product_id' => $product->id, 'quantity' => 2],
],
'delivery_address' => 'ул. Тестовая, 1',
]);
$response
->assertCreated()
->assertJsonPath('data.total', 2000);
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
'total' => 2000,
]);
// Проверяем списание со склада
$this->assertEquals(8, $product->fresh()->stock);
}
public function test_cannot_order_out_of_stock_product(): void
{
$user = User::factory()->create();
$product = Product::factory()->create(['stock' => 5]);
$response = $this->actingAs($user)
->postJson('/api/orders', [
'items' => [
['product_id' => $product->id, 'quantity' => 10],
],
'delivery_address' => 'ул. Тестовая, 1',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['items']);
}
public function test_guest_cannot_create_order(): void
{
$response = $this->postJson('/api/orders', []);
$response->assertUnauthorized();
}
}Unit Tests
// tests/Unit/Services/DiscountServiceTest.php
namespace Tests\Unit\Services;
use Tests\TestCase;
use App\Models\User;
use App\Services\DiscountService;
use Illuminate\Foundation\Testing\RefreshDatabase;
class DiscountServiceTest extends TestCase
{
use RefreshDatabase;
private DiscountService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new DiscountService();
}
public function test_no_discount_for_new_user(): void
{
$user = User::factory()->create();
$result = $this->service->apply($user, 10000);
$this->assertEquals(10000, $result);
}
public function test_3_percent_discount_for_10_orders(): void
{
$user = User::factory()
->has(Order::factory()->count(10)->state(['status' => 'completed']))
->create();
$result = $this->service->apply($user, 10000);
$this->assertEquals(9700, $result); // 10000 * 0.97
}
/**
* @dataProvider discountDataProvider
*/
public function test_discount_tiers(int $ordersCount, float $expectedMultiplier): void
{
$user = User::factory()
->has(Order::factory()->count($ordersCount)->state(['status' => 'completed']))
->create();
$result = $this->service->apply($user, 10000);
$this->assertEquals(10000 * $expectedMultiplier, $result);
}
public static function discountDataProvider(): array
{
return [
'new user' => [0, 1.0],
'9 orders' => [9, 1.0],
'10 orders' => [10, 0.97],
'19 orders' => [19, 0.97],
'20 orders' => [20, 0.95],
'50 orders' => [50, 0.90],
];
}
}Mocking
Мокайте то, чем владеете вы, а не чужие библиотеки. Мок стороннего SDK фиксирует ваше представление о том, как он работает, — и тест продолжит проходить после того, как поведение библиотеки изменится в обновлении. Оборачивайте внешнюю зависимость в свой интерфейс и мокайте его: тогда тест проверяет ваш контракт, а совместимость с библиотекой ловится одним интеграционным тестом.
Отдельно про Http::fake(): он подменяет HTTP-клиент Laravel и потому безопаснее мока SDK, но у него та же природа — он проверяет, что вы отправили запрос, а не что внешний сервис его примет.
Мок внешнего сервиса
use App\Services\PaymentGateway;
use Mockery;
public function test_order_is_charged(): void
{
// Создаём мок
$gateway = Mockery::mock(PaymentGateway::class);
$gateway->shouldReceive('charge')
->once()
->with(Mockery::on(fn($amount) => $amount === 5000))
->andReturn(true);
// Подменяем в контейнере
$this->app->instance(PaymentGateway::class, $gateway);
$user = User::factory()->create();
$order = Order::factory()->create(['total' => 5000, 'user_id' => $user->id]);
$response = $this->actingAs($user)
->postJson("/api/orders/{$order->id}/pay");
$response->assertOk();
}Мок HTTP-запросов
use Illuminate\Support\Facades\Http;
public function test_syncs_order_to_crm(): void
{
Http::fake([
'crm.example.com/*' => Http::response(['success' => true], 200),
]);
$order = Order::factory()->create();
// Вызываем код, который делает HTTP-запрос к CRM
$this->artisan('orders:sync');
Http::assertSent(function ($request) use ($order) {
return $request->url() === 'https://crm.example.com/orders'
&& $request['order_id'] === $order->id;
});
}Мок файловой системы
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\UploadedFile;
public function test_user_can_upload_avatar(): void
{
Storage::fake('public');
$user = User::factory()->create();
$file = UploadedFile::fake()->image('avatar.jpg', 200, 200);
$response = $this->actingAs($user)
->postJson('/api/user/avatar', [
'avatar' => $file,
]);
$response->assertOk();
Storage::disk('public')->assertExists('avatars/' . $file->hashName());
}Тестирование очередей
use Illuminate\Support\Facades\Queue;
use App\Jobs\ProcessOrderJob;
public function test_order_job_is_dispatched(): void
{
Queue::fake();
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/orders', $this->validOrderData());
$response->assertCreated();
Queue::assertPushed(ProcessOrderJob::class, function ($job) {
return $job->order->user_id === $this->user->id;
});
}
public function test_job_handles_order_correctly(): void
{
$order = Order::factory()->create();
// Выполняем job синхронно
(new ProcessOrderJob($order))->handle(
new CrmService(),
new PdfService()
);
$this->assertNotNull($order->fresh()->processed_at);
}Тестирование событий
use Illuminate\Support\Facades\Event;
use App\Events\OrderCompleted;
public function test_order_completed_event_is_dispatched(): void
{
Event::fake();
$order = Order::factory()->create(['status' => 'pending']);
$this->actingAs($order->user)
->postJson("/api/orders/{$order->id}/complete");
Event::assertDispatched(OrderCompleted::class, function ($event) use ($order) {
return $event->order->id === $order->id;
});
}Тестирование уведомлений
use Illuminate\Support\Facades\Notification;
use App\Notifications\OrderShipped;
public function test_user_is_notified_when_order_ships(): void
{
Notification::fake();
$order = Order::factory()->paid()->create();
$this->actisan('orders:ship', ['order' => $order->id]);
Notification::assertSentTo(
$order->user,
OrderShipped::class,
function ($notification) use ($order) {
return $notification->order->id === $order->id;
}
);
}RefreshDatabase vs DatabaseTransactions: RefreshDatabase мигрирует БД один раз и откатывает транзакции. DatabaseTransactions — только транзакции. Первый надёжнее, второй быстрее.
Итоги
| Что тестировать | Подход |
|---|---|
| HTTP endpoints | Feature tests + assertJson |
| Сервисы | Unit tests + моки зависимостей |
| Jobs, Events | Queue::fake(), Event::fake() |
| Внешние API | Http::fake() |
| Файлы | Storage::fake() |
Что тестировать, а что нет
Критерий, которым я пользуюсь: тест должен ломаться, когда ломается поведение, и не ломаться при рефакторинге. Из него следует почти всё остальное.
Тестируйте то, что имеет цену ошибки. Расчёт стоимости заказа, применение скидок, права доступа, обработка платёжных уведомлений — здесь тесты окупаются немедленно. Геттеры, ресурсы без логики, конфигурация — нет.
Тестируйте поведение, а не реализацию. Тест, проверяющий, что сервис вызвал такой-то метод репозитория, ломается при любом рефакторинге, ничего при этом не находя. Тест, проверяющий, что после оформления заказа товар зарезервирован, переживёт переписывание внутренностей.
Покрытие — не цель. Восемьдесят процентов покрытия, набранные тестами на тривиальный код, дают меньше уверенности, чем двадцать процентов на критичных сценариях. Метрика полезна как индикатор («вот этот модуль не покрыт вообще»), но не как задача.
Каждый найденный баг — это отсутствующий тест. Самый дешёвый способ решать, что тестировать: перед исправлением бага написать тест, который его воспроизводит. Так набор тестов растёт ровно там, где код реально ломается.
Следите за скоростью. RefreshDatabase откатывает транзакцию после каждого теста — это быстро; а вот полный migrate:fresh на каждый класс медленный. Набор тестов, идущий десять минут, перестают запускать перед коммитом, а незапускаемый тест бесполезен.
Про Queue::fake() и Event::fake(). Они удобны, но у них есть побочный эффект: подменяя очередь, вы перестаёте проверять саму задачу. Проверка «задача была отправлена» и проверка «задача делает то, что нужно» — два разных теста, и второй забывают чаще. Для критичных задач пишите оба.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.