Skip to main content
Web app skills · Lesson 8 of 11 · 14 min

Test a Next.js Site With node:test and Build Gates

Test a Next.js site without Jest: node:test contract tests, prebuild gates like assertBlogParity, and how to decide what a test guards and a gate does.

By Matthews WongReviewed on

Key takeaway

A contract test checks that your data or a pure function still obeys a rule, and the built-in node:test runner is enough to write one. A build gate is a script that fails the build when an invariant breaks. Use tests for behaviour and content contracts, and gates for cheap invariants that must never reach production.

You will be able to

  • Write a data-driven contract test with node:test and run it with node --test.
  • Write a prebuild gate that fails with a message naming the fix.
  • Decide whether a rule belongs in a test, a gate, or a live check.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

One change, three safety nets

A data change meets npm test, then the prebuild gates, then the build with assertBlogParity, and finally the live agents:check, each failing in a different place with a different message.

Step 1 of 6. Four checkpoints sit between an edit and a visitor. Each one is cheap, fast, and watches something different.

Keys: ← → step · Home / End · K play or pause

  1. Four checkpoints sit between an edit and a visitor. Each one is cheap, fast, and watches something different.

  2. You edit the Dockerfile that a lesson quotes. npm test imports every lesson and runs one check per lesson against its data.

    Predict the next step The lesson still quotes the old Dockerfile line. What catches it first? (The excerpt test in npm test · The production build · Nothing, it ships)
  3. The quoted excerpt no longer matches the file, so an assertion throws, the test fails and the process exits 1. The message says to update the lesson.

    node --test tests/learn-content.test.mjs
  4. After the fix, prebuild runs plain Node scripts before the build starts. One of them compares a generated file with what the source would produce now.

    node scripts/generate-blog-sources.mjs --checkPredict the next step The generated citations file is stale. What does the gate do? (Build continues with old citations · Exits 1 and prints the command to run)
  5. It prints that the file is stale and which command regenerates it, then exits 1. The build never starts, so nothing stale can ship.

    process.exit(1);
  6. Regenerated, the gates pass. The build asserts post parity while rendering the sitemap, and agents:check later probes the running site over HTTP.

    assertBlogParity();

Can node:test replace Jest for a Next.js site?

For many sites, yes. Node.js ships a test runner: you import test, or describe and it, from node:test, and a test passes unless it throws, returns a rejected promise, or reports an error. The docs mark the runner stable since Node 20. You run it with node --test, which by default picks up files named like *.test.mjs, and if any test fails the process exit code is 1.

Read more (1)

This repo has no test framework dependency. Its tests live in tests/ as .test.mjs files, assertions come from Node's assert module, and one npm script runs them. Because the TypeScript data modules are not plain JavaScript, each test file first loads a small resolver, so it can import the real lib files instead of copies. The test headers note that this needs Node 22.18 or newer.

What should a test guard?

Tests suit behaviour you can describe as input and output. The Accept-header function is the model: a table of header strings and the expected answer, one generated test per row. A browser's header, a bare star-slash-star, text/markdown with q=0, and an empty header are all cases that would be painful to reproduce by hand and cost nothing to keep in a table.

Read more (2)

Tests also suit content contracts. The /learn courses are data, so one test file loops over every lesson file and generates checks for each: the slug matches the filename, titles fit the SERP budget, the quiz has three questions, and every quoted code excerpt must still appear byte for byte in the real file. Edit the Dockerfile and the lesson that quotes it fails until someone updates it. That is drift detection a reviewer would never catch by eye.

Next.js ships its own helper for this too. The docs describe an experimental unstable_doesProxyMatch function, in next/experimental/testing/server, that asserts whether middleware would run for a given URL, so a matcher change can be pinned by a test. It is marked experimental, so treat it as optional.

What should a build gate guard instead?

A gate guards an invariant that must hold for the build to be worth shipping, and it must be cheap and dependency-light. This repo's prebuild script chains small Node scripts: one checks that the generated citations file is current, one that every project has a placement in the game, one that the 3D engine is only reached through a lazy boundary, and one that translation key trees match. Each exits non-zero with a message, which stops the build.

Read more (2)

A gate can also live in application code. assertBlogParity throws if the registry of posts and the table of their metadata cover different slugs, and also if a card's displayed date contradicts the date in the structured data. It runs while the sitemap, the feeds and llms.txt are built, so a broken registry fails the build instead of shipping a broken sitemap. That date check exists because comparing slug sets alone once let 168 posts drift.

Why plain scripts, not tests? The placements gate says it itself: the build image runs Node 20, which cannot import the TypeScript data modules, so it reads the slugs with a regex. That keeps it dependency-free and able to run anywhere the build runs. A test that needs the newer Node and the resolver would not run there.

How do you decide between a test, a gate and a live check?

Ask three questions. Would breaking this silently ship a bad site? Then make it a gate, because a build that fails cannot ship. Does it need rich imports, many cases or an explanation of intent? Then write a test, where tables and loops are natural. Does it depend on real HTTP behaviour, such as headers a deployed server really sends? Then it is a live check: the unit tests say so, and a separate script, agents:check, probes the running site.

Read more (1)

Whatever you pick, make the failure teach the fix. The stale-file gate prints the file name and the exact command to regenerate it. The parity check prints the diff of slugs. A failure that says what to run next turns a blocked build into a thirty-second chore.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

package.json:10-13
    "test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test tests/",
    "agents:check": "node scripts/check-agent-readiness.mjs",
    "prebuild": "node scripts/generate-blog-sources.mjs --check && node scripts/game/check-placements.mjs && node scripts/game/check-imports.mjs && node scripts/check-message-keys.mjs",
    "sources:build": "node scripts/generate-blog-sources.mjs",

Three different nets in four lines. test runs the contract tests with node --test, prebuild chains the gates with && so the first failure stops everything, and agents:check is the on-demand probe of a running site. The sources:build line is the fix the stale-file gate points to.

scripts/generate-blog-sources.mjs:105-111
if (CHECK_ONLY) {
  if (next !== previous) {
    console.error(
      `FAIL ${path.relative(ROOT, OUT)} is stale — run: node scripts/generate-blog-sources.mjs`,
    );
    process.exit(1);
  }

A freshness gate in six lines. It regenerates the content in memory, compares it with the committed file, and on a mismatch prints the file and the command that fixes it before exiting 1. The same script writes the file when run without the flag.

lib/blog-meta.ts
/**
 * Build-time guard: BLOG_POSTS_STATIC (which posts exist) and BLOG_META
 * (their SEO metadata) must cover exactly the same slugs. Throws with the
 * diff so a divergent build fails instead of shipping a broken sitemap.
 */
export function assertBlogParity(): void {

A gate inside application code. It is called wherever the sitemap, feeds and llms.txt are generated, so the build cannot finish with two registries that disagree. Its error lists the diff, not just a count.

tests/agent-readiness.test.mjs:52-56
  for (const [accept, expected] of cases) {
    it(`${JSON.stringify(accept)} -> ${expected ? "markdown" : "html"}`, () => {
      assert.equal(prefersMarkdown(accept), expected);
    });
  }

A data-driven test: the loop turns each row of a cases table into its own named test. Adding a header case is one line of data, and a failure names the exact header that broke.

scripts/game/check-placements.mjs:10-12
 * The data modules are TypeScript and the build image runs Node 20, which
 * cannot import them; the slugs are read with a regex instead. That is
 * enough for a parity check and keeps this script dependency-free.

Why a gate is a plain script. It must run where the build runs, and that environment cannot load the TypeScript data, so it trades a little precision for zero dependencies.

What breaks

Mistakes that cost real time

  • A gate that passes while the data is wrong

    Comparing only slug sets let 168 post dates drift, because every value was individually valid. When two sources must agree, check the value they share, not just that both exist.

  • A test that needs a newer Node than the build

    The contract tests need Node 22.18 or newer, while the build image runs Node 20. Put anything the build must enforce in a plain script that Node 20 can run, and keep the resolver-based tests for npm test.

  • A failure message that does not say what to do

    Exit 1 with no explanation sends people hunting. Name the file, the rule and the command that fixes it, as the stale-file gate does.

  • Mocking what a real file can tell you

    A test with a copy of the Dockerfile cannot notice the Dockerfile changing. Read the real file and compare, as the excerpt test does.

Three safety nets in this repo, and what each is for
Run bynpm test, on demandprebuild or the build itselfagents:check, on demand
GuardsBehaviour, data contracts, driftInvariants that must never ship brokenWhat a real server answers over HTTP
NeedsThe TypeScript resolver, Node 22.18+Nothing beyond Node, runs in the buildA running site
Try it yourself

Do it on your own machine

Write one data-driven test and one gate for something in your own project.

  1. Create tests/slug.test.mjs. Put the cases in an array and let a loop generate one named test per case.

    import assert from "node:assert/strict";
    import { describe, it } from "node:test";
    
    const cases = [["Hello World", "hello-world"], ["  a  b ", "a-b"]];
    
    describe("slugify", () => {
      for (const [input, expected] of cases) {
        it(`${JSON.stringify(input)} -> ${expected}`, () => {
          assert.equal(slugify(input), expected);
        });
      }
    });
  2. Run it with node --test, then break one expected value and read the failure. Confirm the exit code is 1.

    node --test tests/
    echo $?
  3. Write a gate: a script that rebuilds a generated file in memory, compares it with the committed one, and exits 1 with the fix command when they differ.

  4. Chain the gate in a prebuild script so it runs before every build, then edit the source without regenerating and watch the build stop.

    "prebuild": "node scripts/check-generated.mjs"
Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.

  1. 1.A rule must hold for every build, and the check can only use plain Node 20. Where does it belong?
  2. 2.Why does one test file loop over every lesson instead of testing one lesson by hand?
  3. 3.What makes the stale-file gate cheap to obey when it fails?
Think it through

Answer in your head, then check

  1. 1.Why did assertBlogParity gain a date check on top of comparing slugs?Show the answer

    Answer:Matching slug sets was not enough: 168 posts showed a date that contradicted the date in their structured data, and every value was individually valid. A gate should compare the shared value that can disagree.

  2. 2.A test fails when a quoted excerpt stops matching its file. Is that a bug in the test?Show the answer

    Answer:No, it is the point. The lesson quotes a real file, so when the file changes the lesson is out of date, and the failure forces someone to update it.

  3. 3.Which of the three nets would you use for the headers a deployed server really sends?Show the answer

    Answer:The live check. Unit tests only exercise the code, so this repo keeps a separate script, agents:check, that makes real HTTP requests to a running site.

Go deeper

Related deep dives on this site