非同期とネットワーク境界: 待つことを設計する
通信が絡むと、画面には状態が増えます。データが出ている状態だけでなく、まだ来ていない状態、来なかった状態、来たけれど 0 件だった状態があります。
後から書くと、この 3 つは忘れられがちです。開発中はローカルの API が即座に返るので読み込み中が見えず、エラーも起きないからです。
**先にテストで要求すると、3 つとも実装せざるをえません。**この章はその手順と、DOM の更新をどう待つかを扱います。
この章で学ぶこと
- 通信を伴う画面の状態を、実装より先に列挙する進め方
- 通信の境界を手書きのダブルテストダブル (Test Double)テストのために本物の代わりへ差し込むオブジェクトの総称。Dummy・スタブ・スパイ・モック・フェイクの 5 つに分けて呼ぶ。で置き換える形
- DOM の更新を待つ書き方と、固定時間で待つことの違い
09 振る舞いをテストする の書き方を使います。08 章で決めた API の形も参照します。
この章で扱わないこと
| 観点 | 参照先 |
|---|---|
| データ取得ライブラリの使い方、キャッシュの設計 | TanStack Query |
fetch の挙動、ネットワークまわりの調査 | デバッグ |
Vitest の非同期テスト全般 (Promise を await や resolves で待つ形) | TypeScript 開発ツールチェーン |
3 つ目には注意があります。参照先の「非同期テスト」は Promise の解決を待つ話で、**DOM が再描画されるのを待つ話ではありません。**この章で扱うのは後者です。
この章の開始時点のコード
08 章で API の形を決めました。
| 項目 | 内容 |
|---|---|
| 一覧 | GET /api/rooms/{roomId}/reservations → 予約の配列 |
09 章では、通信を伴わないフォームの検証まで作りました。ここから一覧の表示を足します。
4 つの状態を先に決める
実装を書く前に、画面が取りうる状態を列挙します。
| 状態 | 画面に出るもの |
|---|---|
| 読み込み中 | 読み込み中であることの表示 |
| 成功 (1 件以上) | 予約の一覧 |
| 成功 (0 件) | 予約が無いことの案内 |
| 失敗 | エラーと、やり直す手段 |
4 つです。この表がそのままテストの一覧になります。
**列挙を先にやることが、この章の主題です。**実装しながら考えると、成功の分岐を書いた時点で動くので、残り 3 つを後回しにできてしまいます。
通信の境界を手書きのダブルにする
コンポーネントの中で直接 fetch を呼ぶと、テストからは差し替えられません。03 章の型 1 と同じです。
取得の手段を外から渡します。
type FetchReservations = (roomId: string) => Promise<Reservation[]>
type Props = {
roomId: string
fetchReservations: FetchReservations
}
export function ReservationList({ roomId, fetchReservations }: Props) {
// ...
}
テストでは、その場で関数を書いて渡します。
// Stub: 決まった値を返す
const returning = (items: Reservation[]): FetchReservations =>
() => Promise.resolve(items)
// Stub: 失敗する
const failing = (): FetchReservations =>
() => Promise.reject(new Error('network'))
// 解決を手元で制御する
const deferred = () => {
let resolve!: (items: Reservation[]) => void
const promise = new Promise<Reservation[]>((r) => { resolve = r })
return { fetch: (() => promise) as FetchReservations, resolve }
}
ライブラリは使いません。返す値を決めるだけなら、この 3 つで足ります。
一覧に並べるデータも、その場で作ります。
const reservation = (over: Partial<Reservation> = {}): Reservation => ({
id: 'r-1',
room: '会議室 A',
start: '13:00',
end: '14:00',
...over,
})
DOM の更新を待つ
通信が挟まると、render した直後にはまだ結果が出ていません。DOM が更新されるのを待つ必要があります。
| 書き方 | 意味 | 使いどころ |
|---|---|---|
getByText | いま在るものを探す。無ければその場で失敗 | 描画直後から在るもの |
findByText | 現れるまで待って探す。既定で 1 秒ほど | 通信の後に出るもの |
queryByText | 無ければ null を返す。待たない | 無いことの確認 |
waitFor | 中の式が通るまで繰り返す | 要素の取得以外の条件 |
findBy* は getBy* と waitFor を合わせたものです。要素を待つだけなら findBy* で足ります。
固定時間の待機は使いません。
// 使わない
await new Promise((r) => setTimeout(r, 100))
expect(screen.getByText('会議室 A')).toBeInTheDocument()
これは 2 つの理由で壊れます。遅い環境では 100 ミリ秒で足りずに落ち、速い環境では無駄に 100 ミリ秒待ちます。テストが増えるとその合計が効いてきます。findBy* は条件が満たされた時点で進むので、どちらも起きません。
4 つの状態をテストにする
列挙した順に書きます。
it('読み込み中は読み込み中と出る', () => {
const { fetch } = deferred()
render(<ReservationList roomId="room-1" fetchReservations={fetch} />)
expect(screen.getByText('読み込み中')).toBeInTheDocument()
})
deferred を使うのは、解決しない状態で止めるためです。即座に解決する Stubスタブ (Stub)決まった値を返すだけのテストダブル。呼ばれ方を記録せず、検証もしない。 を渡すと、読み込み中の表示を捉えられないことがあります。
it('取得できたら一覧に出る', async () => {
render(
<ReservationList
roomId="room-1"
fetchReservations={returning([reservation({ room: '会議室 A' })])}
/>,
)
expect(await screen.findByText('会議室 A')).toBeInTheDocument()
})
it('0 件なら予約が無いと案内する', async () => {
render(
<ReservationList roomId="room-1" fetchReservations={returning([])} />,
)
expect(await screen.findByText('この部屋の予約はありません')).toBeInTheDocument()
})
it('失敗したらエラーと再試行の手段を出す', async () => {
render(
<ReservationList roomId="room-1" fetchReservations={failing()} />,
)
expect(await screen.findByRole('alert')).toHaveTextContent('読み込めませんでした')
expect(screen.getByRole('button', { name: '再試行' })).toBeInTheDocument()
})
4 本目が大事です。エラーの表示だけでなく、やり直す手段があることを要求しています。エラーを出すだけの画面は、利用者にとって行き止まりです。テストに書いておくと、実装が省略できません。
消えることも確かめる
読み込みが終わったら、読み込み中の表示は消えるはずです。これは queryBy* で見ます。
it('取得が終わると読み込み中は消える', async () => {
const { fetch, resolve } = deferred()
render(<ReservationList roomId="room-1" fetchReservations={fetch} />)
resolve([reservation({ room: '会議室 A' })])
expect(await screen.findByText('会議室 A')).toBeInTheDocument()
expect(screen.queryByText('読み込み中')).not.toBeInTheDocument()
})
queryByText を使うのは、getByText が見つからないときに失敗するからです。無いことを確かめるには、無くても失敗しない取り方が要ります。
順序にも意味があります。先に findByText で一覧が出るのを待ってから、読み込み中が消えたことを見ます。逆にすると、まだ描画が進んでいない時点で「消えている」と判定してしまい、実装が壊れていても通ります。
まとめ
- 通信を伴う画面は 4 つの状態を持ちます。実装より先に列挙すると、後回しにできなくなります
- 取得の手段を props で受け取ると、テストは関数を渡すだけで済みます。ライブラリは要りません
- 現れるのを待つのは
findBy*、無いことを見るのはqueryBy*です。固定時間の待機は使いません - エラーの状態では、表示だけでなくやり直す手段もテストで要求します
- 「消えた」を確かめるときは、先に「出た」を待ってから見ます
次に読む
- 11 ロジックをフックへ押し出す — この画面のロジックを切り出すかどうかを判断します
- TanStack Query — 取得の仕組みをライブラリに任せる場合の設計