← Back to DevBytes

Vitest TypeScript: Strongly Typed Applications

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:

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles