この記事はシリーズ「動いているリポジトリを読む — Next.js 16 で学ぶクリーンアーキテクチャと TDD」の第 15 回(全 19 回)です。
実際に動いている公開リポジトリ kai-kou/gem-hunter(MIT)を1本まるごと読み解く連載です。架空のサンプルではなく実ファイルを引用し、掲載しているコマンド結果はすべて実際に動かして採取しています。
全体を通しで読みたい方へ: 同じ内容を 1 冊にまとめた Zenn Book を無料で公開しています。
シリーズ全体の目次
- 第 1 回 動いているリポジトリを読むという学び方
- 第 2 回 clone からテストが緑になるまで
- 第 3 回 要件IDとADRで、何を解くアプリなのかを地図にする
- 第 4 回 App Router の地図。フォルダがそのまま URL になる
- 第 5 回 'use client' が6ファイルしかないアプリの境界の引き方
- 第 6 回 「クリーンアーキテクチャだから」を理由にしない層の分け方
- 第 7 回 ブランド型で「検証済みの値」を型にする
- 第 8 回 DIコンテナを使わない依存性逆転
- 第 9 回 外部APIの語彙を持ち込ませない翻訳層の作り方
- 第 10 回 依存規則を440行のPythonで機械検査する
- 第 11 回 URLのクエリが画面に出るまでを5層ぶん追跡する
- 第 12 回 ライブラリなしのi18nと、画面の変化を目で見ていない人へ伝える実装
- 第 13 回 どの層を何でテストするかを表で決めておく
- 第 14 回 失敗するテストを先に書いて Red→Green を1周する
- 第 15 回 「実ネットワークに出ない」を設定1行で保証する(この記事)
- 第 16 回 外部APIに依存しないE2Eの組み方
- 第 17 回 赤くなったテストをどう判定するか、axeの限界とflakyの正体
- 第 18 回 生IPを残さないレート制限と、第三者HTMLを安全に表示する
- 第 19 回 CIは道具ではなくゲートの集合、そして自分のプロジェクトへの持ち帰り方
TL;DR
対象読者は、API を叩くコードや React コンポーネントのテストの書き方に迷っているエンジニアです。
- MSW の
onUnhandledRequest: 'error'が、「テスト中に実ネットワークへ出ない」を機械的に保証 します - MSW を使うと 送ったリクエストの中身も検証できます。セキュリティ要件の検証にも効きます
- Testing Library は 役割(role)で要素を取る。取れないならアクセシビリティの問題でもあります
環境を切り替えないという判断と、それをやめた理由
テストの設定ファイル vitest.config.mts の構造はこれだけです(全文は記事末尾に置きます)。
-
test.projectsに プロジェクトが 2 つ 並んでいる -
app:environment: 'jsdom'/setupFiles: ['./vitest.setup.ts']/ 対象は{app,src}/** -
tools:environment: 'node'/setupFiles: []/ 対象はtools/**/*.{test,spec}.mjs
vitest.setup.ts は 1 行 です。
import '@testing-library/jest-dom/vitest'
分割の理由は、設定ファイル自身の冒頭コメントに書かれています。ビルド時ツール(tools/)は実行時も node tools/... で走るのに、テストだけ jsdom 上で走らせると、fetch / URL / Buffer の実装差に依存する退行がテストをすり抜ける。jsdom の実装で通ってしまい、本番の Node 22 で壊れる、という失敗の形です。実際に SP-17 / PR #416 のセルフレビュー指摘として記録されています。「どこで壊れうるか」が具体的に言えたので分けた わけです。
読みどころは、それでも「書いていないこと」のほうです。
- MSW をグローバルで起動していない
- カバレッジの設定が無い
- 環境の切り替えは プロジェクト単位で 2 つだけ。ファイルごと・層ごとには切り替えていない
3 番目には経緯があります。かつては単一環境で、domain 層の純粋なロジックまで jsdom の上で走っていました。厳密には無駄ですが、切り替えの設定を持たないほうが単純だからです。その判断が変わったのは node 実行を前提とするビルド時ツールが増えたときで、そこでも粒度は最小に留めています。
MSW をグローバルに起動しないのは意図的です。MSW を使ってよいのは ACL のテストだけ という戦略(第 13 回)を、設定レベルで担保しています。グローバルに起動すると、どの層からでも気軽に HTTP をモックできてしまい、層の境界が緩みます。
MSW で HTTP 境界を止める
GitHub API を叩くクラスのテストです。
const requests: URL[] = []
const server = setupServer(
http.get('https://api.github.com/search/repositories', ({ request }) => {
requests.push(new URL(request.url))
return HttpResponse.json(fixture)
}),
http.get('https://api.github.com/repos/:owner/:repo', ({ request }) => {
requests.push(new URL(request.url))
return HttpResponse.json(detailFixture)
}),
// ...
)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
server.resetHandlers()
requests.length = 0
})
afterAll(() => server.close())
onUnhandledRequest: 'error' が要点
この 1 行が、第 2 回で「外部に出ないのは構造的に塞いであるから」と書いた仕組みの片方です。
定義していない URL へのリクエストが飛んだら、テストを失敗させます。
これが無いと、こういう事故が起きます。
- テストが実 API を叩いてしまい、たまたま通る
- CI で GitHub がレート制限を返し、原因不明の失敗になる
- ネットワークが無い環境(オフライン・サンドボックス)で動かなくなる
「外部に出ないこと」を 方針ではなく設定で保証 しています。
応用がひとつあります。src/composition/container.test.ts は setupServer() を ハンドラ 0 個 で張っています。返したい偽レスポンスは何もなく、狙いは onUnhandledRequest: 'error' だけ。ハンドラが空なら どのリクエストも「未登録」になり、その場で落ちる ので、composition root の配線が外部へ出ないことだけを担保できます。MSW を「偽の応答を返す道具」ではなく「出口を塞ぐ道具」として使う形です。
リクエストの中身も検証する
ハンドラの中で requests.push(new URL(request.url)) としているのが効いてきます。
it('検索 API を呼び出してドメインモデルを返す', async () => {
const result = await makeQuery().search(searchQuery({ keyword: 'react', page: 2 }))
expect(result.items).toHaveLength(2)
expect(requests[0].searchParams.get('q')).toBe('is:public react')
expect(requests[0].searchParams.get('page')).toBe('2')
expect(requests[0].searchParams.get('per_page')).toBe('20')
})
戻り値の変換だけでなく、送ったリクエストの組み立ても検証しています。 ここが単純なモックとの差です。
そしてこのテストが秀逸です。
it('検索クエリの先頭に is:public を置いて公開リポジトリに閉じる', async () => {
await makeQuery().search(searchQuery({ keyword: 'react' }))
// 🔴 完全一致で検証する。末尾に置くと、キーワード側の未知の構文(末尾の `NOT` 等)に
// この修飾子が吸収・否定されうるため、位置そのものが防御の一部になっている。
expect(requests[0].searchParams.get('q')).toBe('is:public react')
})
第 9 回で見た「非公開リポジトリを弾く」防御を、クエリ文字列の完全一致 で固定しています。toContain('is:public') ではなく toBe('is:public react')。
コメントの理由が本質です。「位置そのものが防御の一部」。もし将来リファクタで修飾子を末尾に移したら、このテストが落ちます。設計判断がテストで守られている わけです。
fixture は「縮めた実レスポンス」
__fixtures__/ に置かれた JSON は、GitHub API の実レスポンスから 使うフィールドだけを残して縮めたもの です。完全なレスポンスをそのまま保存すると、巨大で読めなくなります。
React コンポーネントのテスト
役割で取る
it('検索入力欄と送信ボタンがアクセシブルな名前で取得できる', () => {
render(<SearchForm keyword="" action="/ja" labels={labels} />)
expect(screen.getByRole('searchbox')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '検索' })).toBeInTheDocument()
})
getByRole('searchbox') は「検索欄という役割の要素」を探します。CSS クラスでも data-testid でもありません。
この書き方だと、要素が取れない=スクリーンリーダーからも扱いにくい、と分かります。 テストがアクセシビリティの検査を兼ねる(第 13 回)というのはこのことです。
もう 1 つ、契約を検証するテストがあります。
it('input の name が SEARCH_PARAM_KEYS.keyword(契約由来)になっている', () => {
render(<SearchForm keyword="" action="/ja" labels={labels} />)
expect(screen.getByRole('searchbox')).toHaveAttribute('name', SEARCH_PARAM_KEYS.keyword)
})
第 11 回で見た SEARCH_PARAM_KEYS を、テスト側でも import して突き合わせています。フォームの name と URL の解析が同じ定数を見ている ことが保証されます。
「無いこと」を検証する
it('1 ページ目では前ページリンクを出さない', () => {
render(
<Pagination
basePath="/ja"
current={{ keyword: 'react', page: 1, sort: 'relevance', perPage: 20 }}
totalCount={500}
labels={labels}
/>,
)
expect(screen.queryByRole('link', { name: '前のページへ' })).not.toBeInTheDocument()
expect(screen.getByText('前のページへ')).toHaveAttribute('aria-disabled', 'true')
})
getBy* と queryBy* の使い分けの決定版です。
| メソッド | 見つからないとき | 使いどころ |
|---|---|---|
getBy* |
例外を投げる | あるはずのものを取る |
queryBy* |
null を返す |
無いこと を検証する |
findBy* |
待ってから判定 | 非同期で現れるものを待つ |
このテストは 2 つを組み合わせて、「リンクとしては存在しないが、テキストは残っていて aria-disabled が付いている」 という状態を表現しています。
1 ページ目で「前のページへ」を完全に消すと、ボタンの位置がずれてレイアウトが揺れます。だから 残したまま無効化する。その仕様が、query の選び分けでそのまま表現されています。
user-event は使っていない
@testing-library/user-event は package.json に入っていますが、テストコードでは 1 箇所も使われていません(fireEvent も 0 件)。
ユーザーの操作フローは、すべて Playwright の E2E に寄せているからです。第 13 回の二重ループでいう「外側」に操作を集めた結果です。
「入っているのに使っていない依存」は本来なら整理対象 ですが、教材としては「操作の検証をどちらに寄せたか」が見える例になっています。
まとめ
-
onUnhandledRequest: 'error'が「実ネットワークに出ない」を 設定で保証 する - MSW なら 送ったリクエストの中身も検証できる。セキュリティ要件の固定にも使える
- MSW をグローバル起動しないことで「MSW は ACL だけ」の方針を担保する
- 環境の分割は
app(jsdom)とtools(node)の 2 プロジェクトだけ。分ける理由が実際に来てから、最小の粒度で分けた - Testing Library は 役割で取る。
getByTestIdは最後の手段 -
queryBy*で「無いこと」を検証し、getByTextと組み合わせて状態を表現する
`vitest.config.mts` の全文
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'
// 🔴 ランタイム分離(`test.projects`・Vitest 4 の推奨形)
// アプリ層(`{app,src}/**`)はブラウザ相当(jsdom + jest-dom)で、ビルド時ツール
// (`tools/**`)は実行時と同じ Node ランタイムで走らせる。両者を 1 つの environment に
// 混ぜると、Node 22 専用のツールが jsdom 上で実行され、`fetch` / `URL` / `Buffer` の
// 実装差に依存する退行がテストをすり抜ける(`SP-17` / PR #416 セルフレビュー指摘)。
export default defineConfig({
test: {
projects: [
{
plugins: [react()],
resolve: { tsconfigPaths: true },
test: {
name: 'app',
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
include: ['{app,src}/**/*.{test,spec}.{ts,tsx}'],
globals: true,
},
},
{
// ビルド時ツール(Gem 候補プール生成等)。実行は `node tools/...` なので
// テストも Node ランタイムで走らせ、jsdom 用の setupFiles は読み込まない。
test: {
name: 'tools',
environment: 'node',
setupFiles: [],
include: ['tools/**/*.{test,spec}.mjs'],
globals: true,
},
},
],
},
})
シリーズの前後の記事
- ⬅️ 前の記事: 第 14 回 失敗するテストを先に書いて Red→Green を1周する
- ➡️ 次の記事: 第 16 回 外部APIに依存しないE2Eの組み方
全 19 回をまとめて読みたい方は、同じ内容の Zenn Book(無料)へどうぞ。