Testing TypeScript with Vitest and Jest

Intermediate
12 min

Testing TypeScript with Vitest and Jest

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.

Setting Up Vitest

bash
npm install --save-dev vitest

Vitest transpiles .ts files with esbuild, so no extra configuration is required. A config file is optional but useful for globals and coverage:

typescript
// 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:

json
{ "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.

Typed Mocks and Spies

vi.fn() accepts a function type, so mocks are checked like real functions:

typescript
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.

Type-Level Tests

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:

typescript
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.

Setting Up Jest

Jest does not understand TypeScript out of the box. The most common transformer is ts-jest, which also type-checks each test file:

bash
npm install --save-dev jest ts-jest @types/jest npx ts-jest config:init # creates jest.config.js with preset "ts-jest"
javascript
// 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 |

Tips

  • Name files *.test.ts next to the code they test; keep type-only tests in *.test-d.ts.
  • Test pure functions directly; test Express routes through supertest against the app object without starting a server.
  • Run tsc --noEmit in CI even when tests pass; a green test run does not prove the types are sound.
Quick Quiz
Question 1 of 3

Why can a Vitest test that passes a wrong argument type still execute?

Key Takeaways

  • Vitest runs TypeScript natively via esbuild; add tsc --noEmit to CI because esbuild does not type-check.
  • Jest needs 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.
  • Keep unit tests on pure functions and services; test HTTP layers through the app object with supertest.

Next lesson: ESLint and Prettier Setup — enforce consistent style and catch type-aware mistakes automatically.

Testing TypeScript with Vitest and Jest - TypeScript | CodeYourCraft | CodeYourCraft