DEV Community

Daniel Pertu
Daniel Pertu

Posted on

3,281 unit tests in 4.4 seconds, and the DATABASE_URL points at port 1

The whole test suite for CogniPrep, a Next.js app with 24 assessment providers, four purchasable products, four discount programmes, lifecycle email and a PDF report generator:

 Test Files  138 passed (138)
      Tests  3281 passed (3281)
   Duration  4.40s (transform 6.62s, setup 764ms, import 18.80s, tests 3.91s, environment 7ms)
Enter fullscreen mode Exit fullscreen mode

No database. No jsdom. No mock server. No fixtures directory. environment: 'node' and nothing else.

That is not a claim that integration tests are worthless. It is the result of a deliberate split: the logic worth testing thousands of times a day is pure, and the logic that touches Postgres and Stripe is thin enough to read. This post is about the harness that makes the pure half run in four seconds, because it took more setup than "install vitest" and none of it is obvious.

Importing your own app is the expensive part

Look at that breakdown again. Assertions took 3.91s. Importing modules took 18.80s across workers.

Module import is the dominant cost in any test suite over a real application, and it is a cost you control with your import graph rather than your assertions. Every pure module that reaches for a database client, a Stripe client or a server-only helper drags the whole SDK into every test file that touches it, transitively, forever.

So the modules under test are written to be importable without a runtime. The pricing maths, the code format helpers, the plan tier guard, the free score rule: all pure functions in files with no server imports, deliberately, with the reason written at the top of the file. That is what makes a 3,000 test suite cheap, and it is a design constraint, not a testing trick.

Priming the environment, because SDKs are built at import time

Plenty of modules instantiate clients at module scope. new Stripe(process.env.STRIPE_SECRET_KEY!) is the canonical example, and it throws on undefined. A test that only exercises a pure function will still pull that module in through the graph and die before the first assertion.

The global setup file therefore hands out harmless dummies:

process.env.STRIPE_SECRET_KEY ??= 'sk_test_dummy_key_for_unit_tests';
process.env.NEXT_PUBLIC_WEBSITE_URL ??= 'https://cogniprep.test';
process.env.UNSUBSCRIBE_SECRET ??= 'test-unsubscribe-secret';
Enter fullscreen mode Exit fullscreen mode

Two small decisions in there matter more than they look.

??= rather than =. A developer who has a real value exported in their shell keeps it, which means the same test file can be run against a real service on purpose without editing the harness.

And the database URL:

// lib/db/db.ts builds its postgres.js client at import time. postgres.js is lazy,
// so no connection is opened, but the URL must parse. The port is unroutable on
// purpose: anything that actually tries to query fails loudly instead of reaching
// a real database.
process.env.DATABASE_URL ??= 'postgres://u:p@127.0.0.1:1/none';
Enter fullscreen mode Exit fullscreen mode

Port 1 is the interesting part. The URL has to parse, because the client is constructed eagerly. It must never connect, because a unit test that silently reaches a real database is the worst outcome available: it passes locally, mutates something, and fails in CI for reasons nobody can reproduce.

Pointing it at a port nothing can listen on turns "accidentally hit the database" from a subtle bug into an immediate, loud connection failure with a stack trace pointing at the test that did it. The failure mode is chosen rather than hoped for.

Stubbing server-only, which is not a package you can install

Next.js modules guard themselves with import 'server-only' so the build fails if a client component ever imports them. Under Vitest, that import fails for a boring reason: Next ships server-only inside its own bundle rather than as a top level dependency, so resolving the bare specifier from node_modules finds nothing, and every test touching the database or Supabase server helpers fails at import.

resolve: {
  alias: {
    '@': path.resolve(__dirname, '.'),
    'server-only': path.resolve(__dirname, '__tests__/stubs/server-only.ts'),
  },
},
Enter fullscreen mode Exit fullscreen mode

The stub is one line: export {};

Aliasing it is correct rather than a cheat, and it is worth being clear why. The real package protects a boundary that exists only in a bundler: it stops server code being shipped to a browser. Vitest runs in node, so there is no browser to protect. The guard is not being disabled, it is being asked to defend a boundary that is not present.

The exclude that only exists because of coding agents

This one is new, and I suspect more repos are about to need it:

include: ['**/__tests__/**/*.test.ts'],
// Agent worktrees live at .claude/worktrees/<name> and are full checkouts of
// this repo, so the include glob above matches their __tests__ directories
// too. Without this, a single active worktree doubles the suite and a stale
// one fails it against code that was never merged.
exclude: [...configDefaults.exclude, '**/.claude/worktrees/**'],
Enter fullscreen mode Exit fullscreen mode

Running coding agents in git worktrees is a good pattern: each agent gets an isolated checkout and cannot trip over another's uncommitted work. The side effect is that your repository now contains complete copies of itself, inside itself, at a path your test glob happily matches.

The symptoms are memorable. The test count doubles, which looks like nothing is wrong. Then a worktree from last week starts failing assertions about behaviour that was rewritten before it merged, and you spend twenty minutes reading a failure in a file whose path you do not recognise.

If you use worktrees, agent driven or not, check what your include globs actually match. The same applies to lint, typecheck, and any script that walks the tree.

Coverage as a spotlight, not a score

The coverage config names six files:

coverage: {
  provider: 'v8',
  // Focus coverage on the architectural / high-risk modules the tests target:
  // access control, API validation, billing tier mapping, email signing, and
  // the two fail-closed / anti-spoofing helpers guarding the cron and rate
  // limit boundaries.
  include: [
    'lib/games/constants.ts',
    'lib/api/validation-schemas.ts',
    'lib/api/cron-helpers.ts',
    'lib/auth/client-identifier.ts',
    'lib/plan/plan.ts',
    'lib/email/unsubscribe-url.ts',
  ],
},
Enter fullscreen mode Exit fullscreen mode

Whole repository coverage produces one number that is mostly a measure of how much presentational code you have written. These six modules are the ones where an uncovered branch is a security or billing incident: who may play what, what a request body is allowed to contain, whether a cron endpoint can be called by a stranger, how a client is identified for rate limiting, which tier a payment maps to, and whether an unsubscribe link can be forged.

On those, "which line is not covered" is a question worth answering precisely. Averaging them with 200 React components makes the answer unreadable.

The layout is the domain

__tests__ has 36 directories. One per assessment provider, plus cross cutting ones: access, api, auth, billing, discounts, email, exercises, interview, jobs, middleware, onboarding, pricing, referrals, support, theme, validation.

The provider directories exist because provider behaviour is where the divergence lives. When a provider's suite needs a rule the others do not, the test for it belongs somewhere obvious, and "somewhere obvious" is a directory named after the provider rather than a 4,000 line games.test.ts that everyone is afraid of.

See it

  • cogniprep.app/games lists all 24 providers. That count is the same one the per provider test directories mirror, and the page derives it rather than stating it, so the two cannot drift.
  • cogniprep.app/pricing, FAQ item Can I try before I buy?, quotes the free game count and the per provider play budget. Those numbers come out of the same pure modules the unit tests pin, which is why a page can quote a limit and be trusted.
  • The transferable part needs no account: open your own suite's output and compare the tests figure with the import figure. If import dwarfs assertions, your slow suite is an import graph problem, and no amount of parallelism fixes it.

Top comments (0)