PubTrivia is a Next.js app with 201 .tsx files, 52 of which are client components, and a test suite that cannot render a single one of them in a browser-like environment. There is no jsdom, no happy-dom, no Testing Library, and nothing in package.json that would provide one:
$ grep -c 'jsdom\|happy-dom\|testing-library' package.json
0
The config is eleven lines:
export default defineConfig({
test: {
environment: 'node',
include: ['**/__tests__/**/*.test.ts'],
setupFiles: ['./__tests__/setup.ts'],
},
resolve: { alias: { '@': path.resolve(__dirname, '.') } },
})
Two details in there decide the shape of everything else. environment: 'node' means there is no document. And the include pattern ends in .test.ts, so a file named Something.test.tsx would not be collected at all: it would sit in the repository looking like a test and never run.
Here is what the suite actually contains, straight from vitest list:
| File | Tests |
|---|---|
pricing/display-currency.test.ts |
43 |
pricing/price-copy.test.ts |
28 |
billing/managed-payments.test.ts |
27 |
billing/checkout-route.test.ts |
26 |
billing/checkout-session.test.ts |
22 |
content/registry.test.ts |
21 |
seed-data.test.ts |
20 |
scoring.test.ts |
16 |
gameplay.integration.test.ts |
13 |
billing/stripe-webhook.test.ts |
13 |
indexnow.test.ts |
10 |
maintenance.test.ts |
9 |
routes.test.ts |
8 |
server-clock.test.ts |
5 |
content/page-metadata.test.ts |
3 |
shared-constants.test.ts |
2 |
266 tests, 16 files, 3,201 lines. Thirteen of them need a database. The other 253 need nothing:
Test Files 15 passed (15)
Tests 253 passed (253)
Duration 603ms
Four of the sixteen files call vi.mock, and twelve mock nothing, because there is nothing to mock: they import a module and call it.
Technique one: render the component to a string anyway
The interesting file is pricing/price-copy.test.ts, which has an actual React component under test. With no DOM, it reaches for the server renderer:
import { createElement, type ComponentProps, type ComponentType } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import PricingCards from '@/components/PricingCards'
/** The visible text of a rendered tree, tags stripped. */
function renderedText(component: ComponentType<never>, currency: DisplayCurrency, props = {}) {
const html = renderToStaticMarkup(
createElement(
DisplayCurrencyProvider,
{ currency } as ComponentProps<typeof DisplayCurrencyProvider>,
createElement(component, props as never)
)
)
return html.replace(/<[^>]+>/g, ' ')
}
renderToStaticMarkup works fine in Node, so the component renders, wrapped in the same currency provider the real app wraps it in, once per currency in a list of six. Then the markup is thrown away and only the words survive, because what these tests assert is a promise about the pricing page: a visitor is charged the local figure they are shown, so no price surface may describe that figure as an approximation or a conversion from pounds. That is a list of banned phrases checked against the rendered text, and a regex over the visible text is exactly the right instrument for it.
The limits are real. Nothing here can click. No test in this repository has ever fired an event, checked a focus ring, or asserted that a dialog closes. For a suite whose job is to protect money and metadata that is a defensible trade, but it should be said out loud rather than discovered later.
Technique two: read the page as text
Two files do not import the thing they test at all. The page metadata test regexes export const metadata out of 100 route files because importing one boots Supabase, and the price copy test does the same thing for the pages that read the request:
The pricing cards are rendered for real; the pages that can't be rendered
outside Next (they read the request, or pull in Supabase) are read as source,
as __tests__/content/page-metadata.test.ts does.
A third file, shared-constants.test.ts, parses a different package's source with a regex, because the WebSocket server is a separate build root that this test run cannot import from.
Three files, then, that test code by reading it rather than running it. That is usually a smell, and here it is the consequence of one decision: the test runner is plain Node, so anything that only exists inside the Next.js runtime has to be checked some other way or not at all.
The thirteen tests that need the world
gameplay.integration.test.ts is the only file that reaches a service, and it is the longest in the suite at 543 lines for 13 tests. It gets its connection string from the setup file, which is seven lines:
/**
* Vitest global setup: loads environment variables from .env.local so
* integration tests can reach the database and other services.
*/
import { config } from 'dotenv'
config({ path: '.env.local' })
So the entire difference between a unit test and an integration test in this repository is whether the file happens to import @/utils/db/db. There is no separate project, no tag, no testNamePattern in a second script. pnpm test runs all sixteen files, and the thirteen tests at the end of it write rows into a real Postgres.
They are careful about it: every row the file creates is tracked and deleted in afterAll. But "deleted in afterAll" has a well-known failure mode, which is that you press Ctrl-C. An interrupted run leaves its rows behind, and the next thing to read that database sees them. If I were starting this suite again, the integration file would live behind its own script and its own database, and pnpm test would mean the 253 that need nothing.
Two dependencies nothing imports
While counting, two entries in devDependencies turned out to have no call sites at all:
$ grep -rln "fast-check" app lib utils components __tests__ scripts ws-server/src
$ grep -c coverage package.json
1
fast-check is a property-based testing library, imported by zero files. It was installed for the currency rounding work, which is the obvious candidate for generative tests (43 tests over six currencies, all of them hand-written examples), and then never used. @vitest/coverage-v8 is installed, and the single mention of the word "coverage" in package.json is that dependency line: there is no test:coverage script and no coverage block in the Vitest config, so no coverage report has ever been generated. I know the suite's size and its speed, and I genuinely do not know what fraction of the codebase it touches.
The honest summary of our quality gates is shorter than it should be. pnpm test works and takes 603 milliseconds. pnpm format:check reports style issues in 335 files. pnpm lint has not run since the Next 16 upgrade, because next lint was removed and the command now reads its argument as a directory:
$ pnpm lint
$ next lint
Invalid project directory provided, no such directory: /Users/daniel/Documents/programs/pub-trivia/lint
And there is no .github directory in the repository, so nothing runs any of it except me, on this laptop, when I remember. Vercel builds and deploys on push without running a single test.
What I am doing about it
In order, cheapest first: split the integration file out of the default run, add the coverage script that the already-installed dependency makes free, and fix the lint script. None of that needs a new dependency.
Adding jsdom is the one I keep not doing, and the reason is the same one that produced this shape in the first place. The things most likely to break on this site are a price in the wrong currency, a page that drops out of the sitemap, and a plan change that leaves somebody with no subscription. All three are testable in Node, in milliseconds, with no DOM. The dialog animation is not what keeps me up.
See the surface these tests protect
The 28 copy tests are all about one page, so the fastest way to see what they are guarding is to open it:
-
The pricing page renders the plan cards that
renderToStaticMarkuprenders in the test, in your own currency, chosen from where the request comes from. Switch on a VPN and reload, and the figure changes while the wording around it does not. That invariance is what the tests exist for. -
The features index and the guides are built from the content registry that
registry.test.tschecks with 21 assertions. - The free tier needs no card, so you can sign up and look at the half of the app that has no tests at all.
Top comments (0)