TypeScript 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"
}
}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");
});