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.
Table of Contents
- 1. Why combine Jest with TypeScript: ts-jest vs. babel-jest
- 2. Configuring Jest for TypeScript projects
- 3. Setting up a type-safe jest.config.ts
- 4. Typing mock functions: jest.Mock and jest.fn<T>
- 5. Typing jest.mock() for entire modules
- 6. Mocking classes and typed class instances
- 7. Common pitfalls: partial mocks and jest.mocked()
- 8. Practical example: testing an async function and API call with types
- 9. Typed snapshot testing and comparison: any vs. type-safe
- 10. Summary
- 11. FAQ
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.