Updated Nov 5, 20267 min read

Node.js Unit Testing in 2026: node:test, Vitest and Jest, With a Real Database

How to write unit tests in Node.js with the built-in test runner, when Vitest or Jest is the better pick, how far mocking should go, and how to test Express routes, and how to test database code against a real Postgres database.

Unit testing in Node.js has gotten simpler: the runtime ships its own test runner, Vitest handles TypeScript and ESM without configuration, and the choice of tool matters less than what you choose to test and what you mock. Most of the bugs that reach production in a Node.js API sit in the code that talks to the database, which is exactly the code that heavily mocked unit tests do not exercise.

This guide covers the built-in node:test runner, how Vitest and Jest compare, how to test an Express route handler with Vitest and Supertest, where mocking helps and where it hides bugs, and how to test database code against a real Postgres database.

At a glance

ToolBest forSetup
node:testPlain JavaScript, libraries, zero dependenciesNone, run node --test
VitestTypeScript and ESM projects, Vite usersOne dev dependency
JestLarge existing suites and its plugin ecosystemTransform setup for TypeScript and ESM

The built-in runner: node:test

The node:test module is stable and ships with every supported Node.js release, together with node:assert. A small pricing function and its tests look like this:

// pricing.js export function applyDiscount(cents, code) { if (!Number.isInteger(cents) || cents < 0) { throw new RangeError("cents must be a non-negative integer"); } if (code === "LAUNCH20") return Math.round(cents * 0.8); return cents; }
// pricing.test.js import { test } from "node:test"; import assert from "node:assert/strict"; import { applyDiscount } from "./pricing.js"; test("applies the launch discount", () => { assert.equal(applyDiscount(1000, "LAUNCH20"), 800); }); test("ignores unknown codes", () => { assert.equal(applyDiscount(1000, "NOPE"), 1000); }); test("rejects negative amounts", () => { assert.throws(() => applyDiscount(-1, "LAUNCH20"), RangeError); });

With "type": "module" in package.json, node --test finds files that match the default test patterns, such as *.test.js, and runs them. The runner also includes mock.fn() and mock.method() for spies, and code coverage with the --experimental-test-coverage flag.

Vitest and Jest

Vitest runs TypeScript and ES modules directly, has a watch mode that reruns only affected tests, and exposes describe, test, expect and vi.fn() in a shape close to Jest, so most Jest tests port with import changes. Jest is still widely used and has the largest set of plugins and matchers, though TypeScript needs a transform such as ts-jest or Babel and native ESM support needs an experimental Node.js flag, as described in the Jest ESM documentation.

For a new TypeScript backend, Vitest removes the most setup. For a JavaScript library with no build step, node:test keeps the dependency list at zero. Moving a large Jest suite only to change runners rarely pays back the time.

Test an Express route with Vitest and Supertest

Most Node.js unit tests in an API project test route handlers. Build the app in a function that takes its dependencies, so a test can create an app with an in-memory store and send requests to it with Supertest without opening a port:

// app.ts import express from "express"; export interface Coupon { code: string; percentOff: number; } export interface CouponStore { get(code: string): Promise<Coupon | null>; } export function createApp(store: CouponStore) { const app = express(); app.use(express.json()); app.get("/coupons/:code", async (req, res) => { const coupon = await store.get(req.params.code); if (!coupon) { res.status(404).json({ error: "coupon not found" }); return; } res.json(coupon); }); return app; }
// app.test.ts import { describe, expect, test, vi } from "vitest"; import request from "supertest"; import { createApp, type CouponStore } from "./app"; describe("GET /coupons/:code", () => { test("returns a stored coupon", async () => { const store: CouponStore = { get: vi.fn().mockResolvedValue({ code: "SPRING", percentOff: 10 }), }; const res = await request(createApp(store)).get("/coupons/SPRING"); expect(res.status).toBe(200); expect(res.body).toEqual({ code: "SPRING", percentOff: 10 }); expect(store.get).toHaveBeenCalledWith("SPRING"); }); test("returns 404 for an unknown code", async () => { const store: CouponStore = { get: vi.fn().mockResolvedValue(null) }; const res = await request(createApp(store)).get("/coupons/NOPE"); expect(res.status).toBe(404); }); });

Install the tools with npm install --save-dev vitest supertest @types/supertest and run the tests with npx vitest. The same tests run under Jest with jest.fn() in place of vi.fn() once a TypeScript transform is configured. These tests cover routing, status codes and response shapes. The store that talks to Postgres needs its own tests against a real database, covered next.

What to mock and what not to mock

Mocks are useful at the edges you do not control: a payment provider, an email API, the clock. Replacing your own database layer with a mock makes tests fast and wrong in the same way, since the mock returns whatever the test author expected, including results from SQL that would fail against Postgres. A unique constraint, a missing column after a migration and a query that returns two rows instead of one all pass against a mock.

Pure functions such as applyDiscount get plain unit tests with no setup. Endpoints and anything that reads or writes data get tests that call the code against a real, disposable database, which is slower per test and catches the class of bug that otherwise shows up in production. Keep each test independent by creating the rows it needs, with unique keys, rather than relying on rows left behind by another test. For plain Node.js projects, a Postgres container started in CI or with Testcontainers, with migrations applied before the suite runs, gives those tests a disposable database.

Endpoint tests against a real database with Encore

Encore

Encore is a tool that works alongside your framework for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. Endpoints are typed functions that a test imports and calls, and encore test sets up the infrastructure the code declares, including a separate test database with the migrations applied, before your test runner starts.

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create shop --example=ts/empty$ cd shop && npm install --save-dev vitest

Configure Vitest as the testing docs describe, with a vite.config.ts that aliases ~encore to ./encore.gen and "test": "vitest" in package.json. With coupons/encore.service.ts exporting new Service("coupons"), the service declares its database and two endpoints:

// coupons/coupons.ts import { api, APIError } from "encore.dev/api"; import { SQLDatabase } from "encore.dev/storage/sqldb"; const db = new SQLDatabase("coupons", { migrations: "./migrations" }); interface Coupon { code: string; percentOff: number; } export const create = api( { expose: true, method: "POST", path: "/coupons" }, async (c: Coupon): Promise<Coupon> => { await db.exec` INSERT INTO coupons (code, percent_off) VALUES (${c.code}, ${c.percentOff}) `; return c; }, ); export const get = api( { expose: true, method: "GET", path: "/coupons/:code" }, async ({ code }: { code: string }): Promise<Coupon> => { const row = await db.queryRow<{ code: string; percent_off: number }>` SELECT code, percent_off FROM coupons WHERE code = ${code} `; if (!row) throw APIError.notFound("coupon not found"); return { code: row.code, percentOff: row.percent_off }; }, );

The migration in coupons/migrations/1_create_coupons.up.sql gives the code a uniqueness rule a mock would never enforce:

CREATE TABLE coupons ( code TEXT PRIMARY KEY, percent_off INT NOT NULL CHECK (percent_off BETWEEN 1 AND 100) );

The test calls the endpoints directly, and the duplicate insert fails on the primary key exactly as it would in production:

// coupons/coupons.test.ts import { describe, expect, test } from "vitest"; import { create, get } from "./coupons"; describe("coupons", () => { test("stores and reads a coupon", async () => { const code = `SPRING-${Date.now()}`; await create({ code, percentOff: 10 }); const c = await get({ code }); expect(c.percentOff).toBe(10); }); test("rejects a duplicate code", async () => { const code = `DUP-${Date.now()}`; await create({ code, percentOff: 5 }); await expect(create({ code, percentOff: 5 })).rejects.toThrow(); }); });

Run the suite with encore test. Encore configures the test database for speed by skipping fsync and using an in-memory filesystem, which keeps database tests close to the speed of unit tests. The same command runs in CI, and on deploy the database becomes RDS on AWS or Cloud SQL on GCP, with no test-only configuration to keep in sync.

Best fit: TypeScript teams building Node.js APIs on Postgres who want endpoint tests that run against a real database locally, in CI and on deploy to their own AWS or GCP account.

Frequently asked questions

What is the best way to unit test in Node.js?

For plain JavaScript, the built-in node:test runner with node:assert needs no dependencies and runs with node --test. For TypeScript projects, Vitest is the most common pick in 2026, with native ESM and TypeScript support and a Jest-compatible API. Test pure logic with unit tests and test code that touches the database against a real database.

Should I use Vitest or Jest for a new Node.js project?

Vitest for most new TypeScript projects, since it handles ESM and TypeScript without a separate transform setup and its API is close enough to Jest that existing knowledge carries over. Jest remains a reasonable choice for large existing suites that already depend on its ecosystem.

Should I mock the database in Node.js unit tests?

Mock things you do not control, such as third-party HTTP APIs, and avoid mocking your own database. A mocked query layer passes while the SQL is wrong, and running tests against a disposable Postgres database catches constraint errors, migrations and query bugs that mocks hide.

How do I test an Encore TypeScript endpoint with a database?

Add a vite.config.ts that aliases ~encore to ./encore.gen, set the test script in package.json to vitest, import the endpoint in a test file and call it like a function. Running encore test sets up a separate test database with your migrations applied before Vitest runs.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders