Skip to main content

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

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

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

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

この章で学ぶこと

  • 通信を伴う画面の状態を、実装より先に列挙する進め方
  • 通信の境界を手書きのダブルで置き換える形
  • DOM の更新を待つ書き方と、固定時間で待つことの違い
前提知識

09 振る舞いをテストする の書き方を使います。08 章で決めた API の形も参照します。

この章で扱わないこと

観点参照先
データ取得ライブラリの使い方、キャッシュの設計TanStack Query
fetch の挙動、ネットワークまわりの調査デバッグ
Vitest の非同期テスト全般 (Promise を awaitresolves で待つ形)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 を渡すと、読み込み中の表示を捉えられないことがあります。

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* です。固定時間の待機は使いません
  • エラーの状態では、表示だけでなくやり直す手段もテストで要求します
  • 「消えた」を確かめるときは、先に「出た」を待ってから見ます

次に読む