How I unblocked tests stuck on SQLite :memory: with AdonisJS
The problem
I was implementing a new feature (Freshdesk Knowledge Base import) and the functional tests started failing during setup — before any test even ran. The seeder tried inserting records and got table tickets has no column named type. It looked like the migration that added the column wasn't executing. But all previous migrations worked fine. What changed?
The answer was hidden inside AdonisJS Lucid's MigrationRunner.
What I found in the MigrationRunner
Reading the source code of @adonisjs/lucid, I realized that MigrationRunner.run() silently swallows errors:
async run() {
try {
await this.runUp();
} catch (error) {
this.error = error; // logged but never re-thrown
}
await this.shutdown();
}
If any migration fails — due to a timeout, SQL error, or any other reason — run() finishes normally and the bootstrap moves on to the seed. The seed fails, the setup returns an error, and the actual cause never shows up in the logs. This means any migration that doesn't execute correctly is silently swallowed by the framework.
Why each migration was creating a transaction on SQLite :memory:
The MigrationRunner runs each migration inside a transaction by default:
const client = await this.client.transaction();
With pool: { min: 0, max: 1 } on SQLite :memory:, the pool has exactly one connection. Each transaction needs to acquire that connection. If the previous one hasn't been released in time — or if better-sqlite3's synchronous behavior conflicts with the async pool from tarn.js — the next transaction times out.
The result: all migrations that depended on transactions got stuck. And the seed — which uses the same connection from the pool — couldn't see the columns added by the blocked migrations.
The solution
Two changes fixed the problem.
1. Disable transactions in test migrations
In config/database.ts, the memory connection now includes disableTransactions: true:
memory: {
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
migrations: {
naturalSort: true,
paths: ['database/migrations'],
disableTransactions: true, // prevents pool timeout
},
},
Each migration now uses this.client directly instead of creating a separate transaction. This way, migrations complete without acquiring multiple pool connections.
2. Check the migrator error in the bootstrap
In tests/bootstrap.ts, I added a check after migrator.run():
await migrator.run();
if (migrator.error) {
throw migrator.error; // propagate instead of swallowing
}
If any migration fails now, the test fails with the actual error — not a column does not exist message that masks the root cause.
What I learned
-
Always verify the migrator status after
run(). Don't trust that the framework propagates errors silently — it may not. -
disableTransactionsexists for a reason. In in-memory test databases, transactions aren't just unnecessary — they can cause pool timeouts. For SQLite:memory:, disabling transactions eliminates the bottleneck. -
The wrong test leads you far from the real cause. The error
table has no column named typesuggested a missing@columnon the model. But the column already existed in the model. The problem was in the test infrastructure, not the code. When a test fails, ask yourself: "Am I testing the right code, or am I testing the environment?" -
Read the framework source when something seems impossible. The
MigrationRunner.run()is 50 lines. Understanding that it swallows errors turned 4 hours of frustration into 20 minutes of diagnosis.
This experience reinforces a truth every backend developer should internalize: integration tests are unreliable when the test infrastructure is opaque. When the framework hides failures, you need your own verification layer. It's not optional — it's infrastructure.
- Testing
- AdonisJS
- SQLite
- Backend
- NodeJS
Vinicios Clarindo
@vinicioslc
Comments