Laravel предоставляет инструменты для управления состоянием базы данных в тестах и проверки данных после операций.
Трейты для управления БД
RefreshDatabase
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class UserTest extends TestCase
{
use RefreshDatabase;
// Database is migrated once, then each test runs in a transaction
// that is rolled back after the test completes
public function test_user_creation(): void
{
$user = \App\Models\User::factory()->create();
$this->assertDatabaseHas('users', [
'id' => $user->id,
]);
}
// After this test, the user is automatically removed (transaction rollback)
}
Как работает RefreshDatabase:
- При первом тесте в классе -- запускает
php artisan migrate:fresh - Каждый тест оборачивается в транзакцию
- После теста транзакция откатывается
- База данных остаётся чистой для следующего теста
Подвох на экзамене: RefreshDatabase использует транзакции для очистки. Это означает, что данные, созданные в тесте, НЕ видны в отдельных процессах (например, Dusk browser tests). Для Dusk используйте DatabaseMigrations.
DatabaseTransactions
<?php
use Illuminate\Foundation\Testing\DatabaseTransactions;
class OrderTest extends TestCase
{
use DatabaseTransactions;
// Same as RefreshDatabase but does NOT run migrations
// Assumes database schema is already up-to-date
// Wraps each test in a transaction and rolls back
public function test_order_placement(): void
{
$user = \App\Models\User::factory()->create();
$order = \App\Models\Order::factory()->for($user)->create();
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
]);
}
}
Подвох на экзамене: DatabaseTransactions НЕ запускает миграции. Он предполагает, что схема уже актуальна. Используйте его, если миграции запускаются отдельно (например, в CI/CD перед тестами).
DatabaseMigrations
<?php
use Illuminate\Foundation\Testing\DatabaseMigrations;
class DuskTest extends TestCase
{
use DatabaseMigrations;
// Runs migrate:fresh before EACH test
// Slower but ensures clean state for Dusk tests
// No transaction wrapping (data visible in browser process)
}
Сравнение трейтов
| Трейт | Миграции | Очистка | Скорость | Dusk |
|---|---|---|---|---|
RefreshDatabase |
Один раз | Transaction rollback | Быстро | Нет |
DatabaseTransactions |
Нет | Transaction rollback | Быстро | Нет |
DatabaseMigrations |
Каждый тест | migrate:fresh | Медленно | Да |
LazilyRefreshDatabase |
При первом обращении | Transaction rollback | Быстро | Нет |
LazilyRefreshDatabase (Laravel 11+)
<?php
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
class ApiTest extends TestCase
{
use LazilyRefreshDatabase;
// Migrations run ONLY if test actually touches the database
// Useful for tests that may or may not need DB
public function test_health_check(): void
{
// This test doesn't touch DB, so migrations are NOT run
$this->getJson('/health')->assertOk();
}
public function test_user_count(): void
{
// This test touches DB, migrations run before first DB call
\App\Models\User::factory(3)->create();
$this->assertDatabaseCount('users', 3);
}
}
Database Assertions
assertDatabaseHas
<?php
use App\Models\User;
public function test_user_is_stored(): void
{
$response = $this->postJson('/api/users', [
'name' => 'John Doe',
'email' => '[email protected]',
'password' => 'SecurePass123!',
]);
// Check record exists with specific values
$this->assertDatabaseHas('users', [
'name' => 'John Doe',
'email' => '[email protected]',
]);
// With custom connection
$this->assertDatabaseHas('users', [
'email' => '[email protected]',
], 'mysql_secondary');
}
assertDatabaseMissing
<?php
public function test_deleted_user_is_removed(): void
{
$user = User::factory()->create();
$this->actingAs($user, 'sanctum')
->deleteJson("/api/users/{$user->id}");
$this->assertDatabaseMissing('users', [
'id' => $user->id,
]);
}
assertDatabaseCount
<?php
public function test_seeder_creates_correct_amount(): void
{
$this->seed(\Database\Seeders\UserSeeder::class);
// Assert exact count
$this->assertDatabaseCount('users', 50);
}
public function test_bulk_import(): void
{
$this->assertDatabaseCount('products', 0); // Empty before
$this->postJson('/api/products/import', [
'file' => \Illuminate\Http\UploadedFile::fake()->create('products.csv'),
]);
$this->assertDatabaseCount('products', 100);
}
// Assert table is empty
$this->assertDatabaseEmpty('notifications');
assertSoftDeleted
<?php
use App\Models\Post;
public function test_post_is_soft_deleted(): void
{
$post = Post::factory()->create();
$this->actingAs($post->user, 'sanctum')
->deleteJson("/api/posts/{$post->id}");
// Assert record is soft deleted (deleted_at is NOT null)
$this->assertSoftDeleted('posts', [
'id' => $post->id,
]);
// Assert using model
$this->assertSoftDeleted($post);
}
// Assert record is NOT soft deleted
public function test_post_is_restored(): void
{
$post = Post::factory()->create();
$post->delete(); // Soft delete
$this->actingAs($post->user, 'sanctum')
->postJson("/api/posts/{$post->id}/restore");
$this->assertNotSoftDeleted('posts', [
'id' => $post->id,
]);
// Assert using model
$this->assertNotSoftDeleted($post);
}
Подвох на экзамене: assertSoftDeleted() проверяет, что запись СУЩЕСТВУЕТ в таблице И deleted_at IS NOT NULL. assertDatabaseMissing() для soft deleted записи ПРОЙДЁТ, если в условии нет deleted_at, потому что запись всё ещё в таблице.
<?php
$post = Post::factory()->create();
$post->delete(); // Soft delete
// This PASSES (record still in DB!)
$this->assertDatabaseHas('posts', ['id' => $post->id]);
// This FAILS (record IS in DB)
$this->assertDatabaseMissing('posts', ['id' => $post->id]);
// Correct way to check soft delete:
$this->assertSoftDeleted($post);
assertModelExists и assertModelMissing
<?php
public function test_model_assertions(): void
{
$user = User::factory()->create();
// Assert model exists in database
$this->assertModelExists($user);
$user->delete();
// Assert model no longer exists (for hard deletes)
$this->assertModelMissing($user);
}
Model Factories в тестах
Создание тестовых данных
<?php
use App\Models\User;
use App\Models\Post;
use App\Models\Comment;
public function test_dashboard_shows_user_posts(): void
{
$user = User::factory()
->has(Post::factory()->count(5)->has(
Comment::factory()->count(3)
))
->create();
$response = $this->actingAs($user)
->getJson('/api/dashboard');
$response->assertOk()
->assertJsonCount(5, 'data.posts')
->assertJsonPath('data.total_comments', 15);
}
Использование States
<?php
public function test_admin_can_see_all_users(): void
{
$admin = User::factory()->admin()->create();
User::factory(10)->create();
$this->actingAs($admin, 'sanctum')
->getJson('/api/admin/users')
->assertOk()
->assertJsonCount(11, 'data'); // 10 + admin
}
public function test_suspended_user_cannot_login(): void
{
$user = User::factory()->suspended()->create([
'email' => '[email protected]',
]);
$this->postJson('/api/login', [
'email' => '[email protected]',
'password' => 'password',
])->assertForbidden();
}
Sequence в тестах
<?php
use Illuminate\Database\Eloquent\Factories\Sequence;
public function test_mixed_post_statuses(): void
{
$posts = Post::factory()
->count(6)
->sequence(
['status' => 'draft'],
['status' => 'published'],
['status' => 'archived'],
)
->create();
// 2 drafts, 2 published, 2 archived
$this->assertDatabaseCount('posts', 6);
$this->getJson('/api/posts?status=published')
->assertOk()
->assertJsonCount(2, 'data');
}
Seeders в тестах
<?php
use Database\Seeders\RoleSeeder;
use Database\Seeders\PermissionSeeder;
class RoleTest extends TestCase
{
use RefreshDatabase;
// Run specific seeder before this test class
protected $seed = true; // Run DatabaseSeeder
// OR specify seeders
protected string $seeder = RoleSeeder::class;
public function test_roles_exist(): void
{
$this->assertDatabaseHas('roles', ['name' => 'admin']);
$this->assertDatabaseHas('roles', ['name' => 'user']);
}
}
// Or seed in individual tests
class PermissionTest extends TestCase
{
use RefreshDatabase;
public function test_permissions_seeded(): void
{
$this->seed(PermissionSeeder::class);
$this->assertDatabaseCount('permissions', 20);
}
public function test_multiple_seeders(): void
{
$this->seed([
RoleSeeder::class,
PermissionSeeder::class,
]);
$this->assertDatabaseHas('roles', ['name' => 'admin']);
$this->assertDatabaseCount('permissions', 20);
}
}
Транзакции в тестах
<?php
use Illuminate\Support\Facades\DB;
public function test_transfer_is_atomic(): void
{
$sender = User::factory()->create(['balance' => 1000]);
$receiver = User::factory()->create(['balance' => 0]);
$this->postJson('/api/transfer', [
'from' => $sender->id,
'to' => $receiver->id,
'amount' => 500,
])->assertOk();
// Both balances should be updated atomically
$this->assertDatabaseHas('users', [
'id' => $sender->id,
'balance' => 500,
]);
$this->assertDatabaseHas('users', [
'id' => $receiver->id,
'balance' => 500,
]);
}
public function test_failed_transfer_rolls_back(): void
{
$sender = User::factory()->create(['balance' => 100]);
$receiver = User::factory()->create(['balance' => 0]);
$this->postJson('/api/transfer', [
'from' => $sender->id,
'to' => $receiver->id,
'amount' => 500, // More than balance!
])->assertUnprocessable();
// Both balances should remain unchanged
$this->assertDatabaseHas('users', [
'id' => $sender->id,
'balance' => 100,
]);
$this->assertDatabaseHas('users', [
'id' => $receiver->id,
'balance' => 0,
]);
}
Pest-стиль
<?php
use App\Models\User;
use App\Models\Post;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
test('user can be created', function () {
$user = User::factory()->create([
'name' => 'Test User',
]);
$this->assertModelExists($user);
$this->assertDatabaseHas('users', ['name' => 'Test User']);
});
test('soft deleted post can be restored', function () {
$post = Post::factory()->create();
$post->delete();
$this->assertSoftDeleted($post);
$post->restore();
$this->assertNotSoftDeleted($post);
expect($post->fresh()->trashed())->toBeFalse();
});
test('bulk delete removes all records', function () {
Post::factory(10)->create();
$this->assertDatabaseCount('posts', 10);
Post::query()->forceDelete();
$this->assertDatabaseEmpty('posts');
});
test('factory creates related models', function () {
$user = User::factory()
->has(Post::factory()->count(3))
->create();
expect($user->posts)->toHaveCount(3);
$this->assertDatabaseCount('posts', 3);
});
test('database transaction rollback on failure', function () {
$initialCount = User::count();
try {
\Illuminate\Support\Facades\DB::transaction(function () {
User::factory()->create();
throw new \Exception('Simulated failure');
});
} catch (\Exception) {
// Expected
}
expect(User::count())->toBe($initialCount);
});
Тестирование с несколькими подключениями
<?php
public function test_data_synced_between_databases(): void
{
// Create on primary
$user = User::on('mysql_primary')->create([
'name' => 'John',
'email' => '[email protected]',
'password' => bcrypt('password'),
]);
// Check on replica
$this->assertDatabaseHas('users', [
'email' => '[email protected]',
], 'mysql_replica');
}
Стратегии тестирования БД
In-memory SQLite
<?php
// phpunit.xml
// <env name="DB_CONNECTION" value="sqlite"/>
// <env name="DB_DATABASE" value=":memory:"/>
// Pros: Very fast, no cleanup needed
// Cons: SQLite doesn't support all PostgreSQL/MySQL features
// (JSON operations, full-text search, specific types)
Parallel Testing
<?php
// Laravel 11+: parallel testing with RefreshDatabase
// php artisan test --parallel
// Each process gets its own database
// test_1 -> testing_test_1
// test_2 -> testing_test_2
// test_3 -> testing_test_3
// In TestCase:
class TestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
// This runs in each parallel process
}
}