Repository navigation
DatabaseTruncation combined with a transactional base TestCase silently runs migrate:fresh on every test and never truncates #61619
Replies: 1 comment
|
I dug a little deeper into the interaction between
This means the queries triggered by So there seem to be two separate concerns:
I wonder if temporarily disabling the lazy-refresh callback during I haven't tested this approach yet, so I'd be interested to hear whether there are cases where the |
Uh oh!
There was an error while loading. Please reload this page.
Laravel Version: 13.32.0
PHP Version: 8.3.33
Database Driver & Version: MySQL 8.0.30
Description
When a base
TestCaseusesRefreshDatabase(orLazilyRefreshDatabase) and an individual test class usesDatabaseTruncation, both strategies are applied. Two things then go wrong:truncateTablesForAllConnections()is never reached; isolation comes incidentally from themigrate:freshthat replaces it.On a clean Laravel 13.32.0 app with 43 migrations, a 3-test class issued 294
CREATE TABLEand 0TRUNCATEstatements, against an expected 49 and 2.The tests still pass, so nothing signals that anything is wrong. The only visible symptom is the suite getting dramatically slower.
Why both traits get applied
setUpTraits()resolves strategies withclass_uses_recursive(static::class), which does not distinguish a trait used directly by the test class from one inherited via the base class. The four strategies are then dispatched as independentifblocks with no mutual exclusion:https://github.com/laravel/framework/blob/13.x/src/Illuminate/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycle.php
So a class that inherits
LazilyRefreshDatabaseand addsDatabaseTruncationgets both, with no error or warning.Why that produces a rebuild per test
RefreshDatabase::beginDatabaseTransaction()registers a teardown that invalidates the migrated state if the connection is no longer inside the transaction it opened:In isolation this is correct and deliberate — a test that committed has escaped the rollback guarantee, so rebuilding is the safe recovery. But
DatabaseTruncationcommits by design; that is its entire purpose. So the condition is true after every test, and$migratedis reset every time.DatabaseTruncation::truncateDatabaseTables()then re-enters its first-run branch on every subsequent test:Note the early
return. The tables are never truncated at all; isolation ends up being provided incidentally by themigrate:freshthat precedes it.The root cause is that three traits share one static flag,
RefreshDatabaseState::$migrated, while assigning it different meanings — "DB is migrated and safe for transactional use" inRefreshDatabase, versus "the one-timemigrate:freshhas happened" inDatabaseTruncation.DatabaseTruncationused on its own is unaffected, becauseRefreshDatabase.phpis the only place that resets the flag.Why this is easy to hit
RefreshDatabase/LazilyRefreshDatabaseon a baseTestCaseis a common and reasonable layout.DatabaseTruncationto a single class then looks like selecting a different strategy for that class. It isn't — traits compose rather than override.DatabaseMigrationsorDatabaseTruncation"instead" ofRefreshDatabase, but does not state that the traits are mutually exclusive, and all examples apply a trait to a test class without discussing a base class that already has one.Steps To Reproduce
Reproduced on a clean
laravel newapp (Laravel 13.32.0, PHP 8.3.33, MySQL 8.0.30) with 40 trivial padding migrations added so a rebuild is measurable (43 migrations, 49 tables total).LazilyRefreshDatabasenothing happens until a query runs:Expected: one
migrate:freshon the first test (49CREATE TABLE), then a truncation pass before each subsequent test (2TRUNCATEbatches).Actual: six full schema rebuilds and no truncation at all.
Measurement
Identical 3 tests, same app, same machine:
CREATE TABLETRUNCATELazilyRefreshDatabasebase +DatabaseTruncationrefreshDatabase()overridden to a no-opThe tests pass in both cases.
Note the count: 294 = 3 tests x 2 rebuilds x 49 tables. There appear to be two rebuilds per test, because
truncateDatabaseTables()invokesartisan('migrate:fresh'), whose query activity tripsLazilyRefreshDatabase'sbeforeExecutinghook mid-flight and nests a secondmigrateDatabases()inside the first.beginDatabaseTransaction()then registers its teardown, which finds the connection outside a transaction (the DDL having implicitly committed), resets$migrated, and the cycle repeats on the next test.Because
$migratedis false on entry to every test,truncateDatabaseTables()always takes the early-return branch, which is whytruncateTablesForAllConnections()is never reached andCom_truncatestays at zero.For scale, on a real application (112 migrations, 87 tests across 6 classes) the same collision cost roughly 9x: one 6-test class took 36.1s instead of 4.1s.
Possible directions
I'm happy to open a PR, but the right approach isn't obvious to me and I'd rather ask than guess:
Throw when more than one strategy resolves in
setUpTraits(). Clearest signal, and everything needed is already in scope at that point — but it would break any application currently combining traits, so presumably it could only be considered for a major release.Give one strategy precedence — e.g. skip
refreshDatabase()whenDatabaseTruncationis present. Non-erroring and strictly faster, but it does change behaviour for existing combined setups.Stop the teardown reset clobbering truncation's state. Closest to root cause.
RefreshDatabaseState::$migratedis documented as "indicates if the test database has been migrated", which is a legitimately shared concern; the issue is that aRefreshDatabase-specific recovery path writes to it. I'd want to understand the original motivation for that reset before narrowing it, so as not to reintroduce whatever it was guarding against.Happy to implement whichever of these you'd accept, or something else entirely.
Workaround
For anyone who lands here from a search: override
refreshDatabase()to a no-op in the truncating test class, so no transaction is ever opened. A trait used directly by the class takes precedence over the method inherited from the parent, so this suppresses the transactional strategy while leaving truncation intact.All reactions