Testing with a Database
RefreshDatabase migrates once per run and wraps each test in a transaction, so every test starts from the same empty schema.
use Illuminate\Foundation\Testing\RefreshDatabase;A test that writes rows has to leave the database as it found it, or the next test inherits the mess and the suite starts depending on test order. The Illuminate\Foundation\Testing\RefreshDatabase trait does that for you.
What the trait actually does
On the first test of a run it executes migrate:fresh and records that in RefreshDatabaseState::$migrated; every later test skips migrating. Each test then opens a transaction before your code runs and rolls it back afterwards, which is why it is fast. With SQLite :memory: it also keeps the PDO handle alive between tests, because the application is rebooted for each test and a fresh in-memory database would be empty. Two slower traits exist when you need a true reset: DatabaseMigrations migrates fresh for every test, and DatabaseTruncation truncates the tables.
Data and assertions
Factories create the rows: Post::factory()->count(3)->for($user)->create(). Then assert against the tables with assertDatabaseHas('posts', ['title' => 'Hello']), assertDatabaseMissing, assertDatabaseCount, assertDatabaseEmpty, assertModelExists($post), assertModelMissing($post) and assertSoftDeleted($post). $this->seed() runs DatabaseSeeder, and $this->seed(OrderStatusSeeder::class) runs one seeder. Laravel 13 adds attributes for this: #[Seed] on your base test case seeds before every RefreshDatabase test, and #[Seeder(OrderStatusSeeder::class)] on a class picks the seeder.
Independence has limits
The rollback only undoes work on the connections the trait transacts. Rows written by a test that does not use the trait survive. Code that commits deliberately, or truncates a table, escapes the rollback too.
Example
<?php
// tests/Feature/SubscriberTest.php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Subscriber;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class SubscriberTest extends TestCase
{
use RefreshDatabase;
public function test_a_visitor_can_subscribe(): void
{
$response = $this->post('/subscribers', [
'email' => '[email protected]',
]);
$response->assertRedirect('/thanks');
$this->assertDatabaseCount('subscribers', 1);
$this->assertDatabaseHas('subscribers', [
'email' => '[email protected]',
]);
}
public function test_unsubscribing_removes_the_row(): void
{
$subscriber = Subscriber::factory()->create([
'email' => '[email protected]',
]);
$subscriber->delete();
// The same check, written two ways.
$this->assertModelMissing($subscriber);
$this->assertDatabaseMissing('subscribers', ['id' => $subscriber->id]);
}
}
When to use it
- Prove a destroy route actually deletes the row rather than only redirecting.
- Create exactly the rows a report needs and assert its totals.
- Keep a suite green when tests run in a different order or in parallel.
- Catch an N+1 query on a list page before it reaches production.
More examples
The trait applied to a whole Pest file
pest()->use(RefreshDatabase::class) applies the trait to every test in the file, which is what `use RefreshDatabase;` inside a PHPUnit class does. Put it in tests/Pest.php instead to apply it suite-wide.
<?php
// tests/Feature/SubscriberTest.php
use App\Models\Subscriber;
use Illuminate\Foundation\Testing\RefreshDatabase;
pest()->use(RefreshDatabase::class);
test('a visitor can subscribe', function () {
$this->post('/subscribers', ['email' => '[email protected]'])
->assertRedirect('/thanks');
$this->assertDatabaseHas('subscribers', ['email' => '[email protected]']);
});
test('subscribers are unique by email', function () {
Subscriber::factory()->create(['email' => '[email protected]']);
$this->post('/subscribers', ['email' => '[email protected]'])
->assertInvalid(['email']);
$this->assertDatabaseCount('subscribers', 1);
});
Seeding reference data before each test
Use this for lookup tables that every test needs and no test should create by hand: statuses, roles, currencies. The longhand is calling $this->seed(OrderStatusSeeder::class) at the top of each test.
<?php
// tests/Feature/OrderTest.php
declare(strict_types=1);
namespace Tests\Feature;
use Database\Seeders\OrderStatusSeeder;
use Illuminate\Foundation\Testing\Attributes\Seeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
#[Seeder(OrderStatusSeeder::class)]
class OrderTest extends TestCase
{
use RefreshDatabase;
public function test_a_new_order_starts_as_pending(): void
{
// The statuses table is already populated by the seeder.
$this->post('/orders', ['sku' => 'LARAVEL-13', 'quantity' => 1])
->assertRedirect('/orders');
$this->assertDatabaseHas('orders', ['status' => 'pending']);
}
}
Counting the queries a page runs
expectsDatabaseQueryCount() turns an N+1 problem into a failing test. It is strict: any change in query count fails, which is the point on a page you care about, and a nuisance anywhere else.
public function test_the_index_page_does_not_query_per_row(): void
{
Post::factory()->count(10)->create();
// Fails if the real number is not exactly 2.
$this->expectsDatabaseQueryCount(2);
$this->get('/posts')->assertOk();
}
Discussion