Vite is the only bundler I use. Vitest is the only testing framework I use. Evan You, the creator of these tools, is truly a blessing to the JavaScript™ community. You may know him for the Vue framework, but in my opinion, his Vite-branded projects are much more impactful.
For me, using these tools is a no-brainer: I don’t need to install webpack to bundle, babel to transpile TypeScript, and jest to test… Then, to make sure these tools work together, I have to create a million configs and use other tools to make them cooperate. It’s a vicious cycle of “another npm package will fix it.”
Run vite and the web app is served in watch mode. Run vitest and all tests run in watch mode. Want code coverage? Run vitest run --coverage and it just works, again.
However, at times vite and vitest seem like overkill. It’s like bringing a bazooka to a knife fight. In this article, I will show that only 3 packages are needed to develop TypeScript libraries: typescript, tsx, and @types/node.
The code accompanying this article is on GitHub.
Aha Moment
After setting up yet another TypeScript library, I noticed that node:test exports functions such as describe, test, and it. The node:assert package looks like something I can use for testing…
Testing API is stable since v20.0.0. I am using Node.js 24 if you want to follow along.
Test With Node.js
It’s easy. Use tsx to let node run TypeScript directly. The command to run tests is node --import tsx --test. Want tests to rerun on code change? Add the --watch flag. Need test coverage? Add --experimental-test-coverage.
Using node:test and node:assert for testing is quite straightforward
export function add(a: number, b: number): number {
return a + b;
}
export function throwError(message: string): never {
throw new Error(message);
}import { describe, it } from "node:test";
import assert from "node:assert";
import { add, throwError } from "./example.js";
it("can sum numbers", () => {
const actual = add(1, 2);
const expected = 3;
assert.strictEqual(actual, expected);
});
it("can handle errors", () => {
// by error type (Error is of type ErrorConstructor)
assert.throws(() => throwError("test error"), Error);
// with message assertion
assert.throws(() => throwError("test error"), Error, "test error");
});
// Bonus! Golang inspired
describe("table tests", () => {
const testCases = [
{ name: "positive numbers", a: 1, b: 2, expected: 3 },
{ name: "double infinity", a: Infinity, b: Infinity, expected: Infinity },
{ name: "subtract self", a: -Infinity, b: Infinity, expected: NaN },
{ name: "IEEE 754 :)", a: 0.1, b: 0.2, expected: 0.30000000000000004 },
];
testCases.forEach(({ name, a, b, expected }) => {
it(name, () => {
const result = add(a, b);
assert.strictEqual(
result,
expected,
`Expected add(${a}, ${b}) to equal ${expected}, got ${result}`,
);
});
});
});
The output of node --import tsx --test is
✔ can sum numbers (0.59279ms)
✔ can handle errors (0.325195ms)
▶ table tests
✔ positive numbers (0.196521ms)
✔ double infinity (0.108285ms)
✔ subtract self (0.120578ms)
✔ IEEE 754 :) (0.078078ms)
✔ table tests (0.822425ms)
ℹ tests 6
ℹ suites 1
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 160.479201What About Mocking?
Certainly! It is supported :) Here is a snippet on how to mock functions and time.
import { it, mock } from "node:test";
import assert from "node:assert";
function functionRunner(fn: (arg: string, date: Date) => void) {
const now = new Date();
return {
run: (arg: string) => fn(arg, now),
};
}
it("can mock functions", () => {
const mockFn = mock.fn();
mock.timers.enable({ now: new Date("2000-01-01T00:02:02Z"), apis: ["Date"] });
const runner = functionRunner(mockFn);
runner.run("test");
assert.strictEqual(mockFn.mock.calls.length, 1);
assert.deepStrictEqual(mockFn.mock.calls[0]?.arguments, [
"test",
new Date("2000-01-01T00:02:02Z"),
]);
});Vitest embeds Sinon.JS to do mocking, similarly to how it uses Jest under the hood for testing. Node.js owns its testing stack instead of gluing other code together.
Build With tsc
Building the code is as simple as running tsc.
I like to have my tests alongside the code, and I strongly suggest not to publish transpiled tests. To achieve this, create a tsconfig.build.json file that references the base tsconfig.json but excludes files matching the test pattern **/*.test.ts.
What Are The Downsides?
If someone tells you that the new solution has no downsides, they are probably scamming you. There are a few things I miss from Vitest.
Inline Snapshots
Inline snapshots are my favorite way to test complicated data structures. The first advantage is that they can be generated by the test runner. The second benefit is that assertion is done with strings. This decouples implementation from testing.
expect(onDataMock.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"id": "1",
},
],
Array [
Object {
"id": "2",
},
],
]
`);Flexible Environment
If you want to run the tests outside of Node.js, Vitest is the way to go. Vite supports environments such as jsdom, happy-dom, and node. It can also run tests in real browsers by using playwright.
Type Testing
This is a little-known Vitest feature, but it’s great for type-centric libraries.
expectTypeOf({ docId: '123' }).toEqualTypeOf<{ docId: string }>();Let’s Wrap It Up
When running npm install, only 8 packages are added.
added 8 packages, and audited 9 packages in 532ms
2 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilitiesOther notable benefits are:
- No configuration files.
- It just works.
- Stable Node.js APIs have very strong backward compatibility.
The proper viteless library setup in 2026 looks like the following:
{
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"src"
],
"scripts": {
"dev": "node --import tsx --test --watch",
"test": "node --import tsx --test",
"coverage": "node --import tsx --test --experimental-test-coverage",
"build": "rm -rf ./dist && tsc -p tsconfig.build.json",
"prepublishOnly": "npm run test && npm run build"
},
"dependencies": {},
"devDependencies": {
"typescript": "^5.9.3",
"@types/node": "^25.2.1",
"tsx": "^4.21.0"
}
}{
"extends": "./tsconfig",
"compilterOptions": {
"rootDir": "./src",
"outDir": "./dist",
"sourceMap": true,
"declaration": true,
"declarationMap": true,
}
"exclude": ["**/*.test.ts"],
}