DEV Community

Daniel Pertu
Daniel Pertu

Posted on

1,082 tests across six workspaces, no test framework installed, and four different ways of invoking the same runner

$ pnpm -r run test
...
ℹ tests 1082
ℹ pass 1082
ℹ fail 0
Enter fullscreen mode Exit fullscreen mode

That is six workspaces of a pnpm monorepo: a rules engine, a content package, a pricing package, a video package, a Next.js app and an Expo app. 55 test files. Grep the repository for jest, vitest, mocha or ava and you get nothing, because none of them is installed.

The whole test infrastructure is the Node built-in runner plus node:assert/strict. What is interesting is that the invocation is not the same in every workspace, and the differences are not sloppiness, they are three different relationships with the module resolver.

The four invocations

// packages/rules-engine, packages/content, packages/pricing
"test": "node --test 'src/**/*.test.ts'"

// packages/reels
"test": "node --experimental-strip-types --test 'src/**/*.test.ts'"

// apps/web
"test": "node --import tsx --test 'lib/**/*.test.ts'"

// apps/mobile
"test": "node --import tsx --test 'src/**/*.test.ts'"
Enter fullscreen mode Exit fullscreen mode

Four forms, one runner. Here is what separates them.

Three of them work bare because of one character in every import

Node can execute TypeScript directly now by stripping the types out. What it will not do is invent module specifiers for you. Node's ESM resolver has no extension-guessing step, so a TypeScript file that is going to be run by Node has to name its imports the way the runtime will look for them:

// packages/rules-engine/src/__tests__/engine.test.ts
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { fitCheck } from '../engine.ts';
import { getTaxonomyVersion } from '../util/overlay-state.ts';
import { product } from './fixtures.ts';
Enter fullscreen mode Exit fullscreen mode

'../engine.ts', with the extension, pointing at a file that is TypeScript. That looks wrong to anyone who has spent years writing '../engine' and letting a bundler sort it out, and it is exactly what makes the bare runner work. The shared packages are written for Node's resolver, so Node can run them with no loader, no transform step, no config file and no dependency.

The two apps are not written for Node's resolver, because they are not run by Node. The web app is compiled by Next, the phone app is bundled by Metro, and both of those resolve extensionless specifiers by convention, so the app code is full of them. Point the bare runner at an app test and it tells you so immediately:

$ cd apps/web && node --test 'lib/__tests__/lookup.test.ts'
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
  '/.../apps/web/lib/lookup' imported from /.../apps/web/lib/__tests__/lookup.test.ts
Enter fullscreen mode Exit fullscreen mode
$ cd apps/mobile && node --test 'src/lib/__tests__/recipes.test.ts'
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
  '/.../apps/mobile/src/lib/fitCheck' imported from /.../apps/mobile/src/lib/recipes.ts
Enter fullscreen mode Exit fullscreen mode

Note where the second one fails. The test file itself does write '../recipes.ts' with the extension. It is the app source two levels in that imports './fitCheck', and no amount of discipline in the test file fixes the file it is testing. That is the real boundary: you can write tests Node can resolve, but you cannot test bundler-resolved source without something that resolves like a bundler.

Hence --import tsx in the two apps and nothing in the three packages. One dependency, in the two places that genuinely need it, instead of a transform layer over the whole repository because two workspaces have bundler habits.

The fourth one is a leftover and it is worth saying so

packages/reels passes --experimental-strip-types explicitly. That flag was required when type stripping was behind a flag. It has been on by default since Node 22.18, which is the version this repository pins in .nvmrc, and the package's test runs without it on the Node I have installed today.

So it does nothing. I am leaving it in the post rather than quietly fixing it first, because this is what a real monorepo looks like: a flag that was once load-bearing, in one workspace, surviving a runtime upgrade that made it redundant. The cost of finding these is that four scripts that should be identical are not, and the next person has to work out whether the difference is meaningful. For --import tsx it is. For this one it is not.

One more artefact of running TypeScript directly, which you will see on every run:

[MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///.../lookup.test.ts is not specified
and it doesn't parse as CommonJS. Reparsing as ES module because module syntax was detected.
Enter fullscreen mode Exit fullscreen mode

Node is telling you it had to parse the file twice because the nearest package.json has no "type". For a Next.js app the package.json cannot casually gain "type": "module", so the warning stays and you pay a little startup cost per file.

The second tsconfig nobody expects

The Expo app type-checks twice:

"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json"
Enter fullscreen mode Exit fullscreen mode

And that second config is four lines:

{
  "extends": "./tsconfig.json",
  "compilerOptions": { "types": ["node"] },
  "include": ["src/**/__tests__/**/*.ts"],
  "exclude": []
}
Enter fullscreen mode Exit fullscreen mode

The app's own tsconfig.json ends with "exclude": ["**/__tests__/**"] and brings in no Node types. Both halves are deliberate. It is a React Native app, so process, Buffer and node:fs should not type-check inside app code, because they do not exist at runtime there. But the test files import node:test and node:assert/strict and run on Node, so under the app's config they would either fail to type-check or have to be excluded, which is what the exclude line does.

The second config picks the excluded files back up with "exclude": [], includes only src/**/__tests__/**, and adds "types": ["node"]. So app source is checked without Node types, test files are checked with them, the two sets do not overlap, and nothing goes unchecked. Running both is what tells you that neither the app has drifted into Node APIs nor the tests have lost theirs.

What the distribution of tests says about the project

Per workspace, from the run above:

workspace test files tests
apps/web 28 333
packages/rules-engine 16 186
apps/mobile 7 64
packages/content 2 474
packages/pricing 1 24
packages/reels 1 1

The content package is the fun one: 474 assertions out of 2 files, because those tests are table-driven over the data. Every recipe in the library gets walked, and the checks are the same for all of them, so adding a recipe adds tests. The library those tests cover is published at munchable.app/recipes, and the per-page numbers you see there are derived from the same objects the tests read, which is why a recipe cannot pass its tests and still be wrong on the page.

packages/reels having exactly one test is also deliberate rather than neglect. It is a rendering package, and almost nothing about a rendered frame is worth asserting in a unit test. The one thing that is worth asserting is that the illustrated products in the video overlays are built from ingredient ids the engine actually knows, because a drawing with a typo in its data would score differently from the real pack and no visual review would catch it.

And the 186 in packages/rules-engine are the ones that matter most, since that package is what both apps and the 373 public question pages at munchable.app/answers call to get an answer. I will not go into what those tests assert, but the shape is worth stealing: the engine has no network, no database and no clock, so its tests are plain function calls over fixtures: 186 of them in 571 ms, with no setup and no teardown.

Would I do it again

Yes, with one caveat.

What you give up is real: no describe.each, no built-in mocking framework worth the name, no snapshot testing, no ecosystem of plugins, and the watch mode is node --test --watch, which is fine rather than good. If your tests need a browser-like environment or heavy module mocking, this is not the setup.

What you get is that pnpm -r run test has no build step, no config file, no transform cache that can go stale, no framework version that can disagree with your TypeScript version, and a dependency graph where the test tooling is a 0-byte line item in four of the six workspaces. For a repository where the most valuable tests are pure functions over data, that trade is not close.

The practical advice is the one-character one: if you want the built-in runner, write your import specifiers for the runtime, not for the bundler. Extensions in, and the whole thing works with no tooling at all. Leave them out, and you have bought a loader.

Top comments (0)