メインコンテンツまでスキップ

非同期とネットワーク境界: 待つことを設計する

通信が絡むと、画面には状態が増えます。データが出ている状態だけでなく、まだ来ていない状態、来なかった状態、来たけれど 0 件だった状態があります。

後から書くと、この 3 つは忘れられがちです。開発中はローカルの API が即座に返るので読み込み中が見えず、エラーも起きないからです。

先にテストで要求すると、3 つとも実装せざるをえません。この章はその手順と、DOM の更新をどう待つかを扱います。

この章で学ぶこと​

  • 通信を伴う画面の状態を、実装より先に列挙する進め方
  • 通信の境界を手書きのダブルで置き換える形
  • 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 を呼ぶと、差し替える口がコンポーネントの引数にありません。テストから差し替えるには、グローバルの fetch を書き換える (Vitest の vi.stubGlobal) か、通信そのものを横取りする (Mock Service Worker など) ことになり、どちらもコンポーネントの外の仕組みに頼ります。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 を渡すと、読み込み中の表示を捉えられないことがあります。

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 で一覧が出るのを待ってから、読み込み中が消えたことを見ます。逆にすると、resolve した直後はまだ再描画が起きていないので、正しい実装でも読み込み中の表示が残っていて落ちます。一方で、読み込み中の表示を遅れて出し、取得が終わっても消さない壊れた実装は、まだ表示が出ていない時点で「消えている」と判定されて通ってしまいます。

まとめ​

  • 通信を伴う画面は 4 つの状態を持ちます。実装より先に列挙すると、後回しにできなくなります
  • 取得の手段を props で受け取ると、テストは関数を渡すだけで済みます。ライブラリは要りません
  • 現れるのを待つのは findBy*、無いことを見るのは queryBy* です。固定時間の待機は使いません
  • エラーの状態では、表示だけでなく、やり直すボタンがあることもテストで要求します
  • 「消えた」を確かめるときは、先に「出た」を待ってから見ます

次に読む​