MidПрактика7 min

Тестирование базы данных

RefreshDatabase, DatabaseTransactions, model factories в тестах, assertDatabaseHas, assertDatabaseCount, assertSoftDeleted

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:

  1. При первом тесте в классе -- запускает php artisan migrate:fresh
  2. Каждый тест оборачивается в транзакцию
  3. После теста транзакция откатывается
  4. База данных остаётся чистой для следующего теста

Подвох на экзамене: 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
    }
}

Проверь себя

Что делает assertDatabaseEmpty()?

Что проверяет assertSoftDeleted()?

Что делает `LazilyRefreshDatabase` в отличие от `RefreshDatabase`?

Почему RefreshDatabase не подходит для Laravel Dusk?

Чем `RefreshDatabase` отличается от `DatabaseMigrations`?