Why Your Test Suite Takes Eleven Minutes
A slow suite is a suite people skip. The time is almost never in the assertions - it is in rebuilding the database, hashing passwords, and reaching the network without meaning to.
A suite that takes eleven minutes is not run before pushing. It is run by CI, after the context has been lost, which turns a five-second correction into a twenty-minute round trip. Eventually somebody pushes past a red build because they are fairly sure it is unrelated.
The speed of the suite is therefore not a developer comfort. It is what decides whether the tests do their job.
Measure before guessing
php artisan test --profileThis prints the slowest tests. Almost always a handful of cases account for most of the wall clock, and the fix is specific rather than architectural.
The database, which is usually most of it
Migrating for every test. RefreshDatabase wraps each test in a
transaction and rolls it back, which is fast. DatabaseMigrations runs every
migration for every test, which is not. On a project with two hundred
migrations, that difference is the whole problem.
Migrations rather than a schema dump. Even once per run, replaying two hundred migrations takes longer than loading their result:
php artisan schema:dump --pruneThe suite then loads one SQL file. This is the single biggest win on a mature codebase and it takes one command.
Factories creating more than the test needs. A factory with a has()
chain creating twenty related records for a test that reads one field. Create
the minimum, and use make() rather than create() where nothing touches the
database.
Password hashing, which nobody expects
Hashing is deliberately slow - that is its purpose. A suite creating hundreds of users through a factory pays that cost hundreds of times.
// tests/TestCase.php or Pest.php
Hash::driver('bcrypt')->setRounds(4);On suites with a lot of user fixtures this alone has cut runtimes in half.
Tests that reach the network
The most damaging category, because they are slow and unreliable at once. A test that calls a real API waits on somebody else's server and fails when that server has a bad day.
Http::preventStrayRequests();Put that in the base test case. Any test making an unfaked HTTP call now fails immediately and tells you which one - and you will find some you did not know about.
The same applies to queues, mail and storage. Faked, they are instant; real, they are doing work the test is not asserting on.
Sleeps and polling
sleep(2) in a test waiting for something asynchronous is two seconds every
run, forever, and it is either too long or - on a slower machine - not long
enough.
Laravel's time helpers make waiting unnecessary: freeze time, travel forward, assert. For genuinely asynchronous work, run the queue synchronously in tests rather than waiting for a worker.
Browser tests, which belong in a different bucket
They are slow because they drive a real browser, and there is no trick that makes that fast. The answer is quantity rather than speed: a small number covering the paths that matter, run separately from the fast suite, so they do not sit between a developer and their feedback.
Parallelism, once the tests deserve it
php artisan test --parallelNear-linear improvement on a multi-core machine - and it will expose every test that was quietly depending on another. Shared fixture files, hardcoded ids, a database record assumed to exist because an earlier test created it.
Those failures are worth having. A test that only passes when run after another is not a test, it is a sequence, and it was going to fail at random in CI anyway.
The target
Under a minute for the suite people run before pushing, with anything slower
- browser tests, integration against real services - in a separate job that runs after.
The measure is not coverage or elegance. It is whether running the tests is something a developer does without deciding to.
A suite nobody runs is also a suite nobody can upgrade behind. That is why building one is the first phase of an upgrade and not the last. And if the fast suite runs on SQLite while production runs MySQL, that trade has a cost, and it is not always a small one.
