Key Takeaway
How to run the Node test runner in Next.js: write a file named *.test.js that imports describe and it from node:test and assert from node:assert/strict, run it with node --test, then add npm test as a GitHub Actions step before the build. Exit code 1 on a failing test stops the pipeline, no Jest needed.
You change one small function, the build is green, and a week later you notice it returns the wrong value for one edge case. Nothing caught it because there was no test. The usual next move is hunting for a test framework, and that is where beginners stall: Jest, Vitest, config files, transformers. Node.js already ships its own test runner.
This article takes you from an empty folder to a first test that runs, fails on purpose, tests an API handler and then becomes a gate in GitHub Actions. Every command was actually run and the output is copied as it appeared. In this site's repo, tests really do live in a tests/ folder and run with one npm script, with no test framework.
Want to know when a rule should be a test and when a build gate? Take the free lesson Test a Next.js Site With node:test and Build Gates. This article is its hands-on version, step by step.
What you need
Three things, and none of them is an npm install:
- Node.js 20 or newer. The Node documentation marks the test runner stable since v20.0.0.
- A project folder with a package.json. For a Next.js project, your existing folder is enough.
- A terminal, and a GitHub repo if you want to reach the last step.
Step 1: Set up the folder and the npm test script
Create a small practice folder (or use your Next.js project, only the file locations differ). The type module line lets .js files use import, and the test script just calls node --test with no argument, so Node finds test files by name on its own.
{
"name": "demo",
"private": true,
"type": "module",
"scripts": { "test": "node --test" }
}
# Check your Node version (20 or newer)
node --versionThe naming rule matters and comes back in the error table. With no argument, Node runs files matching patterns like **/*.test.js, **/*-test.js, **/test-*.js, or any file inside a folder named test. A folder named tests is not picked up by itself, so name your file *.test.js.
Step 2: Write a pure function and your first test
Start with a pure function: input in, output out, no network and no database. That is the cheapest kind of code to test. The slugify function below turns a title into a URL segment.
// lib/slugify.js
export function slugify(text) {
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
// tests/slugify.test.js
import assert from "node:assert/strict";
import { describe, it } from "node:test";
// .js is required: relative imports in ES modules need the extension
import { slugify } from "../lib/slugify.js";
const cases = [
["Hello World", "hello-world"],
[" Belajar Next.js ", "belajar-next-js"],
["", ""],
];
describe("slugify", () => {
for (const [input, expected] of cases) {
it(`${JSON.stringify(input)} -> ${JSON.stringify(expected)}`, () => {
assert.equal(slugify(input), expected);
});
}
});Look at the cases table. One loop turns each row into its own named test, so adding an edge case is one line of data, and a failure names the exact input that broke. The repo's Accept-header tests use the same pattern. Import assert from node:assert/strict so assert.equal compares strictly like strictEqual, not with ==.
Step 3: Run it, then break it on purpose
Run npm test and read the output. Then change one expected value to something wrong (for example hello_world), run it again, and notice three things: the cross on the failing test, the actual and expected parts, and exit code 1. That exit code is what GitHub Actions will read later.
npm test
# Output when everything passes (durations differ on your machine):
# ▶ slugify
# ✔ "Hello World" -> "hello-world"
# ✔ " Belajar Next.js " -> "belajar-next-js"
# ✔ "" -> ""
# ✔ slugify
# ℹ tests 3
# ℹ pass 3
# ℹ fail 0
# Now change "hello-world" to "hello_world" in the cases table and run again:
# ✖ "Hello World" -> "hello_world"
# AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
# + actual - expected
# + 'hello-world'
# - 'hello_world'
# ℹ fail 1
echo $?
# 1Reading a failure matters as much as writing a test. The plus sign is the real value from your function, the minus sign is what you expected. If you swap them in your head, you will fix the wrong side.
Step 4: Test an API handler that returns a Response
A route handler does not have to be tested through a real server. Move its logic into a function that returns a Response, then call that function directly. Response.json builds a Response object with a JSON body, and you can read its status, headers and body. This repo does something similar: a test calls the methodNotAllowed helper and checks the 405 status, the Allow header and the code in the JSON body.
// lib/api-error.js
export function methodNotAllowed(allowed) {
return Response.json(
{ success: false, code: "method_not_allowed" },
{ status: 405, headers: { Allow: allowed.join(", ") } },
);
}
// tests/api-error.test.js
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { methodNotAllowed } from "../lib/api-error.js";
describe("methodNotAllowed", () => {
it("answers 405 with an Allow header and a JSON body", async () => {
const response = methodNotAllowed(["POST"]);
assert.equal(response.status, 405);
assert.equal(response.headers.get("allow"), "POST");
const body = await response.json();
assert.equal(body.code, "method_not_allowed");
});
});
// Wrong: no await, so body is a promise and body.code is undefined.
// The failure says "actual: undefined", which hides the real cause.
const body = methodNotAllowed(["POST"]).json();
// Right: the test function is async and the promise is awaited.
const body = await methodNotAllowed(["POST"]).json();Because response.json returns a promise, the test must be async and use await. Forgetting await is the most common mistake in this step, and the message is confusing: actual is undefined, not a promise.
Anything that depends on real HTTP, such as which headers a deployed server actually sends, does not belong in a test like this. This repo keeps that kind of check in a separate script that probes the running site.
Step 5: Run part of the suite and see how this repo does it
Once you have many tests, run only some with --test-name-pattern. The pattern is interpreted as a JavaScript regular expression and matched against test names. There is also --watch to rerun when files change, but the Node documentation marks it as still experimental.
# Only tests whose name matches the pattern (a JavaScript regular expression)
node --test --test-name-pattern="methodNotAllowed"
# Or pass a glob yourself instead of relying on the default patterns
node --test "tests/**/*.test.js"
# This site's package.json: one script, the whole tests/ folder
# "test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test tests/"In this site's repo, the test script calls node --test on the tests/ folder, which holds files such as agent-readiness.test.mjs. Tests that import TypeScript modules load a small resolver first, and the file header notes that this needs Node 22.18 or newer. The Node documentation records that type stripping is enabled by default since v22.18.0. For plain JavaScript tests like the ones in this article, Node 20 is enough.
Step 6: Make npm test a gate in GitHub Actions
A test that only runs when you remember to run it will get skipped. Add it as a workflow step before the build, so a pull request with a red test never reaches the build or deploy stage. A run step fails when its exit code is non-zero, and that is the whole mechanism.
# .github/workflows/test.yml
name: test
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
# Exit code 1 from node --test fails this step and stops the job
- run: npm test
# Only reached when the tests passed
- run: npm run buildFor comparison, the verify job in this site's deploy workflow runs npx tsc --noEmit and npm run lint, and the build job waits for it through needs. Adding npm test between them is one line of the same shape.
No workflow yet? First follow how to build a CI/CD pipeline with GitHub Actions for beginners, then come back here to add the test step.
A test whose name does not match the pattern does not error, it just does not run. If the tests count in the output is lower than you expected, check the file name before you suspect the code.
Common errors and how to fix them
Six problems that most often appear the first time you use node:test, with their causes and fixes.
| Symptom | Cause | Fix |
|---|---|---|
| ERR_MODULE_NOT_FOUND: Cannot find module .../lib/slugify, exit code 1 | A relative import in an ES module without the file extension. | Write it in full: import from ../lib/slugify.js, not ../lib/slugify. |
| The tests count is lower than it should be, with no error | The file name does not match the pattern, for example tests/slugify.js, so it never runs. | Rename it to slugify.test.js, or pass a glob pattern as an argument to node --test. |
| An async test fails with actual being undefined | A missing await on response.json(), so a promise is compared instead of its content. | Make the test async and write await response.json(). |
| assert.equal passes when 1 is compared with the text 1 | Importing from plain node:assert uses legacy mode, which compares with ==. | Import from node:assert/strict, where assert.equal is an alias of strictEqual. |
| Tests pass on your machine but fail importing a TypeScript file on an old Node | Node below 22.18 does not enable type stripping by default. | Raise the Node version locally and in the workflow, or write tests for plain JavaScript files. |
| The pipeline is green but the tests never ran | The workflow has no npm test step, or package.json has no test script. | Add run: npm test before the build and make sure the test script calls node --test. |
Checklist before you push
- Test files end in .test.js or .test.mjs and show up in the tests count.
- You broke one test on purpose and saw exit code 1.
- Async tests use await for every promise.
- assert is imported from node:assert/strict.
- The workflow runs npm ci, then npm test, then npm run build.
Go deeper
The free lesson Test a Next.js Site With node:test and Build Gates explains when a rule becomes a test and when it becomes a build gate that fails the build. For the full pipeline through to deploy, read CI/CD with GitHub Actions: from zero to production, and to test the built app in a real browser, see how to build a Playwright smoke test in GitHub Actions.
The rule to carry away: start from a pure function, write the cases as a data table, break one value to prove the test can actually fail, then put npm test before the build so exit code 1 guards production instead of your memory.

