Jest with TypeScript: Setup and Type-Safe Mocks
AI generated
<T>
type
TypeScript · Jest · Unit Testing · Mocking
Jest with TypeScript: Setup and Type-Safe Mocks
ts-jest, jest.config.ts, and jest.mocked() in practice

Running Jest without a clean TypeScript setup gives up the exact safety net you need most, right where errors are most expensive: in tests and mocks. This article shows how ts-jest and babel-jest differ, how to set up a type-safe jest.config.ts, and how to write mock functions, module mocks, and class mocks with real types instead of any.

13 min. read ts-jest · babel-jest · jest.mock() · jest.mocked() TypeScript 5 · Jest 29 · Node.js

1. Why combine Jest with TypeScript: ts-jest vs. babel-jest

Jest is the most widely used test runner in the JavaScript ecosystem, but it natively understands only JavaScript, not TypeScript syntax. TypeScript projects therefore need a transform layer that converts .ts files into JavaScript before execution. Two established options exist: ts-jest, which uses the real TypeScript compiler and also performs type checking, and babel-jest with @babel/preset-typescript, which only strips type annotations without validating them.

The difference is more than a technical detail: babel-jest transforms every file in isolation and extremely fast, but it never catches type errors, such as a wrongly typed return value or a missing interface field. ts-jest, on the other hand, performs a real type check on every test run and catches exactly those errors, at the cost of noticeably more time on large test suites. The pragmatic solution many teams land on: babel-jest for fast local test runs in watch mode, combined with a separate tsc --noEmit step in the CI pipeline that handles type checking without slowing down local iteration.

2. Configuring Jest for TypeScript projects

When using ts-jest, configuration goes through the preset entry preset: 'ts-jest', which tells Jest to transform .ts and .tsx files via the TypeScript compiler. The isolatedModules option matters a lot here: by default, ts-jest builds a full TypeScript program with cross-file type information, which is noticeably slower on large projects. With isolatedModules: true, each file is compiled independently without knowledge of other files, which speeds up test runs considerably but skips certain cross-file checks, such as const enum or re-exported types.

It's also worth maintaining a dedicated tsconfig.spec.json that extends the base tsconfig.json but applies test-specific adjustments: add @types/jest to the types array so globals like describe and expect are typed, and optionally relax strict compiler flags such as noUnusedLocals, which tend to get in the way in test files with many mock imports. This keeps the production configuration strict while tests stay pragmatic.

3. Setting up a type-safe jest.config.ts

Instead of maintaining jest.config.js or jest.config.json, it's worth writing a jest.config.ts that imports the Config type from the jest package. The editor then validates every field against the actual Jest configuration interface, so a typo in an option name like testEviroment instead of testEnvironment shows up immediately as a compile error, instead of being silently ignored at runtime.


// jest.config.ts: type-safe Jest configuration with autocompletion
import type { Config } from 'jest';

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/__tests__/**/*.test.ts'],
  transform: {
    '^.+\\.tsx?$': ['ts-jest', { isolatedModules: true, tsconfig: 'tsconfig.spec.json' }],
  },
  moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
  collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'],
  coverageThreshold: {
    global: { branches: 80, functions: 80, lines: 80, statements: 80 },
  },
  clearMocks: true,
};

export default config;

The clearMocks: true field deserves particular attention: without it, the state of jest.fn() mocks persists between tests, leading to hard-to-diagnose failures when a mock from an earlier test unintentionally leaks into a later one. Combined with restoreMocks: true, even implementations overridden via jest.spyOn get reset automatically between tests.

4. Typing mock functions: jest.Mock and jest.fn<T>

Without generics, jest.fn() produces a function of type jest.Mock<any, any>, which effectively disables type safety at that exact spot. The explicit form jest.Mock<ReturnType, Args> lets you specify both the return type and the argument tuple, so mocks called incorrectly are flagged at compile time. Since more recent Jest versions, the more idiomatic approach is jest.fn<typeof originalFunction>(), which pulls the entire signature straight from the real function instead of duplicating it by hand.


import { fetchProductPrice } from './pricing';

// Explicitly typed mock function: return type Promise<number>, one string argument
const mockFetchPrice: jest.Mock<Promise<number>, [string]> = jest.fn();

// More idiomatic since Jest 29: derive the type straight from the original function
const typedMock = jest.fn<typeof fetchProductPrice>();

typedMock.mockResolvedValueOnce(49.9);

async function useMock() {
  const price = await typedMock('SKU-1234');
  // price is inferred as number, no any leaking through
  return price.toFixed(2);
}

The benefit shows up mainly with mockResolvedValueOnce and mockReturnValueOnce: if a string is accidentally passed instead of a number, the compiler flags the error immediately, instead of it only surfacing as a failing assertion during the test run. In larger teams especially, this prevents any-typed mocks from silently spreading across the entire test suite and losing their actual regression-catching value.

5. Typing jest.mock() for entire modules

jest.mock('./module') automatically replaces every export of a module with mock functions, but TypeScript doesn't recognize this runtime effect: the imported identifier still keeps its original, non-mocked type. Calling mockResolvedValueOnce on it then commonly triggers the error "Cannot invoke an object which is possibly undefined" or a message that mockResolvedValueOnce doesn't exist on the original type, because TypeScript still sees the import as a real class or function, not as a Jest mock.


import { ProductRepository } from './product-repository';

jest.mock('./product-repository');

// Without jest.mocked(), TypeScript still sees the original type,
// calling mockResolvedValueOnce would otherwise cause a type error
const MockedRepository = jest.mocked(ProductRepository, { shallow: false });

describe('ProductRepository mock', () => {
  it('returns the mocked product list', async () => {
    MockedRepository.prototype.findAll.mockResolvedValueOnce([
      { sku: 'MS-1', name: 'Test Product' },
    ]);

    const repo = new ProductRepository();
    const products = await repo.findAll();

    expect(products).toHaveLength(1);
    expect(MockedRepository.prototype.findAll).toHaveBeenCalledTimes(1);
  });
});

The jest.mocked() helper, built into Jest core since version 27, solves exactly this problem: it takes the original import and returns a version that TypeScript recognizes as a mock, where every function is typed as a jest.Mock and the extra mock methods like mockResolvedValueOnce or mockReturnValue become available, without losing the object structure of the original.

6. Mocking classes and typed class instances

Mocking classes is more complex than mocking plain functions, because both the constructor and the prototype methods need to stay correctly typed. With jest.mock('./repository'), the entire class is automatically mocked, and individual methods are then accessed via jest.mocked(ClassName).prototype.methodName, as shown in the previous code example. For manually created mock objects, for example a test double without a real class import, the utility type jest.Mocked<T> helps: it turns every method of an interface or class into its jest.Mock counterpart and ensures no method gets forgotten.

For tests that only need a subset of a class's methods, a typed factory function that returns Partial<jest.Mocked<T>> works well, completed with an explicit cast to jest.Mocked<T> only at the end of the test file. What matters: the cast should only happen once every method the test actually needs is present, otherwise it hides missing implementations just as much as a blanket as any cast, which it's meant to avoid in the first place.

7. Common pitfalls: partial mocks and jest.mocked()

A frequent pitfall is partial mocks: only some exports of a module should be mocked while the rest keeps the real implementation. This works via a mock factory combined with jest.requireActual, where correct typing requires explicitly intersecting the return type of jest.requireActual<typeof import('./module')>('./module') with the overridden functions, instead of passing the entire return value through unannotated as any.

A second, often overlooked pitfall is the second parameter of jest.mocked(value, options): { shallow: true } mocks only the top level of an object, useful for module namespaces like axios, where only individual methods like get or post need to be recognized as mocks. { shallow: false }, the default, recursively mocks nested objects and class methods too. Choosing the wrong option produces errors like "Property does not exist on type Mock", because TypeScript doesn't recognize nested properties as mocks.

8. Practical example: testing an async function and API call with types

A realistic scenario: a function calls a REST API via axios to fetch a Magento order's status, and needs to cover both the success and the failure path. The import gets automatically mocked via jest.mock('axios'), and jest.mocked(axios, { shallow: true }) ensures the individual HTTP methods like get are recognized as Jest mocks, without deep-mocking the entire axios object.


import axios from 'axios';
import { getOrderStatus } from './order-service';

jest.mock('axios');
const mockedAxios = jest.mocked(axios, { shallow: true });

describe('getOrderStatus', () => {
  it('returns a typed order status from the API', async () => {
    mockedAxios.get.mockResolvedValueOnce({
      data: { orderId: '1001', status: 'shipped' },
    });

    const result = await getOrderStatus('1001');

    expect(result.status).toBe('shipped');
    expect(mockedAxios.get).toHaveBeenCalledWith('/orders/1001');
  });

  it('propagates a typed error on request failure', async () => {
    mockedAxios.get.mockRejectedValueOnce(new Error('Network Error'));

    await expect(getOrderStatus('1001')).rejects.toThrow('Network Error');
  });
});

Crucially, mockedAxios.get is still checked against the real signature of axios.get: a wrongly typed URL or an incorrect response shape gets flagged at compile time. The second test case with mockRejectedValueOnce covers the error path, so both code paths of the function, success and failure, are tested with full type safety, without ever falling back to any.

9. Typed snapshot testing and comparison: any vs. type-safe

Snapshot tests also benefit from type safety when the test data is constructed against the real interface instead of as a loosely typed object literal. If the underlying interface changes, for example a required field gets added, the compiler flags the error immediately in the test code, instead of the snapshot silently locking in stale data that a developer then overwrites unchecked with --ci=false --updateSnapshot.


import { formatInvoice } from './invoice-formatter';
import type { Invoice } from './types';

describe('formatInvoice snapshot', () => {
  it('matches the typed invoice snapshot', () => {
    const invoice: Invoice = {
      id: 'INV-2026-001',
      total: 149.9,
      currency: 'EUR',
      items: [{ sku: 'MS-1', qty: 2, price: 49.9 }],
    };

    // TypeScript enforces the Invoice shape at compile time,
    // the snapshot only checks the serialized output at runtime
    expect(formatInvoice(invoice)).toMatchSnapshot();
  });
});

The table below summarizes the key differences between untyped any mocks and their type-safe counterparts, built up step by step across the previous sections.

Scenario Without type safety (any) Type-safe variant Effect
Creating a mock function jest.fn() without generics jest.fn<typeof fn>() Autocompletion, errors on wrong arguments
Mocking a module jest.mock('./x'), import keeps original type jest.mocked(x) Access to mockResolvedValueOnce without a type error
Return value of a mock mockReturnValue('x' as any) jest.Mock<number, []> Return type is checked against the real signature
Class mock new (MockedClass as any)() jest.mocked(MockedClass, shallow: false) Class method signatures are preserved
Partial mock of a module jest.requireActual(...) as any jest.requireActual<typeof import('./x')>('./x') Avoids silent runtime errors from missing exports
API response mock axios.get = jest.fn() (type error) jest.mocked(axios).get.mockResolvedValueOnce(...) Only compiles with the correct response shape

In practice, the extra effort for type-safe mocks pays off quickly: errors that would otherwise only show up as flaky test failures become visible right in the editor when the file is saved. In larger codebases with many module and class mocks especially, this stops any from silently spreading across the entire test suite and undermining the very type safety the production code is meant to guarantee.

Mironsoft

TypeScript testing, Jest setup, and type-safe mocks for Magento and headless projects

Ready for a Jest setup with real type safety?

We set up Jest and TypeScript for your project, migrate existing any mocks to type-safe variants, and build resilient test suites for frontend tooling, build scripts, and headless integrations.

Jest configuration

Setting up ts-jest or babel-jest to match your project's scale

Type-safe mocks

Replacing any mocks with jest.Mock, jest.fn<T>, and jest.mocked()

CI integration

Wiring test suites and type checks cleanly into the CI/CD pipeline

10. Summary

Jest with TypeScript only pays off fully when not just the production code but also the mocks and test data are cleanly typed. The choice between ts-jest and babel-jest is a tradeoff between real type checking and transform speed, one that combines well with isolatedModules and a separate tsc --noEmit step in CI. A type-safe jest.config.ts catches configuration mistakes right when the file is saved, and jest.fn<typeof fn>() together with jest.Mock<ReturnType, Args> ensure mock functions keep the same signature as the original.

The biggest practical lever is jest.mocked(): the helper solves the common problem that TypeScript still sees the original type of an import after jest.mock(), making errors like "Cannot invoke an object which is possibly undefined" unnecessary. Combined with typed class mocks, partial mocks via jest.requireActual, and typed snapshot fixtures, the result is a test suite that reliably catches regressions instead of just papering over syntax errors.

Jest with TypeScript, The Essentials at a Glance

ts-jest vs. babel-jest

ts-jest checks types but is slower. babel-jest only transforms, so it's faster. isolatedModules as a middle ground.

jest.config.ts

The Config type from jest validates configuration fields instead of allowing silent typos.

jest.fn<T> and jest.Mock

Generics enforce correct return values and arguments instead of any-typed mock functions.

jest.mocked()

Reliably resolves "Cannot invoke an object which is possibly undefined" on module and class mocks.

11. FAQ: Jest with TypeScript

1Should I use ts-jest or babel-jest for TypeScript tests?
ts-jest checks types but is slower on large suites. babel-jest only transforms and is faster. Combining it with tsc --noEmit in CI is common.
2What does the isolatedModules option do in ts-jest?
Compiles each file independently without knowledge of other files, much faster, but skips certain cross-file checks.
3Why should I use jest.config.ts instead of jest.config.js?
The Config type from jest validates every field in the editor, typos are caught as compile errors instead of being ignored at runtime.
4How do I correctly type a mock function with jest.fn?
Best with jest.fn(), which derives the signature from the real function. Alternatively specify jest.Mock explicitly.
5What does Cannot invoke an object which is possibly undefined mean with jest.mock()?
TypeScript still sees the import's original type after jest.mock(). jest.mocked() converts the import into a version recognized as a type-safe mock.
6How do I mock an entire class including prototype methods with types?
jest.mock('./class') plus jest.mocked(ClassName, { shallow: false }) for class and prototype methods. For manual objects, jest.Mocked works well.
7What is a partial mock and how do I type it correctly?
Mocks only individual exports of a module. Type-safe via jest.requireActual('./module'), intersected with the overridden functions.
8When should I use shallow: true with jest.mocked()?
For module namespaces like axios with individual methods to mock. shallow: false, the default, recursively mocks nested objects and class methods.
9How do I test an asynchronous API function with types in Jest?
Mock the module via jest.mock(), make it type-safe with jest.mocked(). mockResolvedValueOnce for the success case, mockRejectedValueOnce for the failure case.
10Do snapshot tests benefit from TypeScript typing?
Yes. Building test data against the real interface ensures interface changes are flagged immediately in the test code instead of in stale snapshots.