Speeding up Elixir test suites
Last week I took our Elixir/Phoenix suite (13,000 tests, 940 files) from 313 seconds to 64 seconds. Below is what was done with most impactful listed first. These are Elixir specific but I think the learnings apply to non-Elixir stacks. Your mileage will vary.
Make your tests async. This sounds obvious but when you encounter flaky tests the first thing you do is make them synchronous which usually "fixes" things, and over time these pile up. Every other point here is essentially in service of this point. We had 134 of 940 files running
async: true, which means an 18-core machine was running one test module at a time. ExUnit runs async modules concurrently up tomax_cases(default: 2x your cores) and the Ecto sandbox gives each test its own transaction, so DB tests parallelize fine. After the flip we had ~830 async files and the wall time floor became "the slowest single module" instead of "the sum of everything".The catch is that async doesn't fail loudly when it's unsafe, it fails as flakes. Every file we couldn't flip had a reason, and finding those reasons is items 3 and 4.
Stop doing per-test setup work that's the same for every test. Our
db_testhelper seeded the entire demo storefront inside every test's sandbox transaction. That's 6.3ms times 10,000 tests, about 64 seconds of every run, recreating identical rows. We moved the seed into the committed test database (run once at build time), and since committed rows predate every sandbox transaction, all tests see them for free.The objection to committed fixtures is that a test can mutate them and poison every test after it, since those writes don't roll back. We handle that with a checksum guard: hash the seeded tables after the build, re-check after the suite, fail CI on drift. The nastier problem with per-test seeding is that it blocks async entirely: two concurrent sandboxes inserting the same unique rows (e.g., a unique product slug) block on each other's uncommitted unique-index entries for the whole duration of a test. We had to bake fixtures before we could flip DB tests.
Find the tests calling real external services. Our slowest module took 170 seconds and I assumed it was doing heavy DB work. It was making live HTTPS calls to PostHog because four describe blocks stubbed one collaborator but not the other. Stubbed, the module runs in 1.3 seconds. The suite dropped from 120s to 64s on this fix alone, and it also stops flaking when the network is slow.
This was made a structural guarantee instead of a one-time cleanup by making the client's base URL config-driven and pointed the test env at
http://127.0.0.1:9. Any future unstubbed call dies instantly with connection refused instead of hanging for 60 seconds and reading like a timeout.Hunt down process-global state as it makes async unsafe. Our list, roughly in order of pain:
ExMachina.Sequence.reset()in setup (one global Agent, a reset corrupts every concurrent test), aCachex.clearof a shared cache in a case template (wipes entries concurrent tests assert on),Application.put_env/System.put_env(feature flags, API keys),Logger.configure(level:)(breaks concurrentcapture_logassertions), hardcoded fixture identities likeid: "test_user_id"andemail: "test@test.com"in session helpers (concurrent inserts deadlock on the unique index), andSandbox.start_owner!(shared: true)(puts the whole pool in shared mode).One subtle one that took a while to hunt was the sandbox pool booting in
:automode which makes DBConnection applies a mode change by force-checking-in every connection currently held. If yourtest_helper.exsdoesn't pinSandbox.mode(Repo, :manual)before tests start, the first ConnCase setup does the auto-to-manual transition and kills the connections of every async test already running. This results in a whole wave of unexplainable OwnershipErrors per run.Check your actual pool size. Ours was 8. The config said
System.schedulers_online() * 2but config files evaluate at compile time, and the cached_buildhad been produced in a constrained environment, so the small number was essentially hardcoded. Withmax_casesat 36 and a pool of 8, concurrent tests starve waiting for connections and die with ownership timeouts. We moved the pool size toruntime.exsand keep the invariantpool_size >= max_cases. Don't bother raisingmax_casespast the default until your slowest modules stop being the bottleneck. We tried 54 and it bought nothing, because tests within a module still run serially.Measure before and after, and know that
--slowestcan be misleading.mix test --slowest Nsilently enables--trace, which forcesmax_cases: 1and runs your entire suite serially. So the run that tells you which tests are slow is useless for measuring wall time. We keep a script that does two passes, a plain run for the real number and a--slowestrun for the rankings, and appends both to a measurements file per change.Treat every flake as an order-dependence bug and reproduce it by seed. A failure that passes under
mix test --failedshouldn't be ignored as it's likely a test reading state some other test wrote. The run header prints the seed;mix test --seed Non the involved files reproduces the interleaving deterministically, and--repeat-until-failureunder that seed proves your fix. Rerunning until green just means that it'll flake later again.Also, any file that stays
async: falsegets a comment at theuseline naming the reason. Without that, nobody knows whether a file is sync because it must be for a valid reason or a mistake.Watch for tests that finish before their async work does. LiveView's
assign_asyncand components thatsend(self(), ...)to their parent create a window whererender_submitreturns but the actual save hasn't run. Serially you never notice since under load the test process exits first and Mimic'sverify_on_exit!reports an expectation invoked zero times, or a DB assertion reads stale data. The fix is to userender(view)is a GenServer call, so it queues behind whatever messages are already in the LiveView's mailbox and acts as a barrier.render_async(view)settlesassign_asynctasks. Also, bumpassert_receive_timeoutinExUnit.start(the 100ms default also feedsrender_async), since only failing waits pay the longer timeout.Make factory values unique, not random. We had
seat_number: "A#{Enum.random(1..50)}"under a unique constraint, which collides as soon as one test seats a handful of tickets on the same variant.System.unique_integer([:positive])costs the same and never collides. Same idea for anything with a unique index: emails, slugs, tokens. And don't assert on sequence-derived literals likedisplay_id == 1001, assert on the record the test created, so no test cares how many factory calls ran before it.Partition CI once the local suite is fast.
mix test --partitions NwithMIX_TEST_PARTITIONin the database name is standard Ecto stuff, but getting the tests to use their own isolated databases is what made async tests possible. We provisioned partition databases by copying a fully built template withCREATE DATABASE x TEMPLATE y(about 0.2s) instead of re-running migrations per partition. If you're on TimescaleDB, the copy fails with "database is being accessed by other users" because its background worker scheduler holds a permanent session on every database. Mark the templatedatistemplate = truewithallow_connections = falseand the scheduler can't attach, so copies need no preparation at all.
I'd submit that parallel tests are a mild form of chaos testing. The flakes were annoying but almost every one pointed at real shared state that would eventually have mattered in production too.