TypeScript Testing Guide: ts-jest, Vitest, and tsconfig Setup
TypeScript testing requires one extra layer beyond JavaScript testing: your test runner must understand TypeScript. The two main approaches are ts-jest (for Jest-based projects) and Vitest (for Vite-based projects). This guide covers setting up both, configuring tsconfig correctly for tests, writing type-safe tests, and avoiding the most common TypeScript testing pitfalls.
Key Takeaways
ts-jest transforms TypeScript on the fly. It uses the TypeScript compiler under the hood, so your tests run with full type checking. Configure it via preset: 'ts-jest' in jest.config.ts.
Vitest is faster for Vite projects. It reuses your vite.config.ts and supports TypeScript natively without additional transformers. Add test: { globals: true } and you're running.
Use a separate tsconfig.test.json. Tests often need "esModuleInterop": true, "resolveJsonModule": true, and relaxed "strict" settings that your production code shouldn't inherit.
jest.fn<ReturnType, Args>() gives you typed mocks. Passing generic parameters prevents returning the wrong type from a mock function — the compiler catches it at write time.
Avoid ts-ignore in tests. If you need to suppress TypeScript errors in a test, the test is probably asserting the wrong thing. Fix the type, not the annotation.
Why TypeScript Testing Is Different
JavaScript tests work with any test runner out of the box. TypeScript tests do not — Node.js cannot execute .ts files directly. You need either a compile step or a transformer that handles TypeScript at runtime.
The two most common solutions:
- ts-jest — a Jest transformer that compiles TypeScript on the fly using
tsc - Vitest — a test runner built for Vite that understands TypeScript natively
Which one to use depends on your project setup. If you're using Create React App, Next.js, or anything Jest-based: use ts-jest. If you're using Vite or starting a new project: use Vitest.
Setting Up ts-jest
Install the dependencies:
npm install --save-dev jest ts-jest @types/jest typescriptCreate jest.config.ts:
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts', '**/*.spec.ts'],
};
export default config;This is the minimal configuration. The preset: 'ts-jest' line does most of the work — it registers the TypeScript transformer for .ts and .tsx files.
tsconfig for Tests
Create a separate tsconfig.test.json at the project root:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"esModuleInterop": true,
"resolveJsonModule": true,
"module": "CommonJS",
"target": "ES2020",
"types": ["jest", "node"]
},
"include": ["src/**/*.ts", "src/**/*.test.ts"]
}Point ts-jest to this config:
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
globals: {
'ts-jest': {
tsconfig: 'tsconfig.test.json',
},
},
};The key reason for a separate test tsconfig: tests often need "module": "CommonJS" (Jest's module system) even if your production code uses ES modules. Mixing these without a separate config causes hard-to-debug import errors.
Setting Up Vitest
Install:
npm install --save-dev vitest @vitest/uiIn vite.config.ts, add the test block:
import { defineConfig } from 'vite';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['**/*.test.ts', '**/*.spec.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
},
},
});With globals: true, you can use describe, it, expect without importing them — same as Jest. Without it, you must import them explicitly:
import { describe, it, expect } from 'vitest';Explicit imports are safer for large projects because they make it obvious which test runner you're using.
Writing TypeScript Tests
A basic test file:
// src/math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function divide(a: number, b: number): number {
if (b === 0) throw new Error('Division by zero');
return a / b;
}// src/math.test.ts
import { add, divide } from './math';
describe('add', () => {
it('returns the sum of two numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('handles negative numbers', () => {
expect(add(-1, 1)).toBe(0);
});
});
describe('divide', () => {
it('divides two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
it('throws on division by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
});TypeScript catches type errors at write time. If you accidentally pass a string to add, the compiler warns before the test ever runs.
Type-Safe Mocks
This is where TypeScript testing provides the most value. Untyped mocks can return anything — typed mocks are constrained to match the real interface.
import { jest } from '@jest/globals';
// Without types (unsafe — can return wrong type)
const mockFetch = jest.fn();
// With types (safe — return type must match)
const mockFetch = jest.fn<() => Promise<Response>>();
// Inline return value
const mockGetUser = jest.fn<(id: string) => Promise<User>>()
.mockResolvedValue({ id: '1', name: 'Alice', email: 'alice@example.com' });For mocking entire modules, use jest.mock with type assertions:
import type { UserService } from './user-service';
jest.mock('./user-service');
const MockUserService = UserService as jest.MockedClass<typeof UserService>;
MockUserService.prototype.getUser.mockResolvedValue({
id: '1',
name: 'Alice',
email: 'alice@example.com',
});In Vitest, the pattern is similar using vi.fn() and vi.mock().
Async Testing
TypeScript async tests look identical to JavaScript ones, but the types help catch mistakes:
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('User not found');
return response.json();
}
it('fetches a user', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: '1', name: 'Alice' }),
} as Response);
const user = await fetchUser('1');
expect(user.name).toBe('Alice');
});Always await async assertions. A common mistake is forgetting to await expect(promise).resolves.toBe(...):
// Wrong — test passes regardless of result
expect(fetchUser('1')).resolves.toBe('Alice');
// Correct
await expect(fetchUser('1')).resolves.toMatchObject({ name: 'Alice' });Running Tests
With ts-jest:
npx jest # run all tests
npx jest --watch <span class="hljs-comment"># watch mode
npx jest --coverage <span class="hljs-comment"># with coverage
npx jest src/math.test.ts <span class="hljs-comment"># single fileWith Vitest:
npx vitest # watch mode by default
npx vitest run <span class="hljs-comment"># single run (for CI)
npx vitest --coverage <span class="hljs-comment"># with coverage
npx vitest --ui <span class="hljs-comment"># browser UICI Integration
Both runners exit with a non-zero code on failure, making them compatible with any CI system.
GitHub Actions example:
- name: Run tests
run: npx jest --ci --coverage --coverageReporters=lcov
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.infoThe --ci flag disables interactive mode and treats missing snapshots as failures — always use it in CI.
Common TypeScript Testing Mistakes
Using any in test expectations. expect(result as any).toBe(...) defeats the purpose of typed tests. Use proper type narrowing instead.
Not isolating test types from production types. If your test imports a type that depends on a third-party package, the test will fail to compile if the package isn't installed. Use typeof imports or local interface definitions for test doubles.
Forgetting isolatedModules: true with ts-jest. For large projects, this speeds up compilation significantly by skipping cross-file type checking during transformation.
globals: {
'ts-jest': {
isolatedModules: true,
},
},This disables some cross-file type checks in tests, which is usually acceptable since type checking still runs separately via tsc --noEmit.
Connecting Tests to Monitoring
TypeScript unit tests verify your logic in isolation. They don't catch what happens in production — expired auth, third-party API failures, or performance regressions under real load.
HelpMeTest runs Robot Framework tests against your live application 24/7. You write tests in plain English, the platform handles scheduling, retries, and alerting. It pairs well with a TypeScript unit test suite: unit tests guard the logic, HelpMeTest guards the running system.
The free tier includes 10 tests with 5-minute monitoring intervals — no credit card required.