DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Setting an env var above your imports does not run before them, and our integration test works anyway

The largest test file in PubTrivia is 543 lines, and it has no mocks in it. It calls the actual server actions a player's phone calls, and the actual engine the host's buttons and our WebSocket server both call, against the actual Postgres database the app uses.

That is a trade with real costs, which I will get to. First, the two lines at the top, because they taught me something about module evaluation that I had believed wrongly for years.

The lines above the imports

// Disable WS notify BEFORE importing modules that read it at call-time.
process.env.WS_INTERNAL_URL = ''
process.env.WS_INTERNAL_SECRET = ''

import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db } from '@/utils/db/db'
import { launchQuestion, closeQuestion } from '@/utils/game/engine'
Enter fullscreen mode Exit fullscreen mode

Assignments, then imports. The intent is obvious: blank the variables before the code that reads them loads.

Except that is not what happens. In an ES module, import declarations are hoisted and every imported module is fully evaluated before the first statement of the importing module runs. Source order does not matter. Those two assignments execute after @/utils/game/engine and everything it pulls in have already been evaluated, top to bottom.

So the trick as written is a lie about ordering, and the test is correct for a different reason. Here is the code being disabled:

export async function notifyWsServer(sessionId, event, payload) {
    const wsUrl = process.env.WS_INTERNAL_URL
    const secret = process.env.WS_INTERNAL_SECRET

    if (!wsUrl || !secret) {
        console.warn('[notifyWsServer] WS_INTERNAL_URL or WS_INTERNAL_SECRET not set, skipping notify')
        return
    }
Enter fullscreen mode Exit fullscreen mode

The read is inside the function body. It happens when the function is called, which is long after the test file has finished executing its own statements. That is the whole reason this works, and the comment says so if you read it precisely: modules that read it at call-time.

The distinction is worth internalising, because the failure is silent and depends on code you did not write:

  • A module that reads process.env.FOO inside a function: assigning the variable anywhere before the first call works, including after the imports.
  • A module that reads process.env.FOO at module scope, into a const, has already captured the old value before your assignment runs. Nothing you write in the test body can change it. You need a setup file, an inline config, or vi.stubEnv with the module imported dynamically afterwards.

Our setup file does the ordinary half of this, and it does work by position because Vitest runs it before the test module exists:

import { config } from 'dotenv'
config({ path: '.env.local' })
Enter fullscreen mode Exit fullscreen mode

What blanking those two actually buys

Determinism, by removing an actor rather than by freezing a clock.

In production, launching a question tells the WebSocket server about it, and the WebSocket server owns the countdown: when the time runs out, it calls back into the app to close the question. That is the right design and it is poison for a test. The test wants to launch question one, assert, close it deliberately, and move on. With notify enabled, a second process is also deciding when that question closes, on a timer the test cannot see, and roughly half the assertions become coin flips.

Blanking the two variables makes notifyWsServer return early with a warning, so nothing leaves the process, and the test is the only thing in the world that can close a question.

There is a related wrinkle in the same area. The hot paths notify after the response instead of blocking on it:

export function notifyWsServerAfterResponse(sessionId, event, payload) {
    try {
        after(() => notifyWsServer(sessionId, event, payload))
    } catch {
        void notifyWsServer(sessionId, event, payload)
    }
}
Enter fullscreen mode Exit fullscreen mode

after() is a request-scope API. In a test there is no request, so it throws, and the catch calls the notify directly. Which means in tests the "later" path becomes the "now" path, and if the environment were not blanked, a test that joins a player would fire a real HTTP request at whatever WS_INTERNAL_URL happens to point at on the machine running the tests. Two lines, two problems.

Fixtures that are rows somebody else created

The test does not seed. It looks up what is already there:

const VENUE_ID = '94857bdc-3c87-46f9-bc11-e102e352b062'
const PACK_ID  = 'b2740061-2bdf-409c-8f0a-447f30a6957f'

beforeAll(async () => {
    await loadFixtures()
    expect(questions.length).toBeGreaterThanOrEqual(3)
    expect(tableIds.length).toBeGreaterThanOrEqual(3)
})
Enter fullscreen mode Exit fullscreen mode

Two hard-coded uuids and two assertions in beforeAll. The assertions are the interesting half. Without them, a database missing its fixtures produces failures deep inside the first test, about an undefined property on questions[0], which is a wrong answer to the question "what is broken". With them, the first thing you see is that the precondition is not met.

A beforeAll that asserts its own preconditions is cheap and I now write it every time. The error message you get at 1 a.m. is a feature.

The array that is the actual contract

Every row the test creates gets remembered:

const createdSessionIds: string[] = []
Enter fullscreen mode Exit fullscreen mode

and afterAll deletes them in an order that is not arbitrary:

afterAll(async () => {
    for (const sid of createdSessionIds) {
        await db.delete(answersTable).where(eq(answersTable.session_id, sid))
        await db.delete(leaderboardEntriesTable).where(eq(leaderboardEntriesTable.session_id, sid))
        await db.delete(tableParticipantsTable).where(eq(tableParticipantsTable.session_id, sid))
        await db.delete(gameSessionsTable).where(eq(gameSessionsTable.id, sid))
    }
})
Enter fullscreen mode Exit fullscreen mode

Children before parents, because the schema has 21 foreign keys and not one ON DELETE CASCADE. That absence is deliberate elsewhere in the product, where "delete my history" has to be an explicit ordered set of statements rather than something a constraint does quietly. The test pays the same tax, in the same order.

Notice also what the test asserts against. Some of what it checks is not in the return value of the action it called, because those actions deliberately do not report it, so it reads the row back out of the table instead. A test that reads the database to find out what happened is more faithful than one that trusts a function's own account of itself, and in this case it is also the only option.

What this costs

I would rather state the bill than pretend there is not one.

It needs credentials. vitest run without a populated .env.local fails immediately, which rules out running this file on a fresh clone or in CI without secrets.

It is not hermetic. The fixture ids are specific rows in a shared database. Someone deleting that venue breaks the suite in a way that has nothing to do with the code.

It does not parallelise safely. Two runs at once would be two sets of sessions against the same venue and the same tables, and some of the assertions count participants.

And a killed process leaves rows behind. afterAll does not run if you interrupt the run, so an abandoned test session can sit in the database until someone notices.

What makes it worth it anyway: every bug this file has caught lived in the database layer. A transaction that was not a transaction. A unique index that treated two nulls as different values. Two phones joining in the same moment and one of them being told the quiz did not exist. Not one of those is reachable with a mocked db, because in every case the mock would have been written to behave the way I wrongly believed Postgres behaves. The test is slow and awkward and it is testing the thing I am actually unsure about.

The split we ended up with: 12 other test files are pure and fast and run anywhere, covering pricing arithmetic, the content registry, route coherence and the clock. One file is slow and dirty and talks to Postgres. The fast ones tell me my logic is right. The slow one tells me my assumptions about the database are.

Why it earns its keep on a quiz night

Because the two things this file exercises are the two things a room notices. The live leaderboard has to move the moment a question closes, which means close has to be a transaction that happens exactly once no matter how many things ask for it. And session history has to still have last week's quiz in it, which means the rows this test creates and deletes are the same rows a landlord looks at on Thursday to see whether the Tuesday quiz was busier than the one before.

Mock a quiz engine and you test your own idea of a database. Two blanked variables, one array of ids and one slow test file later, you test the database.

Top comments (0)