0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「実ネットワークに出ない」を設定1行で保証する(Next.js 16 実プロダクト読解 #15)

0
Last updated at Posted at 2026-09-28

この記事はシリーズ「動いているリポジトリを読む — Next.js 16 で学ぶクリーンアーキテクチャと TDD」の第 15 回(全 19 回)です。

実際に動いている公開リポジトリ kai-kou/gem-hunter(MIT)を1本まるごと読み解く連載です。架空のサンプルではなく実ファイルを引用し、掲載しているコマンド結果はすべて実際に動かして採取しています。

全体を通しで読みたい方へ: 同じ内容を 1 冊にまとめた Zenn Book を無料で公開しています。

シリーズ全体の目次

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 行 です。

vitest.setup.ts
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 を叩くクラスのテストです。

src/infrastructure/github/github-repository-query.test.ts
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 コンポーネントのテスト

役割で取る

src/ui/search-form.test.tsx
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 の解析が同じ定数を見ている ことが保証されます。

「無いこと」を検証する

src/ui/pagination.test.tsx
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` の全文
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,
        },
      },
    ],
  },
})

シリーズの前後の記事

全 19 回をまとめて読みたい方は、同じ内容の Zenn Book(無料)へどうぞ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?