RefreshDatabase: what "each test rolls back" actually doesn't cover
RefreshDatabase is the default for a reason — it's fast, because it doesn't re-run every migration for every test. What it actually does is migrate once, then wrap each individual test in a database transaction and roll it back at the end, so nothing persists between tests. That's a transaction, not a fresh database, and a few real behaviors follow from that distinction that a truly fresh database wouldn't have.
ON THIS PAGE
What RefreshDatabase actually does
The trait runs your migrations once at the start of the test class (or reuses an already-migrated schema if nothing's changed), then begins a database transaction before each individual test and rolls it back immediately after:
use Illuminate\Foundation\Testing\RefreshDatabase;
class OrderTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_an_order(): void
{
$order = Order::factory()->create();
// ...
} // transaction rolls back here — nothing persists
}
This is fast precisely because rolling back a transaction is cheap compared to re-running every migration. The trade-off is that a rolled-back transaction isn't identical to a genuinely fresh database in every respect — two specific gaps are worth knowing about.
Auto-increment counters don't roll back with the transaction
In MySQL, AUTO_INCREMENT is tracked independently of transactions. Rolling back a transaction undoes the inserted rows, but doesn't reset the counter that was incremented while creating them. Run enough tests in a class, and IDs climb steadily upward across tests, even though each test starts with an empty table.
This is invisible until a test hardcodes an assumption about what an ID will be:
public function test_first_order_has_id_one(): void
{
$order = Order::factory()->create();
$this->assertEquals(1, $order->id); // passes in isolation, not always in the suite
}
Run this test alone and it passes — the table is genuinely empty and the first insert gets ID 1. Run the full suite, and depending on test order, dozens of orders may have already been created and rolled back in earlier tests, pushing the counter well past 1. The test becomes order-dependent without anything in it looking order-dependent.
The fix is straightforward: never assert on a specific numeric ID. Assert on the model instance, a relationship, or a count instead:
$this->assertDatabaseHas('orders', ['id' => $order->id, 'status' => 'pending']);
$this->assertCount(1, Order::all()); // table is empty at test start, this is reliable
after_commit jobs and events never actually fire
This is the sharper gotcha, and it connects directly to how ShouldQueueAfterCommit works. That interface holds a job's dispatch until the wrapping transaction commits — and under RefreshDatabase, the entire test runs inside a transaction that never commits. It only ever rolls back. Which means a job or event waiting for "after commit" is waiting for something that, from the transaction's perspective, never happens during the test at all:
public function test_confirmation_job_is_dispatched(): void
{
Queue::fake();
DB::transaction(function () {
$order = Order::create([...]);
SendOrderConfirmation::dispatch($order); // implements ShouldQueueAfterCommit
});
Queue::assertPushed(SendOrderConfirmation::class); // this actually fails
}
Queue::fake() intercepts the dispatch call itself, so it does record that dispatch() was called — but the after_commit mechanism defers when the job is actually pushed onto the fake queue's recorded list until commit, and inside RefreshDatabase's test transaction, that commit signal never fires. The assertion fails, and it's easy to misread that failure as "the job isn't being dispatched" when the real cause is "this queue behavior fundamentally depends on a commit that this test setup structurally prevents."
A few ways to work around it, depending on what you actually want to verify:
Assert dispatch happened without relying on the commit hook, if you're testing that the code path calls dispatch() at all:
Bus::fake();
// ...
Bus::assertDispatched(SendOrderConfirmation::class);
Bus::fake() records the dispatch call directly rather than going through the after-commit queueing mechanism, so it isn't blocked by the same issue.
Use DatabaseTransactions instead of RefreshDatabase for the specific test class where after-commit behavior is what you're actually testing — it wraps each test in a real transaction against a persistent test database and can be configured to actually commit, at the cost of losing the speed and isolation RefreshDatabase gives you elsewhere.
Or accept that after-commit timing genuinely isn't something a rolled-back-transaction test can verify, and test the job's own logic in isolation instead, trusting the interface implementation separately.
Parallel testing needs its own database per process
If you're using --parallel, each process needs a genuinely separate database — Laravel handles this by suffixing a token onto the database name per process (your_db_test_1, your_db_test_2, etc.) via the ParallelTesting facade. If your test database configuration is hardcoded to a single name rather than reading the token Laravel provides, parallel processes collide on the same tables, and you'll see intermittent, hard-to-reproduce failures that look like RefreshDatabase isn't isolating tests at all — when the real issue is that isolation was never set up correctly for multiple processes to begin with.
Checklist before trusting a RefreshDatabase-backed test
- Does any assertion hardcode a specific numeric ID, rather than asserting on the returned model or a count?
- Does this test rely on
ShouldQueueAfterCommitor anafter_commit-dispatched event actually firing? If so, is it usingBus::fake()/Queue::fake()correctly, or does it needDatabaseTransactionsinstead? - If running with
--parallel, does the test database configuration actually use Laravel's per-process token, rather than a single hardcoded database name?