DEV Community

Daniel Pertu
Daniel Pertu

Posted on

node --test runs our TypeScript, and the price is that @/ does not exist

Notifio is a desktop app that watches rental search pages and tells you the moment a new listing appears. The marketing site that sells it is a Next.js app living next door in the same repository, and this is every testing dependency it has:

"devDependencies": {
  "@tailwindcss/postcss": "^4.3.3",
  "@types/mailparser": "^3.4.6",
  "@types/node": "22.20.1",
  "@types/react": "19.1.8",
  "@types/react-dom": "19.1.6",
  "drizzle-kit": "0.31.10",
  "postcss": "^8.5.20",
  "tailwindcss": "^4.3.3",
  "typescript": "5.9.3"
}
Enter fullscreen mode Exit fullscreen mode

There is no Jest, no Vitest, no ts-node, no runner config and no transform. The script is one word and a flag:

"test": "node --test"
Enter fullscreen mode Exit fullscreen mode

That gets me 111 tests across four TypeScript files in 942 milliseconds, with no build step in front of them. I have written before about why the Electron app next door has no test framework either, where the argument was about the import graph rather than the runner. This post is the other half: what happens when you let Node itself be the runner on a TypeScript codebase, and the three specific things it takes away from you in exchange.

What it does for free

Node strips types natively now, so a .ts file with no JavaScript equivalent anywhere on disk just runs. The test files are ordinary TypeScript using node:test and node:assert:

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
  INDEXNOW_ENDPOINTS,
  MAX_URLS_PER_REQUEST,
  buildPayload,
  checkKeyFile,
  chunkUrls,
  describeStatus,
  isPublicHost,
  isValidKey,
  keyFileUrl,
  parseSitemapUrls,
  partitionByHost,
  resolveUrl,
  submitBatch,
} from "../../src/lib/seo/indexnow.ts";
Enter fullscreen mode Exit fullscreen mode

node --test finds those files by convention, runs each in its own process, and prints a TAP-shaped tree:

▶ isPossibleMoRMisroute
  ✔ flags an EU sale that collected no tax (0.078875ms)
  ✔ stays quiet when tax was collected (0.046042ms)
  ✔ stays quiet outside the allowlist, where no tax was due (0.06675ms)
  ✔ stays quiet when the billing country is unknown (0.04575ms)
  ✔ does not depend on the master switch (0.054667ms)
✔ isPossibleMoRMisroute (0.367ms)
ℹ tests 111
ℹ suites 25
ℹ pass 111
ℹ fail 0
ℹ duration_ms 942.410084
Enter fullscreen mode Exit fullscreen mode

The important caveat, which is easy to miss and easy to be burned by: Node strips types, it does not check them. Nothing in that run would notice that a function is being called with the wrong argument type. Type checking is a separate script (tsc --noEmit) and has to stay one. If you read node --test as "my types are fine", you have swapped a test framework for a false sense of security.

Price one: every relative import spells out .ts

Node resolves modules the way Node resolves modules, so from "../../src/lib/seo/indexnow" is a file that does not exist. The extension has to be in the import. That in turn upsets tsc, which by default refuses an import ending in .ts, so the tsconfig has to opt in:

// The unit tests under __tests__ run on Node's built-in test runner, which
// strips types natively and therefore needs the `.ts` extension spelled out
// in their relative imports. Nothing is emitted from here (noEmit), and Next
// compiles the app with SWC, so this only affects what tsc will accept.
"allowImportingTsExtensions": true,
Enter fullscreen mode Exit fullscreen mode

This one is free. It costs a line of config and it never comes up again.

Price two: @/ does not exist

paths in tsconfig is a compiler fiction. Next honours it, your editor honours it, Node has never heard of it. So the moment a test imports a module, that module and everything it transitively imports has to be alias-free all the way down, or the test process dies on resolution.

You can treat that as an annoyance and add a loader. I treated it as a design constraint, and it turned out to be a useful one. The IndexNow module says so in its own header:

/**
 * Deliberately free of `@/` path aliases and of any dependency: this is
 * imported both by the Next app and by `scripts/indexnow.mjs`, which runs on
 * bare Node and cannot resolve the alias.
 */
Enter fullscreen mode Exit fullscreen mode

That file is imported by three consumers with three different resolvers: the Next build, a plain .mjs CLI script, and the test runner. The rule "no aliases, no dependencies" is what makes one copy of the logic serve all three. It is the same constraint the Electron app applies for a different reason, and in both cases the module that ends up isolated is the one carrying the policy worth pinning.

The part that is genuinely a cost: this pushes you towards testing leaves rather than trunks. A React page that imports @/components/Nav is not reachable from a test here, and no amount of cleverness changes that. If your test plan needs component rendering, you want a real framework. Mine does not, because the things I care about pinning are pure functions about money, hosts and strings.

Price three: barrel files bite

The site has a catalogue of per-site alert pages, one entry per rental portal, and the pages at notifio.app/alerts are generated from it. There is a barrel that re-exports every market file, and the barrel uses extensionless imports because it is only ever consumed by Next.

A test that imports the barrel therefore explodes. The fix in the metadata test was to skip the barrel entirely and read the directory:

/**
 * The alerts barrel uses extensionless imports that Node cannot resolve, so the
 * site files are loaded straight from the directory. Globbing rather than
 * listing them also means a new market file is covered the day it is added.
 */
const SITES_DIR = path.join(import.meta.dirname, "../../src/lib/alerts/sites");
const ALERT_SITES = (
  await Promise.all(
    readdirSync(SITES_DIR)
      .filter((name) => name.endsWith(".ts"))
      .map((name) => import(path.join(SITES_DIR, name))),
  )
).flatMap((module) => Object.values(module).flat());
Enter fullscreen mode Exit fullscreen mode

Top-level await in a test file, because node --test runs them as ES modules. Two things worth noticing. First, import.meta.dirname exists now, so there is no fileURLToPath(import.meta.url) dance. Second, the workaround is better than the thing it replaced: a hand-written list of market files would go stale the first time I added a country, and the glob cannot. I went looking for a way around a resolver limitation and came back with a test that covers files I have not written yet.

That test is the one that checks every generated page fits inside the search engine title and description budgets, which I wrote about separately in Fifty nine characters. It now covers the fifteen site pages under /alerts, the comparison pages under /compare, the guides under /guides and every static page with a literal metadata export.

The habit worth stealing, whatever your runner is

One line from the pricing test has nothing to do with node --test and is the most portable thing in this post:

/** The conversion-fee buffer the module applies. Restated here on purpose: a */
/** test that imported the constant would pass if the constant were deleted. */
const FEE_BUFFER = 1.04;
Enter fullscreen mode Exit fullscreen mode

The module under test converts a GBP price into an approximate local figure for the prices shown on notifio.app/pricing, and it pads the result so the number a visitor reads is never lower than what their card is actually asked for. If the test imported the buffer constant, then deleting the buffer would delete the assertion at the same time, and the suite would go green while the guarantee died. So the number is typed out again in the test, on purpose, with a comment saying why somebody should not "tidy it up".

Importing a constant into its own test is how a suite quietly stops testing anything. That is true in Jest too.

Would I do it again

For this codebase, yes, and the deciding factor was not speed or dependency count. It was that the four things worth pinning on this site are all pure: currency conversion, tax routing by country, IndexNow batching rules, and page metadata lengths. None of them need a DOM, a mock or a fixture server. Handing that to a framework would have meant a config file, a transform, a watch mode and four more entries in the lockfile, in exchange for assertions that already read fine as assert.ok.

If I needed to render a component, I would install Vitest tomorrow and not feel clever about it. The win here is not the absence of a framework. It is that the constraint the runner imposed, no aliases and no hidden dependencies, is a constraint I wanted on those modules anyway.

Top comments (0)