「Test Isolation with React」という記事を読んだ

テストは他のテストに影響を与えないようにちゃんと分離しましょうという記事。 先行するテストに依存してはいけない。(例えば先行するテストでrenderしといて後続でそれを利用するとか。変数セットしといて後のやつが利用するとか。)

この記事でも、テストは機能ごとに着目するよりユースケースに着目して書いたほうが良いよということが書かれていた。


記事

Test Isolation with React

「Write fewer, longer tests」という記事を読んだ

テストを書くときに「1テスト1アサーションにこだわる必要はないよ、長くなっても大丈夫だよ」という記事。

それが提唱されたのは昔はテストが失敗したときに出される情報が少なかったらだとか。 つまり、複数アサーションを書いてるとどのアサーションが失敗したかわからなかったのだろう。

React Testing Libraryはアサーションに失敗するとどのアサーションが失敗したかわかりやすく表示してくれる。

コンポーネントの要件リストのまんまテストを書くよりもストーリー(こうするとこう表示される、こう動く)にしたがって、 テストを書いたほうが良いのだとも記事を読んで思った。


記事リンク

Write fewer, longer tests

Reactのテストでa test was not wrapped in act(...)が出た

const user = userEvent.setup();
render(<Component />);

const link = screen.getByRole("link");
await user.click(link)
expect(...)

のようなコードを書いていたらタイトルのようなWarningが出た。 割とよくあることなのか解決方法も同時に出てstateを更新するイベントを発火するやつはactで囲めと書いてある。

ただ、単にactで囲んでact(async() => await user.click(link))でいけるかと思いきや解決しなかった。

react-act-examples/sync.md at master · threepointone/react-act-examples · GitHub

を見ると、actがPromiseを返すのでこれをawaitで待ってやれば良さそうだった。 つまり、

const user = userEvent.setup();
render(<Component />);

const link = screen.getByRole("link");
await act(() => user.click(link))
expect(...)

こう書くとWarningが出ずにテストが通った。

うまくいかないときにError: Not implemented: navigation (except hash changes)というエラーが出たりもして困っていたがこれも上記で同時に解決した。

React + Jest + jsdomでfetchが絡むテストを実行するとTypeErrorが出る

node_modules/jsdom/lib/jsdom/browser/Window.js:376
      return idlUtils.wrapperForImpl(idlUtils.implForWrapper(window._document)._location);
                                                                              ^

TypeError: Cannot read properties of null (reading '_location')

React + Jestでテストを書いているとこういうエラーが出た。スタックトレースを見ると、mswjsから来てる。 問題切り分けてると非同期処理(fetch)があるときに、waitForで再描画待たずにテストを終わらせるとこれが出るっぽい。


追記

これが出るときもあれば出ないときもある不安定な状態になった・・・


さらに追記

`whatwg-fetch` requests don't clear when server is `close()`d, leading to an error · Issue #1684 · mswjs/msw · GitHub

これを踏んでたっぽい。自分しか使わないプロダクトなので一旦

node v18 で導入された global fetch を jsdom 環境下の jest で利用する方法 - Qiita

これで回避することにした。

fetchを呼び出してるReactコンポーネントのテストをしたい(調査中メモ)

import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { useEffect } from 'react';

function Heading({ callback }: { callback: () => void }) {
    useEffect(() => {
        async function f() {
            await fetch('https://localhost/');
            callback();
        }

        f();
    }, [callback]);
    return <h1>Hello</h1>;
}

describe('Heading', () => {
    it('sample test', async () => {
        const mock = jest.fn();
        render(<Heading callback={mock} />);
        expect(screen.getByRole('heading', { name: 'Hello' })).toBeInTheDocument();
        waitFor(() => expect(mock).toHaveBeenCalled());
  });
});

サンプルでこのコードに特に意味はない。最初にコンポーネントレンダリングされるときにAPI経由で何かデータ取ってきて それを表示したい、そのためのテストコードのサンプル。 これをテスト実行するとReferenceError: fetch is not definedが出る。testEnvironment: "jsdom"だとこうなるらしい。 Node18なのでnode環境ならfetchはあるはず。

調べているとテストにはMock Service Workerを使えというのやjest-fetch-mockを使えというのがあった。これから試してみる。

JestでNode.jsのfetchをテストしたい

背景

内部でfetchを呼んでいる関数をテストしたいです。 Nodeのv18以降ではnode-fetchなしでfetchが使えるようになりましたが、node-fetchを使っているサンプルが多く組み込みのfetchを使っているサンプルが少ないので調べました。

結論

node:testモジュールのmock.methodを使うか、Jestのjest.spyOnを使います。

mock.methodの場合

import { mock } from "node:test"

// テスト対象メソッド。成功すると"Hello"が返ってくる。
async function fetchData() {
    const res = await fetch("https://example.com/");
    if (!res.ok) {
        return "NG";
    }

    return res.text();
}

it("fetchに成功するとHelloを返す", async () => {
    mock.method(global, "fetch", () => Promise.resolve(new Response("Hello"));
    const result = await fetchData();
    expect(result).toBe("Hello");
    mock.reset();
});

jest.spyOnの場合

// テスト部分のみ
it("fetchに成功するとHelloを返す", async () => {
    const spy = jest.spyOn(global, "fetch");
    spy.mockImplementation(() => Promise.resolve(new Response("Hello")));

    const result = await fetchData();
    expect(result).toBe("Hello");
    spy.mockRestore();
});

fetchで一定時間応答がなければタイムアウトしたい

AbortSignal.timeout()が使えます。

// 10秒でタイムアウト
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });

タイムアウトすると、TimeoutErrorが例外で投げられるのでこれをcatchして処理します。