The page's advice is to add a separate tsc --noEmit step, and it's the right advice, with one catch. tsc --noEmit checks only what your tsconfig.json includes, and plenty of configs list just src. Test files in a tests/ folder are then type-checked by nothing: the transpile-only runner strips their types, and the type-check step never opens them. A test calling a function with the wrong argument type passes both steps.
{
"compilerOptions": {
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": ["node"],
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src"]
}import { test } from "node:test";
import assert from "node:assert/strict";
import { displayName } from "../src/user.ts";
test("formats the display name", () => {
assert.equal(displayName("7"), "user-7"); // a string where a number belongs
});$ node --experimental-strip-types --test tests/user.test.ts
ok 1 - formats the display name
# pass 1
# fail 0
$ tsc --noEmitTypeScript itself has no test runner — Jest and the newer Vitest (built on Vite, popular for its speed) are the dominant choices in the ecosystem. Vitest always runs .ts test files by transpiling them on the fly (via esbuild) with no separate tsc build step; Jest depends on which transform it's configured with — babel-jest and ts-jest's isolatedModules mode also just transpile, but ts-jest's default mode actually type-checks as it compiles, so a real type error there does fail the run. Where transpile-only applies, there's a crucial, often-missed detail: transpiling and type-checking are separate concerns, so a test suite can run and pass completely while the same code has real type errors that a full tsc run would catch — running tsc --noEmit as a separate CI step is the common way to close that gap regardless of which mode you're in.
A basic test with Vitest
Vitest's API closely mirrors Jest's, and Vitest runs .ts files directly — no separate compile step needed before running tests. (This page uses Vitest for its examples; Jest can be set up to behave the same way, but only with a transpile-only transform — its own default, ts-jest, type-checks as it goes.)
import { describe, it, expect } from "vitest";
import { add } from "./math";
describe("add", () => {
it("adds two numbers", () => {
expect(add(2, 3)).toBe(5);
});
});Type-checking is a SEPARATE step from running tests
This is the detail newcomers most often miss: with Vitest (and with Jest configured for speed via babel-jest or ts-jest's isolatedModules), a test suite can pass in full while the surrounding code has genuine type errors, since the runner only transpiles (strips types) rather than fully type-checking each file in the context of the whole project. Running tsc --noEmit as its own CI step closes this gap — and is worth doing even with ts-jest's type-checking default, since ts-jest only sees the files it actually compiles, not necessarily your whole project.
{
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit",
"ci": "npm run typecheck && npm run test"
}
}Both steps "passed" there: the runner because type-stripping never looks at types, and tsc --noEmit (exit code 0, no output) because tests/ was never part of the program. (Verified with Node 22's built-in test runner and --experimental-strip-types, which strips types the same transpile-only way described above; Vitest itself isn't installed in this environment.) Make sure the type-check step covers the tests: add them to include, or give tests their own tsconfig that the CI step runs:
{
"compilerOptions": {
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": ["node"],
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src", "tests"]
}$ tsc --noEmit
tests/user.test.ts(6,30): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.Typing mocks and test doubles
Type-safe mocking keeps a mocked dependency's shape in sync with the real one — if the real function's signature changes, a typed mock that no longer matches becomes a visible compile error, rather than a silent runtime mismatch discovered only when the test happens to fail.
import { vi, it, expect } from "vitest";
interface UserApi {
getUser(id: number): Promise<{ name: string }>;
}
const mockApi: UserApi = {
getUser: vi.fn().mockResolvedValue({ name: "Alice" }), // must match UserApi's shape
};
it("fetches a user", async () => {
const user = await mockApi.getUser(1);
expect(user.name).toBe("Alice");
});