Vitestでbcryptモックの型エラーを解決した話 - vi.hoisted()による型安全なモック実装
エラー
ユーザー登録機能のテストを書いていたときbcrypt.hash()をモックしようとしたら、こんな型エラーが出た。
import { vi } from 'vitest';
import bcrypt from 'bcrypt';
vi.mock('bcrypt');
// ❌ 型エラー
vi.mocked(bcrypt.hash).mockResolvedValue('$2b$10$hashedPassword');
// Error: 型 'string' の引数を型 'void' のパラメーターに割り当てることはできません
「stringをvoidに割り当てられない?」
この記事では、このエラーがなぜ起きるのか、そしてなぜvi.hoisted()で解決するのかを掘り下げていく。
エラーの本質を理解する
TypeScriptコンパイル時の型推論フロー
このエラーは、TypeScriptがbcrypt.hashの型を推論する過程で発生する。
【TypeScriptコンパイル時の処理】
1. import bcrypt from 'bcrypt' を見つける
↓
2. @types/bcrypt/index.d.ts を開く
↓
3. 型定義を読む(ここでは実行されない!)
↓
export function hash(...): Promise<string>; // 定義1(Promise版)
export function hash(..., callback): void; // 定義2(コールバック版)
↓
4. vi.mocked(bcrypt.hash) を型推論
↓
5. オーバーロードが2つあることを認識
↓
6. 引数情報がない(vi.mocked()は関数を受け取るだけ)
↓
7. TypeScriptのルール: 最後に定義されたオーバーロードを選択
↓
8. void版(コールバック版)を選択
↓
9. mockResolvedValue('string') を評価
↓
10. エラー!(void型にstringを割り当てようとしている)
オーバーロード関数とは
bcrypt.hashはオーバーロード関数で同じ関数名で2つの使い方がある
// 使い方1: Promise版
const hashed = await bcrypt.hash('password', 10); // → Promise<string>
// 使い方2: コールバック版
bcrypt.hash('password', 10, (err, encrypted) => {
// → void(何も返さない)
console.log(encrypted);
});
型定義はこうなっている
// @types/bcrypt/index.d.ts
export declare function hash(data: string | Buffer, saltOrRounds: string | number): Promise<string>;
export declare function hash(
data: string | Buffer,
saltOrRounds: string | number,
callback: (err: Error | undefined, encrypted: string) => any
): void;
なぜvoidエラーが出るのか
問題の原因は、TypeScriptのオーバーロード解決ルールにある。
vi.mocked(bcrypt.hash).mockResolvedValue('hashed');
この時、TypeScriptは以下のように型推論する:
// mockResolvedValueの型定義
interface MockInstance<T extends Procedure> {
mockResolvedValue(value: Awaited<ReturnType<T>>): this;
// ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
// Awaited<ReturnType<T>>
}
// Tはbcrypt.hashの型(オーバーロード関数)
type T = typeof bcrypt.hash;
// ReturnType<オーバーロード関数>の推論
type Return = ReturnType<typeof bcrypt.hash>;
// → TypeScriptのルール: 最後に定義されたオーバーロードを選択
// → void (コールバック版の戻り値型)
// だから
type Expected = Awaited<ReturnType<typeof bcrypt.hash>>;
// → Awaited<void>
// → void
// 結果
mockResolvedValue(value: void)
// 'string' は void に代入できない → 型エラー!
図解:
vi.mocked(bcrypt.hash).mockResolvedValue('string')
↓
bcrypt.hash の型 = オーバーロード関数
↓
ReturnType<オーバーロード関数>
↓
最後のオーバーロードを選択 → void(コールバック版)
↓
mockResolvedValue(value: Awaited<void>)
↓
mockResolvedValue(value: void)
↓
'string' は void に代入できない → 型エラー!
vi.mock()が型定義を保持する問題
さらに重要なのは、vi.mock()を使うと、元のモジュールの型定義が保持されること。
vi.mock('bcrypt');
import bcrypt from 'bcrypt';
// 実行時:bcrypt.hashはvi.fn()に置き換わる
// TypeScript:bcrypt.hashは元の型定義のまま
// → (data: string | Buffer, saltOrRounds: string | number) => Promise<string>
// + オーバーロード: (data, saltOrRounds, callback) => void
// 問題: オーバーロード型が保持されているため、型推論が失敗する
vi.mocked(bcrypt.hash).mockResolvedValue('hashed');
// ✗ Argument of type 'string' is not assignable to parameter of type 'void'
TypeScriptはvi.mock()による実行時の変更を認識できない。これが根本原因。
解決策:vi.hoisted()が解決する2つの問題
vi.hoisted()を使えば解決する。
// ✅ 解決策
const bcryptMock = vi.hoisted(() => ({
hash: vi.fn<() => Promise<string>>(),
compare: vi.fn<() => Promise<boolean>>(),
}));
vi.mock('bcrypt', () => ({
default: bcryptMock,
}));
vi.hoisted()は2つの問題を同時に解決している。
問題1:型情報の明示的な指定(最重要)
vi.hoisted()の最大の利点は、型情報を明示的に指定できる。
const bcryptMock = vi.hoisted(() => ({
hash: vi.fn<() => Promise<string>>(),
// ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
// 型を明示的に指定!
// → 「Promise<string>を返す関数」であることをTypeScriptに伝える
}));
vi.mock('bcrypt', () => ({
default: bcryptMock, // ← すでに型付けされたオブジェクトを参照
}));
重要:vi.mock()は参照を使う
vi.mock('bcrypt', () => ({ default: bcryptMock }));
// ↑↑↑↑↑↑↑↑↑↑
// すでに型付けされたオブジェクトを「参照」している
// 新しいvi.fn()を作っているわけではない
この「参照」がキー。vi.mock()のファクトリ関数内で新しいvi.fn()を作るのではなく、すでに存在する型付きオブジェクトを参照することで、型情報が保持される。
ファクトリ関数がある場合、Vitestは自動モック生成を行わない
重要なのは、ファクトリ関数を指定すると、Vitestは自動モック生成をスキップする:
// パターンA: ファクトリ関数なし
vi.mock('bcrypt');
// → Vitestが自動的にbcryptを読み込む
// → すべての関数を新しいvi.fn()に置き換える
// → 元の型定義が保持される → オーバーロード型 → void型エラー
// パターンB: ファクトリ関数あり
const bcryptMock = vi.hoisted(() => ({
hash: vi.fn<() => Promise<string>>(),
}));
vi.mock('bcrypt', () => ({ default: bcryptMock }));
// → Vitestはファクトリの戻り値(bcryptMock)をそのまま使う
// → 自動モック生成は行わない
// → bcryptMockの型情報が保持される
実際の動作確認:
import bcrypt from 'bcrypt';
// 検証: bcrypt.hashとbcryptMock.hashは同一のオブジェクト
console.log(bcrypt.hash === bcryptMock.hash); // true
// → 同じモック関数インスタンスを参照している
// → 型情報が保持されている
問題2:実行順序の制御
型情報の指定ができても、もう一つの問題がありそれはJavaScriptの実行順序である。
普通に変数を定義すると、実行順序の問題で失敗する:
// ❌ これは動かない
const bcryptMock = {
hash: vi.fn<() => Promise<string>>(),
};
vi.mock('bcrypt', () => ({
default: bcryptMock, // ← エラー!bcryptMockがまだ定義されていない
}));
なぜエラーになるのか?
Vitestはvi.mock()を**自動的にファイルの先頭に巻き上げる(hoist)**ため、実行順序が変わる:
【実際の実行順序】
1. vi.mock()が先に実行される(Vitestが自動的に巻き上げる)
2. その時点でbcryptMockはまだ定義されていない
3. ReferenceError: bcryptMock is not defined
vi.hoisted()を使うと解決:
const bcryptMock = vi.hoisted(() => ({
hash: vi.fn<() => Promise<string>>(),
}));
vi.mock('bcrypt', () => ({
default: bcryptMock, // ✅ OK!
}));
vi.hoisted()もvi.mock()と同じタイミングで巻き上げられるため、実行順序の問題が解決する:
【vi.hoisted()を使った実行順序】
1. vi.hoisted()が実行される(Vitestが巻き上げる)
2. bcryptMockが定義される
3. vi.mock()が実行される
4. bcryptMockを参照できる ✅
vi.hoisted()の2つの役割(まとめ)
vi.hoisted()は単なる回避策ではなく、2つの本質的な問題を解決している
-
型情報の明示的な指定(最重要):
vi.hoisted()で型付きオブジェクトを作り、vi.mock()がそれを参照する -
実行順序の制御:
vi.mock()のfactory関数内で変数を参照できるようにする
重要:正しい理解順序
この2つの問題は、以下の順序で理解する必要がある
1. vi.mock()は「参照」を使う
→ すでに型付けされたオブジェクトを参照することで、型情報を保持する
2. だから、タイミングが重要になる
→ 参照先(bcryptMock)が先に存在している必要がある
→ vi.hoisted()で実行順序を制御する
型安全なbcryptモックのパターン(完全版)
最終的に、以下のパターンに落ち着いた。
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ✅ 型安全なbcryptモック
const bcryptMock = vi.hoisted(() => ({
hash: vi.fn<() => Promise<string>>(),
compare: vi.fn<() => Promise<boolean>>(),
}));
vi.mock('bcrypt', () => ({
default: bcryptMock,
}));
describe('SignUpUseCase', () => {
beforeEach(() => {
vi.clearAllMocks(); // 各テスト前にモックをクリア
});
it('should hash password', async () => {
bcryptMock.hash.mockResolvedValue('$2b$10$hashed');
// テストコード
const result = await bcrypt.hash('password', 10);
expect(bcryptMock.hash).toHaveBeenCalledWith('password', 10);
expect(result).toBe('$2b$10$hashed');
});
});
このパターンは
- 型安全:TypeScriptが型を正確に理解している
- 実行可能:実行順序の問題がない
- 保守しやすい:意図が明確で、読みやすい
まとめ
この問題から得た最大の学びは、「エラーメッセージの表面的な意味だけでなく、その裏にある仕組みを理解することの重要性」だった。
「型 'string' の引数を型 'void' のパラメーターに割り当てることはできません」
このエラーメッセージだけ見ると、単純な型の不一致に見える。しかし実際は
- TypeScriptのオーバーロード解決ルール(最後のシグネチャを選択 → void)
- vi.mock()が元の型定義を保持する問題
- vi.hoisted()による型情報の明示と実行順序の制御
これらが複雑に絡み合った結果だった。
vi.hoisted()という解決策も、「おまじない」ではなく、型情報の保持と実行順序の制御という2つの本質的な問題を解決するための合理的な設計だと理解できた。
Discussion