Promiseの結果で状態更新する場合、全体をactで包んでもダメ
前回はテストコードの中でsetValueを直接呼んだが、今回はasyncUpdateというPromiseのthenで呼ぶ形にする。
例えばネットワークアクセスをした結果や、IndexedDBの読み出し結果などはPromiseの形になってることが多い。テストの際にモックで置き換えたとしてもPromiseであることは変わらないので、こういう形での非同期な状態更新が行われる。
これはテストに失敗する。
code:test.tsx
test("MyComponent2", async () => {
type TSetState = React.Dispatch<React.SetStateAction<number>>;
let setValue: TSetState | undefined;
const exportSetValue = (s: TSetState) => {
setValue = s;
};
const asyncUpdate: Promise<number> = new Promise((resolve) => {
resolve(1);
});
render(<MyComponent exportSetValue={exportSetValue} />);
expect(screen.getByText("0")).toBeTruthy();
expect(setValue).toBeTruthy();
act(() => {
asyncUpdate.then((x) => setValue!(x));
});
expect(screen.queryByText("0")).toBeNull(); // fails
expect(screen.getByText("1")).toBeTruthy();
});
そして、前回actでラップしなかった時に出た警告 Warning: An update to MyComponent inside a test was not wrapped in act(...). がまた出る。
ラップしてるじゃん?何を言ってるのか?と思いそうになるが、つまりこのコードでは適切にラップできてないというのが問題の本質。
Promiseの振る舞いについておさらい。
プロミスは非同期であることが保証されていることに注意してください。したがって、既に「解決済み」のプロミスに対するアクションは、スタックがクリアされ、クロックティックが経過した後にのみ実行されます。この効果は setTimeout(action,10) とよく似ています
つまり下記のコードのconsole.logの順番の通り、setValueはactの外で呼ばれる。
code:ts
console.log(1);
act(() => {
console.log(2);
asyncUpdate.then((x) => {
console.log(5);
setValue!(x);
});
console.log(3);
});
console.log(4);
expect(screen.queryByText("0")).toBeNull(); // fails
ならばどうすれば良いかというと、setValueを直接actでラップして、await asyncUpdate.thenする。これで警告なくテストが通る。
code:test.tsx
test("MyComponent3", async () => {
...
await asyncUpdate.then((x) => {
act(() => {
setValue!(x);
});
});
expect(screen.queryByText("0")).toBeNull(); // OK
expect(screen.getByText("1")).toBeTruthy();
});
このawaitを見て「あれ?actにawaitつけたらどうなるんだろ?」と試してみたら「actはプロミスを返さないのでawaitするな」と親切な警告が出た。
code:ts
await act(() => {
asyncUpdate.then((x) => setValue!(x));
});
code:warning
Warning: Do not await the result of calling act(...) with sync logic, it is not a Promise.
なおasync / awaitを使わない素朴な書き方もできる。ロジックは同じ。
これらのケースでは async や await は事実上、promiseを使用した例と同じロジックの糖衣構文です。
code:test.tsx
test("MyComponent4", () => {
...
return asyncUpdate
.then((x) => {
act(() => {
setValue!(x);
});
})
.then(() => {
expect(screen.queryByText("0")).toBeNull(); // OK
expect(screen.getByText("1")).toBeTruthy();
});
});
さて、Promiseによって非同期にsetValueされる場合にどうすべきであるのか、原理のところは理解できた。
次に解決するべき問題は?
「ユーザがボタンをクリックしたら、ネットワークアクセスをして結果を表示」というシナリオを考えてみると、PromiseがsetValueするコードはテストコードの中ではなく本体コードの側で一塊のイベントハンドラになってる場合が多い。
この本体コードに手を入れてsetValueをactで包むのは現実的ではない。
さあどうするか?というところで続きは次回。
code:ts
test("MyComponent5", async () => {
...
const userEventHandler = () => {
asyncUpdate.then((x) => {
setValue!(x);
});
};
render(<MyComponent exportSetValue={exportSetValue} />);
expect(screen.getByText("0")).toBeTruthy();
expect(setValue).toBeTruthy();
userEventHandler(); // Here
expect(screen.queryByText("0")).toBeNull(); // fails
expect(screen.getByText("1")).toBeTruthy();
});