Next.js Testing: 305 Tests, Zero Rendered Components
31 test files, zero jsdom, zero @testing-library — every assertion is a data invariant or a security boundary. What that catches, and what it can't.
This codebase runs 305 tests across 31 files, and not one of them renders a React component. vitest.config.ts declares no environment: "jsdom", no @testing-library/react is a dependency, and every test is a plain Node function call against data, business logic, or a security boundary — because a storefront's actual failure modes here are a broken invariant or a bypassed check, not a button that doesn't render.
What "testing a Next.js app" actually covers
| Test category | What it catches | Where it lives | Count |
|---|---|---|---|
| Registry invariants | A post/product/spoke added to one place and forgotten in another | src/data/__tests__/ | 5 files |
| Business logic | Filtering, entitlements, refund eligibility, download options | src/lib/__tests__/ | ~13 of 23 files |
| Security boundaries | IDOR, webhook signatures, authorization gates, error-message leakage | src/lib/__tests__/ | ~10 of 23 files |
| Release tooling | Version-manifest and repo-mapping correctness for the template-release pipeline | src/lib/release/__tests__/ | 3 files |
| Component rendering | — | — | 0 files |
That last row is the point, not an omission. The Playwright vs. Vitest post already covers why this codebase runs both tools for entirely unrelated jobs — Vitest gates every push, Playwright is installed ad hoc for release screenshot automation, never for assertions. This post is about what the 305 Vitest tests actually assert, because "we have tests" says nothing about what kind.
The registry check: three files that must agree
CLAUDE.md's rule for adding a blog post is that three files — src/data/blog.ts, src/content/blog/<slug>.mdx, and the loader map in src/lib/blog.ts — must all agree, and the test that enforces it is a loop, not a snapshot:
// src/data/__tests__/blog.test.ts
it("every post has a registered MDX loader", () => {
for (const post of blogPosts) {
expect(
registeredSlugs,
`${post.slug} has no loader in src/lib/blog.ts`,
).toContain(post.slug);
}
});
it("has no loader without a matching post entry", () => {
for (const slug of registeredSlugs) {
expect(
getPost(slug),
`loader ${slug} has no metadata entry`,
).toBeDefined();
}
});
Two assertions, checking the drift in both directions — a post with no loader, and a loader with no post. Neither failure would show up in next build, which happily compiles whatever .mdx files exist regardless of whether the registry references them. Nothing here touches a DOM, a browser, or even React; it's two array comparisons, which is also why the whole suite finishes in about three seconds.
The security boundary check: proving an authorization gate holds without a server
redeemSlot in src/lib/actions/redemption.ts is a Server Action guarding against IDOR (OWASP A4/API1 — a caller reaching another user's data by ID) — and testing it needs neither a running server nor a real Supabase instance:
// src/lib/__tests__/redemption-idor.test.ts
it("user B submitting user A's slotId → denied, and the atomic write is NEVER reached", async () => {
stubSlotSelect({
id: VALID_SLOT_ID,
user_id: USER_A, // owned by A, not the caller (B)
kind: "template_single",
status: "active",
redeemed_at: null,
});
const result = await redeemSlot(
null,
fd({ slotId: VALID_SLOT_ID, productSlug: "asoc-admin", framework: "react" }),
);
expect(result).toEqual({ ok: false, message: GENERIC_ERROR });
expect(mockAdminFrom).not.toHaveBeenCalled();
});
vi.mock fakes the Supabase server/admin clients and next/cache before redemption.ts evaluates, so the test exercises the real ownership-check logic at redemption.ts:82 with none of its infrastructure. The second assertion is the one that actually matters: it doesn't just check the response shape, it checks that the service-role admin client was never called — proving the reject happens before the privileged write path is reached, not just that the response happens to look like a denial. A test that only checked result.ok === false would pass even if the ownership check ran after the write.
Testing a timing-safe comparison without timing anything
verifySignature — the function that authenticates every incoming LemonSqueezy webhook — is a good example of a security property a unit test can prove without measuring a single clock cycle:
// src/lib/__tests__/signature.test.ts
it("rejects empty/malformed header", () => {
expect(verifySignature(body, "", secret)).toBe(false);
expect(verifySignature(body, "not-hex-!!", secret)).toBe(false);
expect(verifySignature(body, "ab", secret)).toBe(false); // right hex, wrong length
});
The implementation uses Node's timingSafeEqual, which throws on a length mismatch rather than returning false — a detail that matters because timingSafeEqual exists specifically to prevent an attacker from learning how much of a guessed signature was correct by measuring response latency, and a function that throws on the malformed case defeats that property by leaking information through a different channel (an error vs. a clean rejection) instead of timing. verifySignature guards this explicitly — comparing buffer lengths before ever calling timingSafeEqual — and the third assertion above ("ab", valid hex but the wrong length) is the one that would fail loudly if that guard were ever removed. The test can't measure whether the real comparison is constant-time; what it proves is that the malformed-input path is handled the way the timing-safe design requires, every time npm test runs, rather than trusting a code review to notice if someone "simplified" the guard away.
What this approach cannot catch
Being honest about the gap: zero component tests means a regression in what a user actually sees — a button that silently stops rendering, a form that submits without any visible feedback — is invisible to this suite by design. That gap is exactly what the FormStatus fix in the toast-notification post had to be caught by manual audit, not a test, because nothing here asserts what an aria-live region announces. The tradeoff this codebase has made is that data and security invariants are cheap to test exhaustively and catch real, shipped-to-production bugs (the redemption IDOR guard exists because that class of bug is exactly what an authorization boundary needs proven), while UI regressions are caught by the accessibility and Lighthouse audits that already run across this blog's batches instead of by a rendering test suite.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Testing that a denial happened, not that the privileged path was never reached | A reordering bug (check runs after the write) still passes | Assert the mocked privileged call was never invoked, not just the return value |
Adding jsdom "just in case" | Slower test boot for a suite that never renders anything | Only add a DOM environment when a test actually needs document |
| A registry three-way check written as one test instead of two | A loader-with-no-post-entry can go undetected if only the reverse direction is checked | Check both directions explicitly, as separate assertions |
Mocking getProduct/the catalog in a flow test | The test no longer exercises the real gate condition (e.g. premium-only) it's meant to prove | Use the real catalog and pick a genuinely matching fixture (asoc-admin is real and premium) |
| Treating "305 tests pass" as proof of UI correctness | Ships a rendering regression with a fully green CI run | Pair the test suite with a manual or Lighthouse-driven UI check — they cover different failure classes |
Frequently asked questions
Why no @testing-library/react at all?
Because nothing in this codebase's test suite renders a component tree — every assertion is against data (a typed array, a registry) or a function's return value (an authorization decision, a filter result). Adding React Testing Library would mean adopting jsdom and a render step for a class of bug this suite has deliberately chosen not to chase with automated tests.
Is Vitest actually faster than Jest here, or does it just feel that way?
Both can be fast; the difference this codebase leans on is Vite-native ESM and TypeScript handling with no separate transform config, which is why npm test finishes 305 tests in about three seconds without a babel.config or ts-jest step in the middle. The full run breakdown — including the environment 3ms line that accounts for most of the gap people attribute to the runner itself — is in Vitest vs. Jest.
Where do E2E tests fit if there are none in this suite?
They don't, currently — Playwright is present only for release-screenshot automation, not assertions. That's a deliberate scope decision this blog has made, not evidence that E2E testing is unnecessary in general; a checkout flow with real payment redirects is exactly the kind of thing E2E testing exists for, and this storefront's LemonSqueezy webhook is instead covered by the signature-verification and idempotency unit tests in webhook.test.ts and webhookRoute.test.ts.
How do you test a Server Action without a real database?
vi.mock the modules the action imports (@/lib/supabase/server, @/lib/supabase/admin, next/cache) before the action file is evaluated — Vitest hoists vi.mock calls above imports automatically, so the fakes are in place before redemption.ts's own import statements run. The action's real logic executes against the fakes, which is what lets a test assert on internal call sequencing like "the admin client was never touched."
Templates where this ships
ASoc Catalyst is an AI automation agency site; ASoc Chain is a DeFi protocol landing page — both ship from a repo that runs the same invariant-test discipline as this storefront's own catalog.test.ts. ASoc Cognition is an AI consulting agency template built on the same convention.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the tool-choice question this post assumes as settled, read Playwright vs. Vitest; for the accessibility gap automated tests here don't cover, read React toast notifications.
