DEV Community

Daniel Pertu
Daniel Pertu

Posted on

611 tests in 4.2 seconds, no test framework, and only one file mocks anything

This is the entire test configuration for Nakodo:

"test": "tsx --test \"src/**/*.test.ts\""
Enter fullscreen mode Exit fullscreen mode

There is no vitest.config.ts, no jest.config.js, no setup file, no transform, no testEnvironment, no mock factory and no @testing-library anything. 59 test files, 4018 lines of test code, and the only dependency involved is tsx, which is already there for the project's scripts.

What it prints:

ℹ tests 611
ℹ suites 75
ℹ pass 611
ℹ fail 0
ℹ duration_ms 4216.2785
Enter fullscreen mode Exit fullscreen mode

4.2 seconds for the suite, 5.2 seconds of wall clock for the whole command at 678% CPU, because node --test runs each file in its own process and my laptop has cores to spare. Nothing is watching, nothing is caching, there is no second run that goes faster.

What node:test actually gives you

Every file starts the same way:

import assert from "node:assert/strict";
import { test } from "node:test";
import { nextMidnightIn, startOfDayIn } from "./time";
Enter fullscreen mode Exit fullscreen mode

test, describe, it, before, after, beforeEach, afterEach, mock, and a glob on the command line. That is the whole API surface in use here. assert/strict means assert.equal is ===, which removes the one footgun of the older assert module.

What you give up, honestly:

  • No DOM, so no component tests. Not "awkward" tests, no tests.
  • No JSX, because tsx --test strips types but a .tsx test still needs a renderer and a document, which it has neither of.
  • No expect, no matchers, no snapshots.
  • No automatic module mocking. mock.module exists in newer Node, but it has not been needed once here.

That list reads like a set of things missing. It has turned out to be a set of things that decide where the tests live.

assert's third argument is the actual feature

assert.equal(a, b) tells you two values differed. The third argument is a message, and it is the difference between a failure you can act on and a failure you have to debug:

assert.ok(page.metaTitle.length <= MAX_TITLE, `title is ${page.metaTitle.length} chars`);
assert.ok(total >= 600, `only ${total} words: thin pages don't rank and don't help`);
assert.ok(isPublicPath(KEY_PATH), `${KEY_PATH} would redirect the engines to /login`);
assert.ok(!re.test(t), `banned ${re} in: ${t.slice(0, 90)}`);
Enter fullscreen mode Exit fullscreen mode

The last two are from a test about a verification file and a test about banned words in copy. That last one is checked in two places, and the second has my favourite line in the test suite:

// Written as an escape, so the file that forbids the character does not contain it.
const EM_DASH = "\u2014";
Enter fullscreen mode Exit fullscreen mode

A test that bans a character cannot spell the character. In both cases the message explains the consequence, not the comparison. The failure output reads like a code review comment, which is the only thing I miss about matcher libraries: nothing in expect(x).toBeLessThan(y) encourages you to write the sentence.

63 of the 75 suites come from one for loop

describe takes a string, so suites can be generated. Our content pages are entries in one array, and the test file iterates it:

for (const { path, page } of ENTRIES) {
  describe(path, () => {
    test("metadata fits the search result", () => { /* ... */ });
    test("answers first and has substance", () => { /* ... */ });
    test("ids, dates and links are valid", () => { /* ... */ });
    test("follows the voice rules", () => { /* ... */ });
  });
}
Enter fullscreen mode Exit fullscreen mode

63 entries, four tests each, and the suite name is the URL. When something fails it says /guides/how-to-vet-a-youtube-creator > answers first and has substance, which names the page and the rule it broke with no further reading. That is 252 of the 611 tests, and I wrote four of them.

The remaining 12 suites are the files that use describe to group by hand. 59 files, 75 suites: most files are a flat list of test() calls, because nesting for the sake of nesting only makes the failure line longer.

There are no database tests, and that is the design

Not one test in this project opens a connection, starts a container, or seeds a row. There is no in-memory Postgres and no transaction rollback helper. The way that works is boring: anything worth testing is a function that takes values and returns values, and the code that talks to the database is thin enough to read.

Here is a representative one, from the module that works out when a daily API quota resets:

test("reset is just after the next Pacific midnight", () => {
  const now = new Date("2026-10-02T18:30:00Z"); // 11:30 PDT
  const reset = nextQuotaReset(now);
  assert.equal(pacificDay(reset), "2026-10-03");
  assert.equal(reset.toISOString(), "2026-10-03T07:05:00.000Z");
});

test("reset works across the DST change", () => {
  const now = new Date("2026-11-01T06:00:00Z"); // Oct 31, 23:00 PDT
  assert.equal(nextQuotaReset(now).toISOString(), "2026-11-01T07:05:00.000Z");
});
Enter fullscreen mode Exit fullscreen mode

Note what makes that possible: nextQuotaReset takes now as an argument. It does not call Date.now(). One parameter is the difference between a test with a fixed instant in it and a test that needs a clock library, and the same trick appears in every time sensitive module here:

test("start of day when the clocks go back", () => {
  // 1 Nov 2026: midnight is PDT (07:00Z), 05:00 PST is 13:00Z, six hours later.
  assert.equal(startOfDayIn(LA, new Date("2026-11-01T13:00:00Z")).toISOString(), "2026-11-01T07:00:00.000Z");
});
Enter fullscreen mode Exit fullscreen mode

Daylight saving arithmetic is the thing I am least able to reason about unaided, and it is fully covered, with the working in a comment above each assertion. Meanwhile the function that writes a row and returns it has no test at all, because there is nothing in it to be wrong about once the arithmetic is right.

The honest cost: a function that is hard to test as a pure function tends to get rewritten here until it is one. Sometimes that is good design pressure. Sometimes it is a parameter that exists for the tests, and I have stopped pretending those two are easy to tell apart.

One file mocks, and it mocks exactly one thing

Of 59 files, one uses mock:

import { afterEach, mock, test } from "node:test";

afterEach(() => mock.restoreAll());

test("submits the host, key and key file location the engines check", async () => {
  const fetch = mock.method(globalThis, "fetch", async () => new Response("", { status: 202 }));
  const result = await submitToIndexNow(["https://nakodo.app/pricing"]);

  assert.deepEqual(result, { ok: true, status: 202, body: "" });
  const [url, init] = fetch.mock.calls[0].arguments as [string, RequestInit];
  assert.equal(url, "https://api.indexnow.org/indexnow");
  // ... and the body it sent
});
Enter fullscreen mode Exit fullscreen mode

mock.method patches a property on an object, mock.restoreAll() in afterEach puts it back, and the assertion is on the request that would have gone out. No module interception, no dependency injection, no network. The reason this file is the exception is that it is the one place where the thing being tested is the request: the shape of the payload sent to a search engine is the behaviour, and there is no pure function hiding inside it worth extracting.

The same file does something I would flag in a review if someone else wrote it:

import { resolveSitemap } from "next/dist/build/webpack/loaders/metadata/resolve-route-data";
import sitemap from "@/app/sitemap";
Enter fullscreen mode Exit fullscreen mode

That is a deep import of a Next.js internal, used to serialise our sitemap exactly the way the framework will when it serves sitemap.xml, so the test can prove that what a script reads back off the live file matches what the code believes. It is pinned to an implementation detail and it will break on some future upgrade. I kept it because the alternative was a hand written XML parser in a test, and when it breaks it will break loudly in a test rather than quietly in production.

@/app/sitemap also shows the one piece of configuration that is not in package.json: tsx reads the paths in tsconfig.json, so test files use the same @/ imports as the app, and a test can import a route module directly.

What is not covered, and what covers it instead

No component renders in any test. No Server Action runs. No page is requested. 4018 lines of tests against 71050 lines of application code, which is 5.7% and not a number I would quote as a virtue.

The things those tests would catch are caught elsewhere, in ways I can describe without exaggerating:

  • tsc --noEmit and eslint, run in the same breath as the tests. A typed boundary between a Server Action and a page is cheaper than a test of the same thing.
  • Opening the app. Every UI change gets looked at, on a phone width and a desktop one, because the failures I actually ship are visual and no DOM assertion I have ever written would have caught them.
  • The content tests, which are the closest thing here to end to end: they load every public page's data, follow every internal link in its body, and refuse a title too long to fit a search result.
  • The data driven tests on things that are easy to get wrong once and never notice. The currency file is the clearest: it imports the plans module, so the test asserts against the same price objects that render on the pricing page, with cases named "the whole euro area, including Croatia (2023) and Bulgaria (2026)" and "yen, forints and krónur are written without decimals".

There is also no CI. No GitHub Actions workflow, no pre commit hook. pnpm lint && pnpm typecheck && pnpm test is a thing I type, and 4.2 seconds is slow enough to notice and fast enough that I never avoid it. If a second person joined, the first thing I would add is the workflow, and the second would be next typegen in front of the typecheck, because the generated route types are not in git.

Would I do it again

For a single developer product with a lot of pure logic and a UI that gets looked at daily, yes, and the deciding factor is not speed. It is that there is no configuration to be wrong. When a test fails here, the only candidates are my code and my assertion. I have spent more hours of my life debugging a test runner's module resolution than node:test has cost me in missing features.

The moment I would switch is the moment there is a component whose behaviour I cannot hold in my head, and at that point I want a real DOM and I would reach for vitest and keep the 59 files exactly as they are. Nothing in them would have to change.

Top comments (0)