🙄

一日一処: Vitestで非同期かどうかを考えずにテストを実行すると、Mockなどのテストが通過しなくなる

2024/03/01に公開

戒め

非常に基礎的な内容だが、自分の過ちに気付けなかったため、戒めとしてこの記事を綴る。

テストフレームワーク

Vitestと題をつけているが、Jestでも変わらない。テスト以前にどのような処理でも非同期であるかどうかをしっかりと確認し、その後に影響がないのか、確認すべきだ。当たり前だが、意外とやらかしてしまう。

実例

getData.ts
type Obj = { value: number };
type Option = {
  process: () => Promise<Obj>;
  check: (obj: Obj) => boolean;
};

export async function getData(option: Option) {
  const { process, check } = option;
  const data = await process();
  if (data.value && check(data)) {
    console.log('success');
  }
}

このように、外部から引数を経由して非同期関数を渡し、内部では、awaitを使用して、待機させる。非同期処理が完了後、返却された内容が存在していることと、その情報を検証を行う。

getData.test.ts
describe('showData', () => {
  test('output data', () => {
    const process = vi.fn(async () => ({ value: 2000 }));
    const check = vi.fn((value) => !!value.value);

    getData({ process, check });

    expect(process).toHaveBeenCalledOnce()
    // passed
    expect(check).toHaveBeenCalledOnce()
    // error
  });
});

引数で渡した関数が内部で実行されるか確認するのは、このように簡単にテストを書くことができるが、2つ目がエラーとなってしまう。途中まで非同期関数であることを理解していたにも関わらず、変数に代入する必要がない場合は、awaitの存在を確実に忘れている。内部的には実行されているはずなのに、なぜテストが通過しないのか、しばらく考えた後、自身がawaitをつけず、関数の完了を待っていなかったことに恥じる。

getData.test.ts
describe('showData', () => {
  test('output data', async () => {
    const process = vi.fn(async () => ({ value: 2000 }));
    const check = vi.fn((value) => !!value.value);

    await getData({ process, check });

    expect(process).toHaveBeenCalledOnce()
    // passed
    expect(check).toHaveBeenCalledOnce()
    // passed
  });
});

たった、関数を非同期化して、awaitをつけるだけ。それだけなのに、関数が実行されなかった条件はなんだろうか、実はまだ誰も遭遇していない特殊な環境下でのエラーではないか、と一大発見のように考えを巡らせたことを恥じたい。本当に恥ずかしい。
他に改善するとしたら、例えば、このような書き方でもありかもしれない。

getData.test.ts
describe('showData', () => {
  const process = vi.fn(async () => ({ value: 2000 }));
  const check = vi.fn((value) => !!value.value);

  test('call function', () => {
    expect(getData({ process, check })).resolves.not.toThrow()
    // passed
  })

  test('call function of option', async () => {
    expect(process).toHaveBeenCalledOnce()
    // passed
    expect(check).toHaveBeenCalledOnce()
    // passed
  });
});

関数の呼び出し自体もテストケースとして、実行後に更に細かなテストを実行するパターンだ。関数の呼び出しと、他の呼び出しとを分離しているので、ある側面では、見やすいテストコードかもしれない。一番いいのは、関数の実行そのものもしっかりとテストできる点だろう。何も返却されないということ自体をしっかりとテストできていれば、今回の初歩的なミスは起きなかった。

他のパターンとしては、こういうのもありだろう。

getData.test.ts
describe('showData', () => {
  test('call function', async () => {
    expect.assertions(2);

    const process = vi.fn(async () => ({ value: 2000 }));
    const check = vi.fn((value) => !!value.value);

    await getData({ process, check }).then(() => {
      expect(process).toHaveBeenCalledOnce();
      expect(check).toHaveBeenCalledOnce();
    });
  });
});

非同期関数の実行後という点では、Promise.thenを用いたほうが、確実にそのテスト直後の結果であることがわかる。ネストが一つ深くなってしまうのは、微妙なラインだが、このテストケースを見たときに、即座に非同期関数に関連するテストであることがわかるので、前述の例に比べれば、理解は容易いかもしれない。

はたまた、このようなパターンももしかしたらあり得るかもしれない。

getData.test.ts
describe('showData', () => {
  const reach = (title: string) => expect(title).not.toBeNull()

  test('call function', async () => {
    expect.assertions(3);

    const process = vi.fn(async () => {
      reach('reached process func')
      return { value: 2000 }
    });
    const check = vi.fn((value) => {
      reach('reached check func')
      return !!value.value
    });

    expect(getData({ process, check })).resolves.not.toThrow();
  });
});

モックが実行されたのか、expectを事後で用いるのではなく、モック内部でexpectさせる方法だ。本当は、expect.extendでマッチャーを追加したほうが良いのだろうが、突貫でアロー関数に押し込んでいる。書き方は、検討しなければならないポイントがあるものの、モック自体が呼ばれているということを確実にともに明記できるのは良さそう。(実際もっと簡単な方法を見たことある気がするが、検索して見つけ出せなかった)

おわりに

これまで経験したことであっても、些細なことで、そのミスに気づくことができないときもある。そういうときは、このように、書き方の工夫などの方針を決めておくと、ミスなくスムーズにコーディングできるのではないかと考えている。
あとは、自分を過信しすぎずに書いたプログラムは常に疑おうと決めた(何回目か)。

Discussion