📘

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つの本質的な問題を解決している

  1. 型情報の明示的な指定(最重要):vi.hoisted()で型付きオブジェクトを作り、vi.mock()がそれを参照する
  2. 実行順序の制御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