はじめに#
TypeScriptでアプリケーションを開発する際、型による静的チェックだけでは不十分です。実行時の振る舞いを検証する単体テストと、型定義そのものを検証する型テストを組み合わせることで、より堅牢なコードベースを構築できます。
本記事では、Viteベースの高速テストフレームワーク「Vitest」を使用して、TypeScriptコードのテストを書く方法を解説します。基本的なテストの書き方から、モックの型付け、expectTypeOfによる型のテスト、テストカバレッジの設定まで、TypeScriptの単体テストに必要な知識を体系的に学べます。
本記事を読むことで、以下のことができるようになります。
- VitestでTypeScriptコードの単体テストを書く
- vi.fn()やvi.mock()を使用した型安全なモックを作成する
- expectTypeOfを使用して型定義をテストする
- テストカバレッジを設定し、コードの網羅率を可視化する
実行環境・前提条件#
前提知識#
- TypeScriptの基本的な知識(型注釈、ジェネリクスなど)
- Node.jsとnpmの基本的な使い方
- 単体テストの基本概念
動作確認環境#
本記事の内容は、以下の環境で動作確認を行っています。
| ソフトウェア |
バージョン |
| Node.js |
22.x LTS |
| npm |
10.x 以上 |
| TypeScript |
5.7 以上 |
| Vitest |
3.x 以上 |
期待される結果#
本記事の手順を完了すると、以下の状態になります。
- Vitestを使用してTypeScriptコードの単体テストを実行できる
- 型安全なモックを作成してテストで利用できる
- 型定義のテストを記述して、型の正確性を検証できる
- テストカバレッジレポートを生成できる
Vitestとは#
Vitestは、Viteをベースとした高速なテストフレームワークです。以下の特徴があり、TypeScriptプロジェクトのテストに最適です。
- Viteの高速なビルドシステムを活用したテスト実行
- TypeScriptのネイティブサポート(追加設定不要)
- Jestと互換性のあるAPI
- ES Modules(ESM)のネイティブサポート
- HMR(Hot Module Replacement)によるテストのリアルタイム更新
- 組み込みのコードカバレッジサポート
Vitestのセットアップ#
プロジェクトの初期化#
まず、新しいTypeScriptプロジェクトを作成し、Vitestをセットアップします。
1
2
3
4
5
6
7
8
9
|
# プロジェクトディレクトリを作成
mkdir typescript-vitest-example
cd typescript-vitest-example
# package.jsonを初期化
npm init -y
# TypeScriptとVitestをインストール
npm install -D typescript vitest
|
TypeScript設定#
tsconfig.jsonを作成します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"types": ["vitest/globals"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
|
Vitest設定#
プロジェクトルートにvitest.config.tsを作成します。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
},
},
})
|
package.jsonにスクリプトを追加#
1
2
3
4
5
6
7
|
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"coverage": "vitest run --coverage"
}
}
|
TypeScriptでの基本的なテストの書き方#
テスト対象のコードを作成#
src/calculator.tsに簡単な計算機モジュールを作成します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
export function add(a: number, b: number): number {
return a + b
}
export function subtract(a: number, b: number): number {
return a - b
}
export function multiply(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 is not allowed')
}
return a / b
}
|
テストファイルを作成#
src/calculator.test.tsにテストを作成します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
import { describe, it, expect } from 'vitest'
import { add, subtract, multiply, divide } from './calculator'
describe('Calculator', () => {
describe('add', () => {
it('2つの正の数を足し算できる', () => {
expect(add(1, 2)).toBe(3)
})
it('負の数を含む足し算ができる', () => {
expect(add(-1, 1)).toBe(0)
})
it('小数の足し算ができる', () => {
expect(add(0.1, 0.2)).toBeCloseTo(0.3)
})
})
describe('subtract', () => {
it('2つの数を引き算できる', () => {
expect(subtract(5, 3)).toBe(2)
})
})
describe('multiply', () => {
it('2つの数を掛け算できる', () => {
expect(multiply(3, 4)).toBe(12)
})
it('0との掛け算は0になる', () => {
expect(multiply(5, 0)).toBe(0)
})
})
describe('divide', () => {
it('2つの数を割り算できる', () => {
expect(divide(10, 2)).toBe(5)
})
it('0で割るとエラーが発生する', () => {
expect(() => divide(10, 0)).toThrow('Division by zero is not allowed')
})
})
})
|
テストの実行#
実行結果は以下のようになります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
✓ src/calculator.test.ts (7)
✓ Calculator (7)
✓ add (3)
✓ 2つの正の数を足し算できる
✓ 負の数を含む足し算ができる
✓ 小数の足し算ができる
✓ subtract (1)
✓ 2つの数を引き算できる
✓ multiply (2)
✓ 2つの数を掛け算できる
✓ 0との掛け算は0になる
✓ divide (2)
✓ 2つの数を割り算できる
✓ 0で割るとエラーが発生する
Test Files 1 passed (1)
Tests 7 passed (7)
|
モックの型付け#
TypeScriptでモックを使用する際は、型安全性を保つことが重要です。Vitestはvi.fn()やvi.mock()を使用して型付きモックを作成できます。
vi.fn()による関数モック#
vi.fn()を使用して、型付きのモック関数を作成します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
// モック関数の型定義
type FetchUserFn = (id: number) => Promise<{ id: number; name: string }>
describe('vi.fn()による型付きモック', () => {
let mockFetchUser: Mock<FetchUserFn>
beforeEach(() => {
// 型付きモック関数を作成
mockFetchUser = vi.fn<FetchUserFn>()
})
it('モック関数の戻り値を設定できる', async () => {
mockFetchUser.mockResolvedValue({ id: 1, name: 'Alice' })
const result = await mockFetchUser(1)
expect(result).toEqual({ id: 1, name: 'Alice' })
expect(mockFetchUser).toHaveBeenCalledWith(1)
})
it('モック関数の実装を差し替えられる', async () => {
mockFetchUser.mockImplementation(async (id) => ({
id,
name: `User ${id}`,
}))
const result = await mockFetchUser(42)
expect(result).toEqual({ id: 42, name: 'User 42' })
})
})
|
vi.mock()によるモジュールモック#
モジュール全体をモックする場合はvi.mock()を使用します。
まず、テスト対象のモジュールを作成します。
1
2
3
4
5
6
7
|
// src/userService.ts
import { fetchUserFromApi } from './api'
export async function getUserName(id: number): Promise<string> {
const user = await fetchUserFromApi(id)
return user.name
}
|
1
2
3
4
5
6
7
8
9
10
11
|
// src/api.ts
export interface User {
id: number
name: string
email: string
}
export async function fetchUserFromApi(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`)
return response.json()
}
|
テストでモジュールをモックします。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
// src/userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getUserName } from './userService'
import { fetchUserFromApi } from './api'
// モジュール全体をモック
vi.mock('./api', () => ({
fetchUserFromApi: vi.fn(),
}))
// モック化された関数の型を取得
const mockFetchUserFromApi = vi.mocked(fetchUserFromApi)
describe('UserService', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('ユーザー名を取得できる', async () => {
mockFetchUserFromApi.mockResolvedValue({
id: 1,
name: 'Alice',
email: 'alice@example.com',
})
const name = await getUserName(1)
expect(name).toBe('Alice')
expect(mockFetchUserFromApi).toHaveBeenCalledWith(1)
})
})
|
vi.spyOn()による部分モック#
既存のオブジェクトのメソッドをスパイする場合はvi.spyOn()を使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
import { describe, it, expect, vi, afterEach } from 'vitest'
const userRepository = {
findById: async (id: number): Promise<{ id: number; name: string } | null> => {
// 実際のDB呼び出しなど
return { id, name: 'Real User' }
},
}
describe('vi.spyOn()による部分モック', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('特定のメソッドだけをモックできる', async () => {
const spy = vi.spyOn(userRepository, 'findById')
spy.mockResolvedValue({ id: 1, name: 'Mocked User' })
const user = await userRepository.findById(1)
expect(user).toEqual({ id: 1, name: 'Mocked User' })
expect(spy).toHaveBeenCalledWith(1)
})
it('実装を維持しながら呼び出しを追跡できる', async () => {
const spy = vi.spyOn(userRepository, 'findById')
const user = await userRepository.findById(1)
expect(user).toEqual({ id: 1, name: 'Real User' })
expect(spy).toHaveBeenCalledWith(1)
})
})
|
クラスのモック#
クラスをモックする場合の型付け方法です。
1
2
3
4
5
6
7
8
|
// src/emailService.ts
export class EmailService {
async send(to: string, subject: string, body: string): Promise<boolean> {
// 実際のメール送信処理
console.log(`Sending email to ${to}`)
return true
}
}
|
1
2
3
4
5
6
7
8
9
10
|
// src/notificationService.ts
import { EmailService } from './emailService'
export class NotificationService {
constructor(private emailService: EmailService) {}
async notifyUser(email: string, message: string): Promise<void> {
await this.emailService.send(email, 'Notification', message)
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
// src/notificationService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NotificationService } from './notificationService'
import { EmailService } from './emailService'
// クラスをモック
vi.mock('./emailService', () => ({
EmailService: vi.fn().mockImplementation(() => ({
send: vi.fn().mockResolvedValue(true),
})),
}))
describe('NotificationService', () => {
let notificationService: NotificationService
let mockEmailService: EmailService
beforeEach(() => {
vi.clearAllMocks()
mockEmailService = new EmailService()
notificationService = new NotificationService(mockEmailService)
})
it('ユーザーに通知を送信できる', async () => {
await notificationService.notifyUser('test@example.com', 'Hello!')
expect(mockEmailService.send).toHaveBeenCalledWith(
'test@example.com',
'Notification',
'Hello!'
)
})
})
|
expectTypeOfによる型のテスト#
VitestはexpectTypeOfとassertTypeを使用して、型定義そのものをテストできます。これにより、ライブラリの型定義や複雑な型変換が正しく機能していることを検証できます。
型テストの基本#
型テストは通常、.test-d.tsという拡張子のファイルに記述します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// src/types.test-d.ts
import { expectTypeOf, assertType } from 'vitest'
describe('型のテスト', () => {
it('基本的な型をテストできる', () => {
const name = 'Alice'
expectTypeOf(name).toBeString()
const age = 30
expectTypeOf(age).toBeNumber()
const isActive = true
expectTypeOf(isActive).toBeBoolean()
})
it('関数の型をテストできる', () => {
const greet = (name: string): string => `Hello, ${name}`
expectTypeOf(greet).toBeFunction()
expectTypeOf(greet).parameter(0).toBeString()
expectTypeOf(greet).returns.toBeString()
})
})
|
Vitest設定に型テストを追加#
vitest.config.tsを更新して型テストを有効にします。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
typecheck: {
enabled: true,
include: ['src/**/*.test-d.ts'],
},
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
},
},
})
|
型の等価性テスト#
toEqualTypeOfを使用して、型が完全に一致するかを検証します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
// src/utils.test-d.ts
import { expectTypeOf } from 'vitest'
interface User {
id: number
name: string
email: string
}
type UserWithoutEmail = Omit<User, 'email'>
describe('ユーティリティ型のテスト', () => {
it('Omitが正しく動作する', () => {
expectTypeOf<UserWithoutEmail>().toEqualTypeOf<{
id: number
name: string
}>()
})
it('Pickが正しく動作する', () => {
type UserName = Pick<User, 'name'>
expectTypeOf<UserName>().toEqualTypeOf<{ name: string }>()
})
it('Partialが正しく動作する', () => {
type PartialUser = Partial<User>
expectTypeOf<PartialUser>().toEqualTypeOf<{
id?: number
name?: string
email?: string
}>()
})
})
|
型の拡張性テスト#
toExtendを使用して、型が別の型を拡張しているかを検証します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// src/inheritance.test-d.ts
import { expectTypeOf } from 'vitest'
interface Animal {
name: string
}
interface Dog extends Animal {
breed: string
}
describe('型の継承テスト', () => {
it('DogはAnimalを拡張している', () => {
expectTypeOf<Dog>().toExtend<Animal>()
})
it('AnimalはDogを拡張していない', () => {
expectTypeOf<Animal>().not.toExtend<Dog>()
})
})
|
関数の型テスト#
関数のパラメータや戻り値の型をテストします。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
// src/functions.test-d.ts
import { expectTypeOf } from 'vitest'
function createUser(name: string, age: number): { name: string; age: number } {
return { name, age }
}
async function fetchData<T>(url: string): Promise<T> {
const response = await fetch(url)
return response.json()
}
describe('関数の型テスト', () => {
it('createUserの型をテストできる', () => {
expectTypeOf(createUser).toBeFunction()
// パラメータの型をテスト
expectTypeOf(createUser).parameter(0).toBeString()
expectTypeOf(createUser).parameter(1).toBeNumber()
// 戻り値の型をテスト
expectTypeOf(createUser).returns.toEqualTypeOf<{
name: string
age: number
}>()
})
it('ジェネリック関数の型をテストできる', () => {
expectTypeOf(fetchData<{ id: number }>).returns.resolves.toEqualTypeOf<{
id: number
}>()
})
})
|
assertTypeによるシンプルな型テスト#
expectTypeOfより簡潔なassertTypeも利用できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// src/simple-types.test-d.ts
import { assertType } from 'vitest'
describe('assertTypeによる型テスト', () => {
it('変数の型を検証できる', () => {
const value = 42
assertType<number>(value)
})
it('@ts-expect-errorで型エラーを検証できる', () => {
const value = 42
// @ts-expect-error valueはnumberなのでstringを期待するとエラー
assertType<string>(value)
})
})
|
実践的な型テスト例#
実際のライブラリやユーティリティ関数の型をテストする例です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
// src/api-response.test-d.ts
import { expectTypeOf } from 'vitest'
// APIレスポンスの型定義
interface ApiResponse<T> {
data: T
status: number
message: string
}
interface SuccessResponse<T> extends ApiResponse<T> {
status: 200
error: never
}
interface ErrorResponse extends ApiResponse<null> {
status: 400 | 404 | 500
error: string
}
type Response<T> = SuccessResponse<T> | ErrorResponse
// 型ガード関数
function isSuccess<T>(response: Response<T>): response is SuccessResponse<T> {
return response.status === 200
}
describe('APIレスポンス型のテスト', () => {
it('SuccessResponseが正しい構造を持つ', () => {
expectTypeOf<SuccessResponse<{ id: number }>>().toExtend<ApiResponse<{ id: number }>>()
})
it('型ガード関数が型を絞り込む', () => {
const response = {} as Response<{ id: number }>
if (isSuccess(response)) {
expectTypeOf(response).toEqualTypeOf<SuccessResponse<{ id: number }>>()
expectTypeOf(response.data).toEqualTypeOf<{ id: number }>()
}
})
})
|
テストカバレッジの設定#
Vitestは、V8とIstanbulの2つのカバレッジプロバイダーをサポートしています。V8はNode.jsに組み込まれており、高速に動作します。
カバレッジの有効化#
まず、カバレッジ用のパッケージをインストールします。
1
|
npm install -D @vitest/coverage-v8
|
vitest.config.tsの設定#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
reportsDirectory: './coverage',
include: ['src/**/*.ts'],
exclude: [
'src/**/*.test.ts',
'src/**/*.test-d.ts',
'src/**/*.spec.ts',
'src/types/**',
],
thresholds: {
statements: 80,
branches: 80,
functions: 80,
lines: 80,
},
},
},
})
|
カバレッジレポートの生成#
実行結果は以下のようになります。
1
2
3
4
5
6
7
8
9
10
11
12
|
✓ src/calculator.test.ts (7)
Test Files 1 passed (1)
Tests 7 passed (7)
% Coverage report from v8
---------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
---------------------|---------|----------|---------|---------|
All files | 100 | 100 | 100 | 100 |
calculator.ts | 100 | 100 | 100 | 100 |
---------------------|---------|----------|---------|---------|
|
カバレッジオプションの詳細#
| オプション |
説明 |
| provider |
カバレッジプロバイダー(v8またはistanbul) |
| reporter |
レポート形式(text、json、html、lcovなど) |
| reportsDirectory |
レポートの出力先ディレクトリ |
| include |
カバレッジ対象に含めるファイルパターン |
| exclude |
カバレッジ対象から除外するファイルパターン |
| thresholds |
カバレッジの閾値(下回るとテスト失敗) |
カバレッジ閾値の設定#
チーム開発では、カバレッジの最低閾値を設定することで、品質基準を維持できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
coverage: {
thresholds: {
// グローバルな閾値
statements: 80,
branches: 80,
functions: 80,
lines: 80,
// ファイル単位の閾値
perFile: true,
// 閾値を満たさない場合にテストを失敗させる
autoUpdate: false,
},
}
|
カバレッジから特定のコードを除外#
テスト困難なコードや意図的にカバレッジ対象外としたいコードには、コメントで除外指定できます。
1
2
3
4
5
6
7
8
9
10
|
/* v8 ignore next -- @preserve */
function debugOnly(): void {
console.log('Debug information')
}
/* v8 ignore start -- @preserve */
if (process.env.NODE_ENV === 'development') {
enableDevTools()
}
/* v8 ignore stop -- @preserve */
|
実践的なテスト構成#
大規模なTypeScriptプロジェクトでの推奨テスト構成を紹介します。
ディレクトリ構成#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
src/
├── components/
│ ├── Button.ts
│ └── Button.test.ts
├── services/
│ ├── userService.ts
│ └── userService.test.ts
├── utils/
│ ├── helpers.ts
│ └── helpers.test.ts
├── types/
│ ├── api.ts
│ └── api.test-d.ts
└── __mocks__/
└── api.ts
|
テストユーティリティの共通化#
src/test/utils.tsにテストユーティリティを作成します。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// src/test/utils.ts
import { vi } from 'vitest'
export function createMockFetch<T>(data: T): typeof fetch {
return vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(data),
}) as typeof fetch
}
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
|
セットアップファイルの作成#
1
2
3
4
5
6
7
8
9
10
11
|
// src/test/setup.ts
import { beforeEach, afterEach, vi } from 'vitest'
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
|
vitest.config.tsでセットアップファイルを指定します。
1
2
3
4
5
6
7
8
|
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./src/test/setup.ts'],
// ...
},
})
|
よくあるエラーと対処法#
モジュールのモックが効かない#
vi.mock()はファイルの先頭で巻き上げ(hoist)されます。動的なモック設定が必要な場合は、vi.doMock()を使用します。
1
2
3
4
5
6
7
8
9
|
// 正しい使い方
vi.mock('./module', () => ({
someFunction: vi.fn(),
}))
// 動的なモック(巻き上げされない)
await vi.doMock('./module', () => ({
someFunction: vi.fn().mockReturnValue('dynamic'),
}))
|
型テストのエラーメッセージが分かりにくい#
expectTypeOfのエラーメッセージは、TypeScriptの制約上複雑になることがあります。より分かりやすいエラーを得るには、具体的な型を型引数で指定します。
1
2
3
4
5
|
// 推奨:型引数で明示的に指定
expectTypeOf<MyType>().toEqualTypeOf<ExpectedType>()
// 非推奨:オブジェクトリテラルを直接渡す
expectTypeOf({ a: 1 }).toEqualTypeOf({ a: '' })
|
カバレッジが正しく計測されない#
ソースマップの問題が原因の可能性があります。tsconfig.jsonでsourceMapを有効にしてください。
1
2
3
4
5
|
{
"compilerOptions": {
"sourceMap": true
}
}
|
まとめ#
本記事では、VitestでTypeScriptコードをテストする方法について解説しました。
- Vitestのセットアップと基本的なテストの書き方
- vi.fn()、vi.mock()、vi.spyOn()を使用した型安全なモック
- expectTypeOfとassertTypeによる型テスト
- カバレッジの設定と閾値管理
TypeScriptプロジェクトでは、実行時の振る舞いをテストする単体テストと、型定義の正確性を検証する型テストを組み合わせることで、より堅牢なコードベースを構築できます。Vitestはその両方をサポートしており、TypeScriptとの親和性が高い選択肢です。
次のステップとして、CI/CDパイプラインにテストを組み込み、継続的にコード品質を監視することをおすすめします。
参考リンク#