Tests written in TypeScript get the same autocompletion and type checking as the code they exercise, which removes a whole class of "the test passes but calls the API wrong" problems. The two mainstream runners are Vitest, which understands TypeScript natively and is the default in Vite-based projects, and Jest, which needs a transformer. This lesson sets up both, shows typed mocks, and introduces type-level tests that fail when a signature changes.
npm install --save-dev vitestVitest transpiles .ts files with esbuild, so no extra configuration is required. A config file is optional but useful for globals and coverage:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
coverage: { provider: "v8", reporter: ["text", "html"] },
},
});Add scripts and run:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"typecheck": "tsc --noEmit"
}
}vitest starts watch mode and re-runs affected tests on save; vitest run executes once for CI. Coverage needs npm install --save-dev @vitest/coverage-v8.
One important detail: esbuild strips types, it does not check them. A test that calls total("oops") still runs (and fails at runtime) unless you also run tsc --noEmit. Keep typecheck as a separate CI step, or enable test.typecheck in the config so Vitest reports type errors from *.test-d.ts files.
vi.fn() accepts a function type, so mocks are checked like real functions:
import { vi, it, expect } from "vitest";
import type { User } from "./types.js";
interface UserRepo { find(id: number): Promise<User | undefined> }
it("returns 404 when the user is missing", async () => {
const find = vi.fn<UserRepo["find"]>().mockResolvedValue(undefined);
const repo: UserRepo = { find };
const result = await getUserHandler(repo, 42);
expect(find).toHaveBeenCalledWith(42);
expect(result.status).toBe(404);
});vi.fn<UserRepo["find"]>() derives the mock's signature from the interface, so mockResolvedValue("nope") is a type error. vi.spyOn(object, "method") wraps an existing method with the same guarantees, and vi.mock("./module.js") replaces a whole module; pair it with vi.mocked(importedFn) to get typed access to the auto-mock.
Some code exists mostly for its types: utility types, builders with fluent generics, schema inference. Vitest's expectTypeOf and assertType make assertions about types that fail at type-check time:
import { expectTypeOf, assertType } from "vitest";
import type { DeepPartial } from "./types.js";
expectTypeOf<DeepPartial<{ a: { b: number } }>>().toEqualTypeOf<{ a?: { b?: number } }>();
expectTypeOf(total).parameter(0).toEqualTypeOf<Item[]>();
// @ts-expect-error qty must be a number
assertType<Item>({ sku: "A", price: 1, qty: "2" });// @ts-expect-error is the standard way to assert that something must not compile; if the line stops erroring, TypeScript reports the unused directive, so your test catches accidental loosening of a type.
Jest does not understand TypeScript out of the box. The most common transformer is ts-jest, which also type-checks each test file:
npm install --save-dev jest ts-jest @types/jest
npx ts-jest config:init # creates jest.config.js with preset "ts-jest"// jest.config.js
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["**/*.test.ts"],
};Test files use the same describe / it / expect globals, typed through @types/jest. For faster runs without type checking, swap the transformer for @swc/jest and rely on tsc --noEmit separately. Jest's ESM support is still experimental, so projects using "type": "module" usually find Vitest simpler.
| | Vitest | Jest |
|---|---|---|
| TypeScript support | Built in (esbuild) | Via ts-jest or @swc/jest |
| Type checking during tests | Optional (typecheck mode) | Yes with ts-jest, no with swc |
| ESM support | Native | Experimental |
| Config file | vitest.config.ts | jest.config.js |
| Mock API | vi.fn, vi.mock, vi.spyOn | jest.fn, jest.mock, jest.spyOn |
*.test.ts next to the code they test; keep type-only tests in *.test-d.ts.supertest against the app object without starting a server.tsc --noEmit in CI even when tests pass; a green test run does not prove the types are sound.Why can a Vitest test that passes a wrong argument type still execute?
tsc --noEmit to CI because esbuild does not type-check.ts-jest (type-checked) or @swc/jest (fast) and has weaker ESM support.vi.fn<Signature>(), vi.spyOn and vi.mocked give mocks the same types as the real functions.expectTypeOf, assertType and // @ts-expect-error turn type contracts into tests.supertest.Next lesson: ESLint and Prettier Setup — enforce consistent style and catch type-aware mistakes automatically.