T-CREATOR

Vitest × TypeScript:型安全なテストの始め方

Vitest × TypeScript:型安全なテストの始め方

TypeScriptプロジェクトでテストを書く際、型安全性を保ちながら効率的にテストコードを作成したいと思ったことはありませんか。従来のテストフレームワークでは型チェックが効かず、ランタイムエラーに悩まされることも少なくありません。

本記事では、VitestとTypeScriptを組み合わせた型安全なテスト環境の構築方法から実践的な書き方まで、初心者の方にもわかりやすく解説いたします。最新のフロントエンド開発において、テストの質を向上させる重要な手法を身につけていきましょう。

背景

TypeScriptプロジェクトでのテストの課題

TypeScriptを使った開発では、コンパイル時に型チェックが行われ、多くのバグを事前に防ぐことができます。しかし、テストコードにおいては従来のフレームワークでは型安全性が十分に保たれないケースが多く見られました。

特に以下のような問題が頻繁に発生していました。

typescript// 従来のテストフレームワークでの問題例
// 型エラーが検出されずにテストが通ってしまう
const mockFunction = jest.fn()
mockFunction.mockReturnValue('string') // 実際はnumberを期待しているのに...

expect(calculateSum(mockFunction())).toBe(10) // ランタイムエラー発生

このように、テストコード内で型の不整合が起きても事前に検出できない問題がありました。

Vitestが選ばれる理由

Vitestは、Viteエコシステムの一部として開発された高速なテストフレームワークです。TypeScriptとの親和性が非常に高く、以下の特徴を持っています。

テストの実行速度と型安全性の両立を図ったVitestの特徴を図で確認してみましょう。

mermaidflowchart TD
    A[Vitestの特徴] --> B[高速実行]
    A --> C[型安全性]
    A --> D[Vite連携]
    
    B --> B1[HMR対応]
    B --> B2[並列実行]
    
    C --> C1[TypeScript標準サポート]
    C --> C2[厳密な型チェック]
    
    D --> D1[同一設定ファイル]
    D --> D2[プラグイン共有]

補足:Vitestは開発環境とテスト環境で同じ設定を共有できるため、設定の複雑さが軽減されます。

主な利点として以下が挙げられます。

項目従来フレームワークVitest
TypeScript対応追加設定が必要標準対応
実行速度遅い高速
設定の複雑さ複雑シンプル
Vite連携不可完全対応

型安全性がテストにもたらすメリット

型安全なテスト環境では、以下のようなメリットを享受できます。

開発時のメリット

  • IDEでの補完機能が充実し、テストコード作成が効率化
  • 型エラーをコンパイル時に検出、ランタイムエラーの削減
  • リファクタリング時の影響範囲の把握が容易

保守性の向上

  • テストコードの可読性向上
  • API変更時の影響を事前に把握
  • チーム開発での一貫性確保

課題

従来のテストフレームワークでの型エラー問題

JestやMochaなどの従来のテストフレームワークでは、TypeScriptサポートが後付けのため、様々な問題が発生していました。

以下は実際によく遭遇する型エラーの例です。

typescript// Jest使用時の型エラー例
interface User {
  id: number
  name: string
  email: string
}

const mockUser: User = {
  id: 1,
  name: 'John Doe',
  email: 'john@example.com'
}

// この時点では型エラーが検出されない
const getUserById = jest.fn().mockReturnValue(mockUser)

// 実際には別の型を返すAPIの変更があった場合
// ランタイムまで問題が発覚しない

このような問題により、テストの信頼性が損なわれることがありました。

テストコードの保守性の低さ

型チェックが効かないテストコードでは、以下のような保守性の問題が発生します。

typescript// 保守性の低いテスト例
describe('ユーザー管理', () => {
  it('ユーザー情報を取得する', () => {
    // 型定義なしのモック
    const mockResponse = {
      data: {
        user: {
          id: '1', // 本来はnumberだが文字列になっている
          name: 'Test User'
          // emailプロパティが不足
        }
      }
    }
    
    // この時点で型エラーが検出されない
    expect(processUserData(mockResponse.data.user)).toBeDefined()
  })
})

API仕様の変更時にテストコードの修正箇所を特定するのが困難になります。

開発効率の悪化

型安全性が保たれないテスト環境では、以下のような開発効率の悪化が見られました。

開発プロセスでの問題点を図で整理してみましょう。

mermaidsequenceDiagram
    participant Dev as 開発者
    participant IDE as IDE/エディタ
    participant Test as テスト実行
    participant Bug as バグ発見
    
    Dev->>IDE: テストコード作成
    IDE->>Dev: 補完機能が不十分
    Dev->>Test: テスト実行
    Test->>Bug: ランタイムエラー発生
    Bug->>Dev: デバッグ作業
    Dev->>Dev: 修正作業
    
    Note over Dev,Bug: 非効率なサイクル

補足:型チェックが効かないため、問題の発見が遅れ、デバッグに多くの時間を要することになります。

具体的な効率悪化の例

  • IDEの補完機能が十分に働かない
  • ランタイムエラーの頻発
  • デバッグ時間の増大
  • チームメンバー間でのコード品質のばらつき

解決策

Vitest + TypeScript環境の構築手順

まず、新規プロジェクトでのVitest + TypeScript環境の構築から始めましょう。

プロジェクトの初期化

bash# 新規プロジェクトの作成
mkdir vitest-typescript-example
cd vitest-typescript-example

# package.jsonの初期化
yarn init -y

必要パッケージのインストール

bash# TypeScriptとVitestの基本パッケージ
yarn add -D typescript vitest

# 型定義ファイル
yarn add -D @types/node

# Viteの設定(オプション)
yarn add -D vite

TypeScript設定ファイルの作成

プロジェクトルートにtsconfig.jsonを作成します。

json{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "node",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "types": ["vitest/globals"]
  },
  "include": [
    "src/**/*",
    "test/**/*",
    "**/*.test.ts"
  ],
  "exclude": [
    "node_modules",
    "dist"
  ]
}

型定義の設定方法

Vitestで型安全なテストを書くための設定を行います。

Vitest設定ファイルの作成

vite.config.tsまたはvitest.config.tsを作成します。

typescript/// <reference types="vitest" />
import { defineConfig } from 'vite'

export default defineConfig({
  test: {
    // グローバルAPIの有効化
    globals: true,
    // テスト環境の設定
    environment: 'node',
    // 型チェックの有効化
    typecheck: {
      checker: 'tsc',
      include: ['**/*.test.ts', '**/*.spec.ts']
    }
  }
})

package.jsonスクリプトの設定

json{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:run": "vitest run",
    "test:typecheck": "vitest typecheck"
  }
}

テストファイルの型安全な書き方

型安全なテストファイルの基本的な構成を見てみましょう。

基本的なテストファイルの構成

typescript// src/utils/math.ts
export function add(a: number, b: number): number {
  return a + b
}

export function multiply(a: number, b: number): number {
  return a * b
}

対応するテストファイル

typescript// test/utils/math.test.ts
import { describe, it, expect } from 'vitest'
import { add, multiply } from '../../src/utils/math'

describe('数学関数のテスト', () => {
  it('add関数は正しく加算を行う', () => {
    // 型安全な引数渡し
    const result: number = add(2, 3)
    expect(result).toBe(5)
  })

  it('multiply関数は正しく乗算を行う', () => {
    const result: number = multiply(4, 5)
    expect(result).toBe(20)
  })
})

型アサーションとアサート関数の活用

typescript// 型アサーションを使用したテスト
it('オブジェクトのプロパティをテストする', () => {
  interface User {
    id: number
    name: string
    isActive: boolean
  }
  
  const user: User = {
    id: 1,
    name: 'Alice',
    isActive: true
  }
  
  // 型安全なアサーション
  expect(user).toEqual<User>({
    id: 1,
    name: 'Alice',
    isActive: true
  })
  
  // プロパティの型チェック
  expect(typeof user.id).toBe('number')
  expect(typeof user.name).toBe('string')
  expect(typeof user.isActive).toBe('boolean')
})

具体例

基本的なユニットテストの実装

実際のプロジェクトでよく使用されるパターンのテスト実装を見ていきましょう。

テスト対象の関数定義

typescript// src/services/userService.ts
export interface User {
  id: number
  name: string
  email: string
  createdAt: Date
}

export class UserService {
  private users: User[] = []

  addUser(userData: Omit<User, 'id' | 'createdAt'>): User {
    const newUser: User = {
      id: this.users.length + 1,
      ...userData,
      createdAt: new Date()
    }
    
    this.users.push(newUser)
    return newUser
  }

  getUserById(id: number): User | undefined {
    return this.users.find(user => user.id === id)
  }

  getAllUsers(): User[] {
    return [...this.users]
  }
}

型安全なユニットテストの実装

typescript// test/services/userService.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { UserService, User } from '../../src/services/userService'

describe('UserService', () => {
  let userService: UserService

  beforeEach(() => {
    userService = new UserService()
  })

  describe('addUser', () => {
    it('新しいユーザーを正しく追加する', () => {
      const userData = {
        name: 'John Doe',
        email: 'john@example.com'
      }

      const result: User = userService.addUser(userData)

      expect(result).toMatchObject({
        id: expect.any(Number),
        name: userData.name,
        email: userData.email,
        createdAt: expect.any(Date)
      })
    })

    it('追加されたユーザーにIDが自動採番される', () => {
      const userData1 = { name: 'User1', email: 'user1@example.com' }
      const userData2 = { name: 'User2', email: 'user2@example.com' }

      const user1 = userService.addUser(userData1)
      const user2 = userService.addUser(userData2)

      expect(user1.id).toBe(1)
      expect(user2.id).toBe(2)
    })
  })
})

モック関数の型安全な使用

モック関数を型安全に使用する方法を見てみましょう。

外部依存関数のモック化

typescript// src/services/apiService.ts
export interface ApiResponse<T> {
  data: T
  status: number
  message: string
}

export interface UserData {
  id: number
  name: string
  email: string
}

export async function fetchUserData(id: number): Promise<ApiResponse<UserData>> {
  // 実際のAPI呼び出し処理
  const response = await fetch(`/api/users/${id}`)
  return response.json()
}

型安全なモックテスト

typescript// test/services/apiService.test.ts
import { describe, it, expect, vi } from 'vitest'
import type { MockedFunction } from 'vitest'
import { fetchUserData, ApiResponse, UserData } from '../../src/services/apiService'

// グローバルfetchのモック
const mockFetch = vi.fn() as MockedFunction<typeof fetch>
global.fetch = mockFetch

describe('API Service', () => {
  it('ユーザーデータを正しく取得する', async () => {
    const mockUserData: UserData = {
      id: 1,
      name: 'Alice',
      email: 'alice@example.com'
    }

    const mockResponse: ApiResponse<UserData> = {
      data: mockUserData,
      status: 200,
      message: 'Success'
    }

    // 型安全なモックレスポンス設定
    mockFetch.mockResolvedValueOnce({
      json: vi.fn().mockResolvedValue(mockResponse),
      ok: true,
      status: 200
    } as any)

    const result = await fetchUserData(1)

    expect(result).toEqual<ApiResponse<UserData>>(mockResponse)
    expect(mockFetch).toHaveBeenCalledWith('/api/users/1')
  })
})

カスタムモック関数の型定義

typescript// test/mocks/userMocks.ts
import type { User } from '../../src/services/userService'

export const createMockUser = (overrides: Partial<User> = {}): User => ({
  id: 1,
  name: 'Test User',
  email: 'test@example.com',
  createdAt: new Date('2024-01-01'),
  ...overrides
})

export const createMockUsers = (count: number): User[] => {
  return Array.from({ length: count }, (_, index) =>
    createMockUser({
      id: index + 1,
      name: `User ${index + 1}`,
      email: `user${index + 1}@example.com`
    })
  )
}

非同期テストの型定義

Promise や async/await を使った非同期処理のテストについて見ていきましょう。

非同期処理の関数定義

typescript// src/utils/asyncOperations.ts
export interface ProcessResult {
  success: boolean
  data?: any
  error?: string
}

export async function processData(input: string): Promise<ProcessResult> {
  try {
    // 非同期処理のシミュレーション
    await new Promise(resolve => setTimeout(resolve, 100))
    
    if (!input || input.length < 3) {
      throw new Error('Input too short')
    }

    return {
      success: true,
      data: input.toUpperCase()
    }
  } catch (error) {
    return {
      success: false,
      error: error instanceof Error ? error.message : 'Unknown error'
    }
  }
}

型安全な非同期テスト

typescript// test/utils/asyncOperations.test.ts
import { describe, it, expect } from 'vitest'
import { processData, ProcessResult } from '../../src/utils/asyncOperations'

describe('非同期処理のテスト', () => {
  it('正常な入力で成功結果を返す', async () => {
    const input = 'hello'
    
    const result: ProcessResult = await processData(input)
    
    expect(result).toEqual<ProcessResult>({
      success: true,
      data: 'HELLO'
    })
  })

  it('短い入力でエラー結果を返す', async () => {
    const input = 'hi'
    
    const result: ProcessResult = await processData(input)
    
    expect(result).toEqual<ProcessResult>({
      success: false,
      error: 'Input too short'
    })
  })

  it('複数の非同期処理を順次実行する', async () => {
    const inputs = ['test1', 'test2', 'test3']
    
    const results: ProcessResult[] = []
    for (const input of inputs) {
      const result = await processData(input)
      results.push(result)
    }
    
    expect(results).toHaveLength(3)
    expect(results.every(r => r.success)).toBe(true)
  })
})

非同期テストのフローを図で確認してみましょう。

mermaidsequenceDiagram
    participant Test as テストケース
    participant Func as 非同期関数
    participant Mock as モック/外部API
    
    Test->>Func: processData('hello')
    Func->>Mock: 非同期処理実行
    Mock-->>Func: 処理完了
    Func->>Func: 結果の型チェック
    Func-->>Test: ProcessResult型で結果返却
    Test->>Test: 型安全なアサーション

補足:非同期処理では戻り値の型が Promise になるため、await または .then() で適切に型を展開することが重要です。

まとめ

Vitest × TypeScriptを組み合わせることで、型安全で効率的なテスト環境を構築できることをご紹介してきました。

重要なポイントの整理

環境構築での要点

  • TypeScript設定でのVitest型定義の追加
  • vite.config.tsでの型チェック機能の有効化
  • package.jsonスクリプトでのテストコマンド設定

型安全なテスト実装の要点

  • インターフェースを活用した明確な型定義
  • モック関数での型アサーションの適切な使用
  • 非同期処理での Promise 型の適切な扱い

開発効率向上の成果

型安全なテスト環境により、以下の改善を実現できます。

観点従来の方法Vitest + TypeScript
エラー検出ランタイム時コンパイル時
IDE サポート限定的充実した補完
リファクタリング手作業で影響調査自動的に影響把握
保守性低い高い

次のステップへ

本記事で学んだ基礎を活用して、より高度なテストパターンに挑戦してみてください。

  • React コンポーネントのテスト
  • E2E テストとの組み合わせ
  • カバレッジ測定の設定
  • CI/CD パイプラインでの型チェック組み込み

型安全なテスト環境は、プロジェクトの品質向上と開発効率の大幅な改善をもたらします。ぜひ今日から取り入れて、より良い開発体験を実現してください。

関連リンク