Open19

VitestのspyOnの挙動について

kagomekagome

以下のテストコードを実行した際、最後のテストのexpect(calc.add(1, 2)).toBe(undefined)は通りますか?

import { calc } from "./calc2"

test("mockClear", () => {
  const spy = vi.spyOn(calc, "add").mockReturnValue(100)
  calc.add(1, 2)
  expect(calc.add).toHaveBeenCalledTimes(1)
  spy.mockClear()
  expect(calc.add).not.toHaveBeenCalledTimes(1)
  expect(calc.add(1, 2)).toBe(100)

  calc.add(2, 3)
})

test("", () => {
  expect(calc.add).toHaveBeenCalledTimes(2)
})

test("mockReset", () => {
  const spy = vi.spyOn(calc, "add")
  calc.add(1, 2)
  calc.add(1, 2)
  expect(spy).toHaveBeenCalledTimes(2)
  spy.mockReset()
  expect(spy).toHaveBeenCalledTimes(0)
  expect(calc.add(1, 2)).toBe(undefined)
})
kagomekagome

expect(calc.add(1, 2)).toBe(undefined) というテストは通りません。

このテストが通らない理由は以下の通りです:

  1. vi.spyOn(calc, "add") は元の add 関数をスパイするだけで、関数の実装を置き換えるわけではありません。
  2. mockReset() はモックの呼び出し履歴と設定をリセットしますが、元の実装を削除してundefinedを返すようにするわけではありません。
  3. mockReturnValue を使って明示的に戻り値を指定しない限り、スパイされた関数は元の実装を使用します。

最初のテストケースでは mockReturnValue(100) で戻り値を100に設定していますが、3つ目のテストケースではそのような設定をしていないため、元の calc.add 関数が実行され、その実際の戻り値(おそらく3)が返されます。

テストを通すためには、3つ目のテストケースでも spy.mockReturnValue(undefined) を使って明示的に戻り値を設定する必要があります。

kagomekagome

Vitestの公式ドキュメントのmockResetの欄には以下のように書いてあるのですが、mockReset()を実行するとundefinedが返るのでないのですか?

function mockReset(): MockInstance<T>
Does what mockClear does and resets inner implementation to the original function. This also resets all "once" implementations.

Note that resetting a mock from vi.fn() will set implementation to an empty function that returns undefined. resetting a mock from vi.fn(impl) will restore implementation to impl.

This is useful when you want to reset a mock to its original state.

To automatically call this method before each test, enable the mockReset setting in the configuration.
kagomekagome

ご質問いただき、ありがとうございます。Vitestのドキュメントを確認しましょう。

おっしゃる通り、mockReset()についてのドキュメントには次のように記載されています:

Does what mockClear does and resets inner implementation to the original function. This also resets all "once" implementations.

Note that resetting a mock from vi.fn() will set implementation to an empty function that returns undefined. resetting a mock from vi.fn(impl) will restore implementation to impl.

ここで重要なのは:

  1. vi.fn()からのモック のリセット:undefinedを返す空の関数に設定されます
  2. vi.spyOn()からのモック のリセット:元の実装に戻します

あなたのコードでは vi.spyOn(calc, "add") を使用しています。これは既存のオブジェクトのメソッドを監視するスパイを作成しており、vi.fn()とは異なります。

mockReset()を呼び出した場合:

  • スパイの場合(vi.spyOn()):元の実装に戻ります(テストでいうとcalc.addの実際の実装)
  • 純粋なモック関数の場合(vi.fn()):undefined を返す空の関数になります

したがって、テストの最後の期待値 expect(calc.add(1, 2)).toBe(undefined) は通りません。スパイされた関数はmockReset()によって元の実装に戻るため、おそらく3が返されます。

もしundefinedを返してほしい場合は、vi.spyOn()の代わりに直接置き換える方法を使うか、mockReturnValue(undefined)を明示的に呼び出す必要があります。

kagomekagome

vi.spyOnは内部でvi.fn()を使用しているのかと思っていましたが、違うのでしょうか。

kagomekagome

いい質問ですね。確かに、vi.spyOn()は内部でvi.fn()を使用しています。しかし、両者には重要な違いがあります:

  1. vi.fn()は単純に新しいモック関数を作成します。
  2. vi.spyOn()はオブジェクトの既存のメソッドを監視しつつ、そのメソッドをモック関数に置き換えます。この時、元の実装への参照も保持しています。

mockReset()の動作の違いは、この「元の実装への参照を保持している」という点から生じます:

  • vi.fn()から作成されたモックの場合:リセット後は単純にundefinedを返す空の関数になります。
  • vi.spyOn()から作成されたモックの場合:リセット後は元のメソッドの実装に戻ります。

Vitestのドキュメントには「resets inner implementation to the original function」と記載されています。これは、スパイされた関数がリセット後に元の実装に戻ることを意味します。

つまり、vi.spyOn(calc, "add")で作成されたスパイに対してmockReset()を呼び出すと、そのスパイはcalc.addの元の実装(おそらく(a, b) => a + bのような関数)に戻ります。そのため、calc.add(1, 2)undefinedではなく3を返すでしょう。

もし明示的にundefinedを返すようにしたい場合は、mockReset()の後にmockReturnValue(undefined)を呼び出す必要があります。

kagomekagome

vi.spyOn()はオブジェクトの既存のメソッドを監視しつつ、そのメソッドをモック関数に置き換えます。この時、元の実装への参照も保持しています。

とありますが、これは、テストごとにspyOnでモックをしたとしても、モック対象はひとつである、ということでしょうか?
つまり、vi.fn()では、インスタンスが作成されるが、spyOnはインスタンスが作成されるわけではない、という認識で合っていますか?

kagomekagome

おっしゃる通りです。重要な違いについて説明します:

vi.fn() vs vi.spyOn() の違い

vi.fn():

  • 新しいモック関数のインスタンスを作成します
  • 各呼び出しで独立したモック関数が生成されます
  • どこにも自動的に接続されません(手動で注入する必要があります)
const mock1 = vi.fn();
const mock2 = vi.fn();
// mock1とmock2は独立した別々のモック関数

vi.spyOn():

  • 既存のオブジェクトのプロパティ/メソッドを参照し、それをモック関数で置き換えます
  • 同じオブジェクトの同じプロパティに対する複数のspyOnは、同じ「場所」を指します
  • オブジェクトのプロパティを実際に書き換えます
// 両方とも calc.add を参照している
const spy1 = vi.spyOn(calc, "add");
const spy2 = vi.spyOn(calc, "add");
// calc.addは1つしかないため、spy1とspy2は同じ「場所」を監視している

テストごとのモックについて

テストごとにspyOnを使用する場合:

  1. 複数のテストで同じオブジェクトのメソッドをスパイしている場合
  2. 各テストは実際には同じメソッド(calc.addなど)への参照を変更している
  3. あるテストでの変更は、次のテストに影響する可能性がある

そのため、テスト間でモックの状態をリセットすることが重要です(mockResetmockRestoreを使用)。

認識の確認

vi.fn()では、インスタンスが作成されるようなイメージですが、spyOnはインスタンスが作成されているわけではない

はい、その認識は正確です:

  • vi.fn():新しいモック関数のインスタンスを作成
  • vi.spyOn():既存のメソッドへの参照を置き換え(新しいインスタンスを作るのではなく、既存の「場所」を変更)

これが、mockResetの挙動が両者で異なる理由でもあります。spyOnでモックした場合、元の実装への参照が保持されているため、リセット時にその元の実装に戻ります。

kagomekagome

では、vi.spyOnを実行した際に返される値は何なのでしょうか?

kagomekagome

vi.spyOn()を実行した際に返されるのは、MockInstance(またはMockFunctionオブジェクト)です。これは基本的には以下の特徴を持つオブジェクトです:

  1. モック関数: vi.fn()で作成されるのと同様のモック関数で、呼び出し可能です
  2. 追跡機能: 呼び出し回数、呼び出し時の引数などを記録します
  3. 制御メソッド: 以下のようなメソッドを持っています
    • mockReturnValue() - 戻り値の設定
    • mockImplementation() - 実装の置き換え
    • mockClear() - 呼び出し履歴のクリア
    • mockReset() - リセット
    • mockRestore() - 元の実装に完全に戻す
  4. 呼び出し履歴: .mock.calls.mock.resultsなどのプロパティで呼び出し履歴にアクセスできます

重要な点として、vi.spyOn()は以下のような処理を行っています:

  1. 対象オブジェクトの指定されたメソッドの元の実装を保存します
  2. そのメソッドをモック関数に置き換えます
  3. このモック関数は内部でvi.fn()を使って作成されますが、元の実装への参照も保持しています
  4. 返されるMockInstanceには元の実装に戻すための情報が含まれています

そのため、返されるオブジェクトは単なるモック関数ではなく、元のメソッドをスパイするための機能が追加された特殊なモック関数オブジェクト(MockInstance)となります。このオブジェクトを通して、元のメソッドの呼び出しを監視したり、挙動を変更したりできます。

kagomekagome

では、returnする値をテストごとに変更したい、というわけではない場合は、vi.spyOn()はテスト前に一度実行し、返ってくる値を各テストで共有しても良さそうですね?

kagomekagome

はい、その通りです。returnする値をテストごとに変更する必要がない場合、vi.spyOn()は一度実行して、返されるMockInstanceを複数のテストで共有することができます。

例えば、以下のようなアプローチが可能です:

// テストファイルのトップレベルでスパイを設定
const addSpy = vi.spyOn(calc, "add");

describe("Calculator tests", () => {
  // 各テストの前にスパイの履歴をクリア
  beforeEach(() => {
    addSpy.mockClear();
  });

  test("Test 1", () => {
    calc.add(1, 2);
    expect(addSpy).toHaveBeenCalledTimes(1);
  });

  test("Test 2", () => {
    calc.add(3, 4);
    expect(addSpy).toHaveBeenCalledTimes(1);
  });

  // 戻り値の変更が不要なら、全テストで元の実装のまま使える
});

このアプローチには以下のメリットがあります:

  1. コードの重複を減らせる: 各テストで同じspyOnコードを繰り返す必要がない
  2. 設定の一元管理: すべてのテストで使われるスパイの設定を一箇所で管理できる
  3. パフォーマンス: わずかですが、スパイを何度も設定し直すよりも効率的

ただし、以下の点には注意が必要です:

  1. テスト間の独立性: beforeEachmockClear()またはmockReset()を呼び出して、テスト間で呼び出し履歴が混ざらないようにする
  2. 戻り値を変更するテスト: もし一部のテストで戻り値を変更する場合、テスト終了時に元に戻すか、afterEachでリセットする

このアプローチは特に、スパイが単に呼び出しを記録するだけで、振る舞いを変更しない場合に適しています。

kagomekagome

vi.mocked についてご質問いただきありがとうございます。

vi.mocked はインスタンスを作成するものではありません。これは完全に異なる目的を持つユーティリティ関数です。

vi.mocked の役割

vi.mocked は主に TypeScript のタイプ支援のためのヘルパーです。既にモック化されたモジュールやオブジェクトに対して、TypeScript が正しい型情報を推論できるようにします。

例えば:

import { myFunction } from './myModule';
import { vi } from 'vitest';

vi.mock('./myModule');

// このままだと TypeScript は myFunction がモック関数だと認識できない
// vi.mocked を使うことでモック関数の型情報が得られる
const mockedFunction = vi.mocked(myFunction);

// これで TypeScript はモック関数のメソッドを認識できる
mockedFunction.mockReturnValue('mocked value');

vi.spyOn との違い

  • vi.spyOn - オブジェクトのメソッドを実際にモック関数に置き換え、その参照を返す
  • vi.mocked - モジュールやオブジェクトが既にモック化されている場合に、正しい型情報を提供する

インスタンスについて

vi.mocked は新しいオブジェクトやモック関数のインスタンスを作成するわけではありません。これは単に既存のモックに対して、TypeScript の型システムが正しく動作するようにするためのラッパー関数です。

実際の実行時には、vi.mocked は基本的に渡されたオブジェクトをそのまま返すだけで、何も変更を加えません(型情報のみを変更します):

// 簡略化された vi.mocked の内部実装イメージ
function mocked<T>(item: T): MockedObject<T> {
  return item as MockedObject<T>;
}

したがって、vi.mocked はインスタンスを作成せず、既にモック化されたオブジェクトへの参照をそのまま返し、型情報のみを付加するものと理解できます。

kagomekagome

vi.spyOnを使う際、モック対象を直接スパイできない、つまりcalc.add(1,2).toHaveBeenCalledとはできないので、代わりにvi.spyOnの返り値をスパイするわけですが、vi.mockedも同じような役割を担っている、という認識で合っていますでしょうか。

kagomekagome

そうですね、ご理解は部分的に正しいですが、少し違いもあります。

vi.spyOn の用途

vi.spyOn の場合:

// 正しい使い方
const addSpy = vi.spyOn(calc, "add");
calc.add(1, 2);
expect(addSpy).toHaveBeenCalledWith(1, 2);  // モック関数の参照を使って検証

// これは直接できない
// calc.add.toHaveBeenCalled()  // エラー

あなたが指摘した通り、calc.add 自体は直接アサーションできないため、vi.spyOn が返すモック関数の参照(addSpy)を使って検証します。

vi.mocked の用途

vi.mocked は少し異なります:

// モジュールをモック化
vi.mock('./calculator');

// 型情報のみを変換(実行時の動作は変わらない)
const mockedCalc = vi.mocked(calc);

// モック化されたメソッドを呼び出し
mockedCalc.add(1, 2);

// モック関数のプロパティにアクセスできるようになる
expect(mockedCalc.add).toHaveBeenCalledWith(1, 2);

vi.mocked の主な役割:

  1. 型変換: TypeScriptで、モック関数特有のメソッドやプロパティの型情報を提供する
  2. 既存のモック参照: 既にモック化されたモジュールやオブジェクトに対して使用する

両者の違い

  1. モック化のタイミング:

    • vi.spyOn: この関数自体がモック化を行う
    • vi.mocked: vi.mock() などで既にモック化された後に使用する
  2. 目的:

    • vi.spyOn: 実際にオブジェクトのメソッドをモックに置き換え、参照を返す
    • vi.mocked: 型情報のみを変換し、モック関数の型チェックを可能にする
  3. JavaScript vs TypeScript:

    • vi.spyOn: JavaScript でも TypeScript でも同じように使える
    • vi.mocked: 主に TypeScript で型の問題を解決するために使用される

つまり、vi.mocked は「モック関数への参照を取得する」という点では似ていますが、実際にモック化する代わりに、既にモック化されたオブジェクトの型情報を変換する役割を持ちます。TypeScript を使わない場合は vi.mocked を使う必要はありません。

kagomekagome

以下のようにmockedの返り値を使用せずに直接calc.addを使用しても問題ないのでしょうか?
両者の違いはなんでしょうか

import { calc } from "./calc2"

// モジュールをモック化
vi.mock("./calc2")

test("", () => {
  // 型情報のみを変換(実行時の動作は変わらない)
  const mockedCalc = vi.mocked(calc)

  // モック化されたメソッドを呼び出し
  mockedCalc.add(1, 2)

  // モック関数のプロパティにアクセスできるようになる
  expect(mockedCalc.add).toHaveBeenCalledWith(1, 2)
})


test("", () => {
  // 型情報のみを変換(実行時の動作は変わらない)
  vi.mocked(calc)

  // モック化されたメソッドを呼び出し
  calc.add(1, 2)

  // モック関数のプロパティにアクセスできるようになる
  expect(calc.add).toHaveBeenCalledTimes(1)
})
kagomekagome

ご質問の2つのコードの違いについて説明します:

mockedを使用するケースと使用しないケースの違い

// ケース1: mockedの結果を変数に保存
const mockedCalc = vi.mocked(calc);
mockedCalc.add(1, 2);
expect(mockedCalc.add).toHaveBeenCalledWith(1, 2);

// ケース2: mockedを呼び出すが結果を使わない
vi.mocked(calc);
calc.add(1, 2);
expect(calc.add).toHaveBeenCalledTimes(1);

実行時の動作については、両方とも同じように動作します。なぜなら:

  1. vi.mock("./calc2") によって、すでに calc オブジェクトは自動的にモック化されています
  2. vi.mocked() はランタイム(実行時)には何も変更を加えず、単に渡されたオブジェクトをそのまま返します
  3. したがって、mockedCalccalc は実行時には同じオブジェクトを参照しています

主な違いはTypeScriptの型情報にあります:

  1. TypeScriptの型システムでの違い:

    • vi.mocked(calc) を使わないと、TypeScriptはコンパイル時に calc.add がモック関数であることを認識できません
    • vi.mocked(calc) を使うと、返り値の型が変換され、TypeScriptは toHaveBeenCalled() などのモック関数のメソッドを認識できます
  2. エディタの支援:

    • mockedCalc.add と書くと、エディタがモック関数のメソッド(.mockReturnValue() など)を提案してくれます
    • calc.add のままだと、TypeScriptはこれを通常の関数と見なすため、モック関数特有のメソッドが提案されません

実用的な考慮点

  1. JavaScript のみの場合:

    • JavaScript だけを使っている場合、vi.mocked() は不要です(型情報がないため)
    • 直接 calc.add を使っても問題ありません
  2. TypeScript を使用する場合:

    • 型の安全性と入力補完のために const mockedCalc = vi.mocked(calc) を使用するのが良いでしょう
    • 特に大規模なコードベースでは、型の安全性が重要になります

簡潔に言えば、2番目のコード例も実行時には問題なく動作しますが、TypeScriptを使っている場合、型の安全性や開発体験を向上させるために1番目のアプローチ(const mockedCalc = vi.mocked(calc))を使う方が良いでしょう。

kagomekagome

おっしゃる通りですね。私の説明が不正確でした。訂正させてください。

実際のVitestの型定義について

Vitestの最新バージョンでは、テスト関数内での型処理が改善されています。多くの場合、vi.mock() を使用すると、Vitestは型システム内でも自動的にモック関数として認識されるようになっています。

vi.mock("./calc2");

// 多くの場合、これは型エラーにならない
calc.add.mockReturnValue(10);
expect(calc.add).toHaveBeenCalledTimes(1);

これは、Vitest/Jestのような最新のテストライブラリが、テスト環境内で自動的に型変換を行うための型定義の工夫をしているためです。

vi.mocked()が必要なケース

それでも、vi.mocked()が役立つケースはあります:

  1. 複雑な型や深くネストされたオブジェクト:自動的な型変換が機能しないケース
  2. 明示的な型情報が必要な場合:変数に保存して再利用する場合など
  3. より厳格な型チェック設定:TypeScriptの設定によっては必要になる場合も

修正された説明

正確には:

  1. 単純なケースでは、最新のVitestではvi.mock()だけで型チェックが通ることが多い
  2. vi.mocked()は、より複雑なケースや明示的な型変換が必要な場合に使用する
  3. TypeScriptプロジェクトでも必ずしも常に必要というわけではない

なぜ型エラーにならなかったか

  1. Vitestの型定義が賢くなっている(テスト用の特殊な型拡張がある)
  2. vi.mock()を使用した場合、テスト内でのみ有効な型の拡張が適用される
  3. expect()関数がテスト特有のコンテキストを提供し、適切な型推論をサポート

お詫びして訂正いたします。型システムの挙動は、使用しているライブラリのバージョンやTypeScriptの設定によって変わることがあります。私の説明は一部の環境では当てはまらない可能性があります。