Vitestでクラスベースライブラリのモックに躓いた背景

画像ダウンロード機能のユニットテストを作成中に、JSZipライブラリのモックでテストが失敗する問題に遭遇しました。

正直なところ、このエラー原因の特定に予想以上に時間がかかりましたが、解決できたおかげでテスト実行が安定して通りやすくなりました。

本記事では、実際の開発現場で遭遇した「not a constructor」エラーの具体的な解決策をコード例と共に解説します。

こんな人におすすめ

  • Vitestでクラスベースライブラリをモックしたい方
  • 「not a constructor」エラーで困っている開発者
  • JSZipのテストを書いているエンジニア
  • new キーワードで呼び出すライブラリのモック方法を知りたい方

目次

「not a constructor」エラーの症状

テスト実行時に、JSZipのインスタンス化でエラーが発生しました。

TypeError: () => mockZip is not a constructor

このエラーメッセージからは、モックの定義に問題があることが読み取れませんでした。

実際にデバッグに時間を要しましたが、原因はモックの定義方法にありました。

Vitestでクラスベースライブラリのモックが失敗する原因

JSZipは new JSZip() でインスタンス化するクラスベースのライブラリです。

Vitestで vi.fn(() => mockZip) を使うと、関数として呼び出されることを期待してしまい、new キーワードでのコンストラクタ呼び出しに対応できません。

NG: 関数ベースのモック(失敗例)

vi.mock('jszip', () => {
  const mockZip = {
    file: vi.fn(),
    generateAsync: vi.fn().mockResolvedValue(new Blob(['mock'])),
  };
  return { default: vi.fn(() => mockZip) };
});

このモック定義では、vi.fn() で作成された関数を返しているため、new キーワードでの呼び出し時に「not a constructor」エラーが発生します。

なぜ失敗するのか: vi.fn() は関数オブジェクトを作成しますが、これは new キーワードでコンストラクタとして呼び出すことはできません。

以下のシーケンス図で、関数ベースのモックが失敗する流れを確認できます。

sequenceDiagram
    participant Test as テストコード
    participant Vitest as vi.mock()
    participant Mock as vi.fn()(関数)
    participant Error as TypeError

    Test->>Vitest: vi.mock('jszip', ...) を定義
    Vitest->>Mock: vi.fn(() => mockZip) を返す
    Note over Mock: 関数オブジェクト(コンストラクタではない)

    Test->>Mock: new JSZip() を呼び出し
    Mock-->>Error: ❌ "not a constructor"

    Note over Error: new キーワードで<br/>関数をインスタンス化できない

【解決策】Vitestでクラスを正しくモックする方法

モックをクラス定義に変更します。

OK: クラスベースのモック(正解例)

vi.mock('jszip', () => {
  return {
    default: class MockJSZip {
      file = vi.fn();
      generateAsync = vi.fn().mockResolvedValue(new Blob(['mock zip content']));
    },
  };
});

このモック定義では、クラス構文を使ってモックを定義しているため、new キーワードでの呼び出しに正しく対応できます。

ポイント:

  • class 構文を使ってモックを定義する
  • クラスプロパティとして vi.fn() を設定する
  • default エクスポートにクラスを返す

以下のシーケンス図で、クラスベースのモックが成功する流れを確認できます。

sequenceDiagram
    participant Test as テストコード
    participant Vitest as vi.mock()
    participant Mock as class MockJSZip
    participant Methods as モックメソッド

    Test->>Vitest: vi.mock('jszip', ...) を定義
    Vitest->>Mock: class MockJSZip を返す
    Note over Mock: クラス定義(コンストラクタとして機能)

    Test->>Mock: new JSZip() を呼び出し
    Mock-->>Test: ✅ インスタンスを返す

    Test->>Methods: zip.file() を呼び出し
    Methods-->>Test: モックされた振る舞いを返す
    Test->>Methods: zip.generateAsync() を呼び出し
    Methods-->>Test: mockResolvedValue を返す

    Note over Methods: クラスプロパティとして<br/>vi.fn() が設定されている

Vitestクラスモックの基本パターン

クラスベースのライブラリをモックする際の基本パターンは以下の通りです。

vi.mock('ライブラリ名', () => ({
  default: class MockClassName {
    メソッド名 = vi.fn();
    プロパティ名 = 値;
  },
}));

このパターンはJSZip以外にも、以下のようなクラスベースライブラリで活用できます。

  • ファイル操作系ライブラリ(FileSaver.jsなど)
  • 画像処理系ライブラリ
  • APIクライアント系ライブラリ

モック方法の選択ガイド

ライブラリの特性に応じて、適切なモック方法を選択するフローチャートです。

flowchart TD
    A[ライブラリをモックする] --> B{new キーワードで<br/>呼び出すか?}

    B -->|はい| C[クラスベースのモック]
    B -->|いいえ| D[関数ベースのモック]

    C --> E{default exportか?}
    E -->|はい| F["return: default = class MockClass"]
    E -->|いいえ| G["return: ClassName = class MockClass"]

    D --> H{default exportか?}
    H -->|はい| I["vi.fn または<br/>vi.fnでreturnValueを返す"]
    H -->|いいえ| J["methodName: vi.fnを設定"]

    F --> K[✅ new ライブラリ名 で呼び出し可能]
    G --> K
    I --> L[✅ 通常の関数呼び出しで使用]
    J --> L

    style C fill:#90EE90
    style D fill:#87CEEB
    style K fill:#FFD700
    style L fill:#FFD700

使い方:

  1. ライブラリのドキュメントで new キーワードの使用を確認
  2. 上記フローチャートに従ってモック方法を選択
  3. 適切な vi.mock() パターンを適用

クラスベースのモック導入で得られた効果

クラスベースのモックに変更後、以下の効果が得られました。

  • テスト実行が安定して通りやすくなりました
  • テストコードが実際のJSZipの使用方法に近くなり、可読性が向上しました
  • 他のクラスベースライブラリ(例:ファイル操作系ライブラリ)のモックにも同様のパターンを適用できるようになりました

正しいモック方法のおかげで、テスト実行時間が短縮され、デバッグ効率が向上しました。

Vitestでクラスモックする際のポイントまとめ

クラスベースライブラリをモックする際は、以下の点に注意が必要です。

  • モックもクラスとして定義する必要があります - vi.fn() ではなく class 構文を使用
  • new キーワードで呼び出されるライブラリは、関数ではなくクラス構文でモックを書きます
  • VitestのES Module モックでは default エクスポートに注意します - 名前付きエクスポートの場合は適切に調整

関連するVitestのトラブルシューティング

Vitestでのテスト中に他の問題が発生した場合は、以下の記事も参考にしてください。

確認手順

  1. npm run test -- tests/utils/downloadImage.test.ts でテストを実行します
  2. 全テストがパスすることを確認します