Vitest TypeScript: Strongly Typed Applications
Testing is often treated as a second-class citizen in the development workflow, but when combined with TypeScript, it becomes one of the most powerful tools for building reliable, maintainable applications. Vitest, the modern test runner built by the Vite team, offers first-class TypeScript support out of the box. In this tutorial, we'll explore how to leverage Vitest and TypeScript together to build strongly typed applications where your tests, mocks, and assertions are all fully type-checked.
What Is Vitest?
Vitest is a blazing-fast unit test framework designed specifically for Vite-powered projects. It shares the same configuration pipeline as Vite, which means your transforms, plugins, and aliases work identically in tests as they do in production. Because Vitest is written in TypeScript and runs through esbuild, it can execute .ts test files directly without any additional compilation step.
Unlike Jest, which requires ts-jest or Babel presets to handle TypeScript, Vitest understands TypeScript natively. This eliminates an entire class of configuration headaches and ensures that the types you write in your source files flow seamlessly into your test files.
Why Strong Typing in Tests Matters
Many developers write strongly typed application code but abandon types in their tests. This is a mistake. Strongly typed tests provide several concrete benefits:
- Refactor safety: When you rename a function or change its signature, your tests immediately flag every call site that needs updating.
- Self-documenting: Type annotations in mocks and fixtures communicate the expected shape of data to future readers.
- Catch regressions early: Type errors surface at compile time rather than at runtime during CI.
- Better IDE support: Autocompletion and inline documentation work in test files just as they do in source files.
Setting Up Vitest with TypeScript
Let's start by initializing a project with Vitest and TypeScript. First, install the necessary dependencies:
npm install -D vitest typescript @vitest/coverage-v8
npm install -D @types/node
Next, create a tsconfig.json file that includes your test files in the compilation context:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["vitest/globals", "node"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "tests"]
}
The key entry here is "types": ["vitest/globals"], which tells TypeScript about Vitest's global APIs like describe, it, and expect. If you prefer not to use globals, you can import them explicitly from vitest instead.
Create a vitest.config.ts file to configure the test runner:
import { defineConfig } from 'vitest/config'
import path from 'node:path'
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
},
},
})
Add a script to your package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
Writing Your First Typed Test
Let's create a simple utility function and a strongly typed test for it. First, the source file:
// src/utils/validate.ts
export interface ValidationResult {
valid: boolean
errors: string[]
}
export function validateEmail(email: string): ValidationResult {
const errors: string[] = []
if (!email) {
errors.push('Email is required')
} else if (!email.includes('@')) {
errors.push('Email must contain @ symbol')
} else if (email.length > 254) {
errors.push('Email is too long')
}
return {
valid: errors.length === 0,
errors,
}
}
Now the test file:
// tests/utils/validate.test.ts
import { describe, it, expect } from 'vitest'
import { validateEmail, ValidationResult } from '@/utils/validate'
describe('validateEmail', () => {
it('returns valid result for a correct email', () => {
const result: ValidationResult = validateEmail('user@example.com')
expect(result.valid).toBe(true)
expect(result.errors).toHaveLength(0)
})
it('returns error for empty string', () => {
const result = validateEmail('')
expect(result.valid).toBe(false)
expect(result.errors).toContain('Email is required')
})
it('returns error for email without @', () => {
const result = validateEmail('invalid-email')
expect(result.valid).toBe(false)
expect(result.errors).toContain('Email must contain @ symbol')
})
})
Notice how the ValidationResult interface is imported and used as a type annotation. This ensures that if the shape of the return value ever changes, the test file will produce a compile-time error.
Type-Safe Mocking with vi.fn and vi.mock
Mocking is where TypeScript integration truly shines. Vitest provides vi.fn() for creating typed mock functions and vi.mock() for replacing entire modules. Let's look at a service that depends on a repository:
// src/repositories/userRepository.ts
export interface User {
id: string
name: string
email: string
}
export interface UserRepository {
findById(id: string): Promise<User | null>
findAll(): Promise<User[]>
save(user: Omit<User, 'id'>): Promise<User>
}
export class UserRepositoryImpl implements UserRepository {
async findById(id: string): Promise<User | null> {
// database call
return null
}
async findAll(): Promise<User[]> {
return []
}
async save(user: Omit<User, 'id'>): Promise<User> {
return { ...user, id: 'generated-id' }
}
}
// src/services/userService.ts
import { UserRepository, User } from '@/repositories/userRepository'
export class UserService {
constructor(private repo: UserRepository) {}
async getUser(id: string): Promise<User | null> {
return this.repo.findById(id)
}
async createUser(name: string, email: string): Promise<User> {
if (!name || !email) {
throw new Error('Name and email are required')
}
return this.repo.save({ name, email })
}
}
Now let's write a test using a typed mock. The key technique is using vi.fn() with a generic type parameter or by passing the implementation directly, which lets TypeScript infer the function signature:
// tests/services/userService.test.ts
import { describe, it, expect, vi } from 'vitest'
import { UserService } from '@/services/userService'
import { UserRepository, User } from '@/repositories/userRepository'
function createMockRepo(): UserRepository {
return {
findById: vi.fn<UserRepository['findById']>(),
findAll: vi.fn<UserRepository['findAll']>(),
save: vi.fn<UserRepository['save']>(),
}
}
describe('UserService', () => {
it('getUser returns user from repository', async () => {
const mockRepo = createMockRepo()
const mockUser: User = {
id: '1',
name: 'Alice',
email: 'alice@example.com',
}
mockRepo.findById.mockResolvedValue(mockUser)
const service = new UserService(mockRepo)
const result = await service.getUser('1')
expect(result).toEqual(mockUser)
expect(mockRepo.findById).toHaveBeenCalledWith('1')
})
it('createUser throws when name is empty', async () => {
const mockRepo = createMockRepo()
const service = new UserService(mockRepo)
await expect(service.createUser('', 'test@example.com'))
.rejects.toThrow('Name and email are required')
expect(mockRepo.save).not.toHaveBeenCalled()
})
it('createUser delegates to repository save', async () => {
const mockRepo = createMockRepo()
const createdUser: User = {
id: 'new-id',
name: 'Bob',
email: 'bob@example.com',
}
mockRepo.save.mockResolvedValue(createdUser)
const service = new UserService(mockRepo)
const result = await service.createUser('Bob', 'bob@example.com')
expect(result).toEqual(createdUser)
expect(mockRepo.save).toHaveBeenCalledWith({
name: 'Bob',
email: 'bob@example.com',
})
})
})
By typing the mock factory function's return as UserRepository, TypeScript enforces that every method on the mock matches the interface. If you later add a method to UserRepository, the mock factory will fail to compile until you provide a mock for the new method.
Module-Level Mocking with vi.mock
For larger applications, you may want to mock entire modules. Vitest's vi.mock() function supports this, and with TypeScript you can ensure the mock matches the real module's exports. Here's an example using a logger module:
// src/utils/logger.ts
export interface Logger {
info(message: string): void
error(message: string): void
warn(message: string): void
}
export const consoleLogger: Logger = {
info: (msg) => console.log(`[INFO] ${msg}`),
error: (msg) => console.error(`[ERROR] ${msg}`),
warn: (msg) => console.warn(`[WARN] ${msg}`),
}
// tests/utils/logger.test.ts
import { describe, it, expect, vi } from 'vitest'
vi.mock('@/utils/logger', () => ({
consoleLogger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
}))
import { consoleLogger } from '@/utils/logger'
describe('logger mock', () => {
it('tracks calls to info', () => {
consoleLogger.info('test message')
expect(consoleLogger.info).toHaveBeenCalledWith('test message')
})
})
Typed Test Context with beforeEach and Test Context
Vitest allows you to pass typed context through beforeEach hooks using the second argument of describe or by augmenting the test context. This is useful for setting up fixtures that multiple tests share:
// tests/services/userService.context.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { UserService } from '@/services/userService'
import { UserRepository, User } from '@/repositories/userRepository'
interface TestContext {
service: UserService
mockRepo: UserRepository
sampleUser: User
}
describe<TestContext>('UserService with context', (ctx) => {
beforeEach((context) => {
const mockRepo: UserRepository = {
findById: vi.fn(),
findAll: vi.fn(),
save: vi.fn(),
}
context.mockRepo = mockRepo
context.service = new UserService(mockRepo)
context.sampleUser = {
id: '42',
name: 'Carol',
email: 'carol@example.com',
}
})
it('finds a user by id', async ({ service, mockRepo, sampleUser }) => {
mockRepo.findById.mockResolvedValue(sampleUser)
const result = await service.getUser('42')
expect(result).toBe(sampleUser)
})
it('returns null for missing user', async ({ service, mockRepo }) => {
mockRepo.findById.mockResolvedValue(null)
const result = await service.getUser('nonexistent')
expect(result).toBeNull()
})
})
The generic parameter TestContext on describe ensures that every property you set in beforeEach and access in it blocks is type-checked. If you try to access a property that doesn't exist on TestContext, TypeScript will flag it immediately.
Type-Safe Snapshot Testing
Vitest supports snapshot testing, and when combined with TypeScript, you can ensure your snapshots match expected types. Here's an example:
// tests/snapshot.test.ts
import { describe, it, expect } from 'vitest'
import { validateEmail } from '@/utils/validate'
describe('validateEmail snapshots', () => {
it('matches snapshot for invalid email', () => {
const result = validateEmail('not-an-email')
expect(result).toMatchInlineSnapshot({
valid: false,
errors: ['Email must contain @ symbol'],
})
})
})
Best Practices for Vitest with TypeScript
- Always type your mocks: Use interface types or
vi.fn<T>()generics so that mock signatures stay in sync with real implementations. - Import types explicitly: Use
import typefor interfaces and types to keep your test bundles lean and avoid circular dependencies. - Use strict mode: Enable
"strict": truein your tsconfig so thatnullandundefinedchecks are enforced in tests too. - Avoid
anyin tests: If you find yourself reaching forany, consider whether your test is actually exercising a realistic code path. Useunknownwith type guards instead. - Co-locate or separate consistently: Choose a convention—either
.test.tsfiles next to source files or a dedicatedtests/directory—and stick with it across the project. - Leverage path aliases: Mirror your application's path aliases in Vitest config so imports in tests match imports in source code exactly.
- Run type-checking in CI: Add
tsc --noEmitas a separate CI step alongsidevitest runto catch type errors that esbuild's transpilation might skip. - Use
expectTypeOffor type-level testing: Vitest ships a utility for asserting types at compile time, which is invaluable for library authors.
Type-Level Testing with expectTypeOf
Vitest includes expectTypeOf, a powerful utility for asserting that types behave as expected. This is especially useful when building generic utilities or libraries:
// tests/typeAssertions.test.ts
import { expectTypeOf, test } from 'vitest'
import { validateEmail } from '@/utils/validate'
test('validateEmail returns ValidationResult', () => {
const result = validateEmail('test@example.com')
expectTypeOf(result).toMatchTypeOf<{
valid: boolean
errors: string[]
}>()
expectTypeOf(result.valid).toBeBoolean()
expectTypeOf(result.errors).toBeArray()
expectTypeOf(result.errors[0]).toBeString()
})
These assertions are evaluated at compile time, not runtime. If someone changes valid from boolean to string, the expectTypeOf(result.valid).toBeBoolean() line will fail during type-checking, even though the test would pass at runtime.
Conclusion
Combining Vitest with TypeScript gives you a testing experience where the compiler acts as a silent pair programmer, catching mismatches between your tests and your application code before they ever reach a browser or CI pipeline. By typing your mocks, leveraging typed test context, using expectTypeOf for type-level assertions, and following consistent conventions, you build a safety net that grows stronger with every refactor. The upfront cost of adding type annotations to your tests pays dividends in confidence, maintainability, and developer velocity—making strongly typed testing not just a best practice, but a foundational pillar of professional TypeScript application development.