10日で覚えるCypressDay 6: ネットワークリクエストの制御

Day 6: ネットワークリクエストの制御

今日学ぶこと

  • cy.intercept() の基本的な使い方
  • HTTPメソッド別のインターセプト(GET, POST, PUT, DELETE)
  • レスポンスのスタブ(固定データを返す)
  • リクエストの待機(cy.wait() + alias)
  • リクエストボディ・ヘッダーの検証
  • エラーレスポンスのシミュレーション(404, 500)
  • ネットワーク遅延のシミュレーション
  • 実践:API連携画面のテスト

cy.intercept() とは

cy.intercept() は、ブラウザから送信されるHTTPリクエストを**傍受(インターセプト)**し、レスポンスを差し替えたり、リクエストの内容を検証したりするためのコマンドです。

flowchart LR
    Browser["ブラウザ"] --> |"リクエスト"| Intercept["cy.intercept()\n傍受"]
    Intercept --> |"そのまま通す"| Server["サーバー"]
    Intercept --> |"スタブ応答"| Stub["固定レスポンス"]
    Server --> |"レスポンス"| Browser
    Stub --> |"レスポンス"| Browser

    style Browser fill:#3b82f6,color:#fff
    style Intercept fill:#f59e0b,color:#fff
    style Server fill:#22c55e,color:#fff
    style Stub fill:#8b5cf6,color:#fff

なぜインターセプトが必要か

課題 cy.intercept() の解決策
APIサーバーが不安定 スタブで固定データを返す
テストデータの準備が大変 任意のレスポンスを設定できる
エラー系のテストが困難 404, 500などを簡単にシミュレート
ネットワーク遅延のテスト delay オプションで遅延を再現
APIコールの検証 リクエスト内容を確認できる

基本的な使い方

シンプルなインターセプト

// GETリクエストをインターセプト
cy.intercept('GET', '/api/users').as('getUsers')

// ページを開く
cy.visit('/users')

// リクエストの完了を待つ
cy.wait('@getUsers')

URLパターンのマッチング

// 完全一致
cy.intercept('GET', '/api/users')

// ワイルドカード
cy.intercept('GET', '/api/users/*')       // /api/users/1, /api/users/abc
cy.intercept('GET', '/api/users/*/posts') // /api/users/1/posts

// 正規表現
cy.intercept('GET', /\/api\/users\/\d+$/) // /api/users/123

// クエリパラメータ付き(URLマッチャーオブジェクト)
cy.intercept({
  method: 'GET',
  url: '/api/users',
  query: { page: '1', limit: '10' }
})

HTTPメソッド別のインターセプト

GET - データ取得

cy.intercept('GET', '/api/users', {
  statusCode: 200,
  body: [
    { id: 1, name: '山田太郎', email: 'yamada@example.com' },
    { id: 2, name: '鈴木花子', email: 'suzuki@example.com' }
  ]
}).as('getUsers')

cy.visit('/users')
cy.wait('@getUsers')

cy.get('.user-card').should('have.length', 2)
cy.get('.user-card').first().should('contain', '山田太郎')

POST - データ作成

cy.intercept('POST', '/api/users', {
  statusCode: 201,
  body: { id: 3, name: '佐藤次郎', email: 'sato@example.com' }
}).as('createUser')

cy.get('#name').type('佐藤次郎')
cy.get('#email').type('sato@example.com')
cy.get('button[type="submit"]').click()

cy.wait('@createUser')
cy.get('.success-toast').should('contain', 'ユーザーを作成しました')

PUT - データ更新

cy.intercept('PUT', '/api/users/1', {
  statusCode: 200,
  body: { id: 1, name: '山田太郎(更新)', email: 'yamada-new@example.com' }
}).as('updateUser')

cy.get('#name').clear().type('山田太郎(更新)')
cy.get('button.save').click()

cy.wait('@updateUser')

DELETE - データ削除

cy.intercept('DELETE', '/api/users/1', {
  statusCode: 204,
  body: null
}).as('deleteUser')

cy.get('button.delete').click()
cy.get('.confirm-dialog button.yes').click()

cy.wait('@deleteUser')
cy.get('.user-card').should('have.length', 1)
flowchart TB
    subgraph Methods["HTTPメソッド"]
        GET["GET\nデータ取得"]
        POST["POST\nデータ作成"]
        PUT["PUT\nデータ更新"]
        DELETE["DELETE\nデータ削除"]
    end

    subgraph StatusCodes["レスポンスコード"]
        S200["200 OK"]
        S201["201 Created"]
        S204["204 No Content"]
    end

    GET --> S200
    POST --> S201
    PUT --> S200
    DELETE --> S204

    style GET fill:#3b82f6,color:#fff
    style POST fill:#22c55e,color:#fff
    style PUT fill:#f59e0b,color:#fff
    style DELETE fill:#ef4444,color:#fff
    style S200 fill:#8b5cf6,color:#fff
    style S201 fill:#8b5cf6,color:#fff
    style S204 fill:#8b5cf6,color:#fff

レスポンスのスタブ

fixture ファイルを使ったスタブ

テストデータを外部ファイルに管理すると、テストコードが見やすくなります。

// cypress/fixtures/users.json
// [
//   { "id": 1, "name": "山田太郎", "email": "yamada@example.com" },
//   { "id": 2, "name": "鈴木花子", "email": "suzuki@example.com" }
// ]

cy.intercept('GET', '/api/users', { fixture: 'users.json' }).as('getUsers')

動的レスポンス(routeHandler)

cy.intercept('GET', '/api/users/*', (req) => {
  const userId = req.url.split('/').pop()

  req.reply({
    statusCode: 200,
    body: {
      id: Number(userId),
      name: `ユーザー${userId}`,
      email: `user${userId}@example.com`
    }
  })
}).as('getUser')

レスポンスヘッダーの設定

cy.intercept('GET', '/api/data', {
  statusCode: 200,
  headers: {
    'Content-Type': 'application/json',
    'X-Total-Count': '100',
    'Cache-Control': 'no-cache'
  },
  body: { items: [] }
})

リクエストの待機と検証

cy.wait() + alias の基本

cy.intercept('POST', '/api/login').as('loginRequest')

cy.get('#email').type('user@example.com')
cy.get('#password').type('password123')
cy.get('button[type="submit"]').click()

// リクエストの完了を待機
cy.wait('@loginRequest').then((interception) => {
  // リクエストの内容を検証
  expect(interception.request.body).to.deep.equal({
    email: 'user@example.com',
    password: 'password123'
  })

  // レスポンスの内容を検証
  expect(interception.response.statusCode).to.equal(200)
})

リクエストヘッダーの検証

cy.intercept('GET', '/api/protected').as('protectedRequest')

cy.visit('/protected-page')

cy.wait('@protectedRequest').then((interception) => {
  expect(interception.request.headers).to.have.property('authorization')
  expect(interception.request.headers.authorization).to.include('Bearer')
})

複数リクエストの待機

cy.intercept('GET', '/api/users').as('getUsers')
cy.intercept('GET', '/api/posts').as('getPosts')
cy.intercept('GET', '/api/comments').as('getComments')

cy.visit('/dashboard')

// 複数のリクエストを順番に待機
cy.wait(['@getUsers', '@getPosts', '@getComments'])

// すべてのデータが表示されていることを確認
cy.get('.users-section').should('be.visible')
cy.get('.posts-section').should('be.visible')
cy.get('.comments-section').should('be.visible')

エラーレスポンスのシミュレーション

HTTPエラーコード

// 404 Not Found
cy.intercept('GET', '/api/users/999', {
  statusCode: 404,
  body: { error: 'User not found' }
}).as('getUserNotFound')

cy.visit('/users/999')
cy.wait('@getUserNotFound')
cy.get('.error-message').should('contain', 'ユーザーが見つかりません')

// 500 Internal Server Error
cy.intercept('POST', '/api/users', {
  statusCode: 500,
  body: { error: 'Internal server error' }
}).as('serverError')

cy.get('form').submit()
cy.wait('@serverError')
cy.get('.error-toast').should('contain', 'サーバーエラーが発生しました')

// 401 Unauthorized
cy.intercept('GET', '/api/protected', {
  statusCode: 401,
  body: { error: 'Unauthorized' }
}).as('unauthorized')

cy.visit('/protected-page')
cy.wait('@unauthorized')
cy.url().should('include', '/login')

よく使うHTTPステータスコード

コード 名前 テスト用途
200 OK 正常レスポンス
201 Created 作成成功
204 No Content 削除成功
400 Bad Request バリデーションエラー
401 Unauthorized 認証エラー
403 Forbidden 権限エラー
404 Not Found リソース不在
409 Conflict 競合エラー
422 Unprocessable Entity バリデーションエラー
500 Internal Server Error サーバーエラー

ネットワーク遅延のシミュレーション

delay オプション

// 3秒の遅延を追加
cy.intercept('GET', '/api/users', {
  statusCode: 200,
  body: [{ id: 1, name: '山田太郎' }],
  delay: 3000  // 3000ms = 3秒
}).as('slowRequest')

cy.visit('/users')

// ローディング表示の確認
cy.get('.loading-spinner').should('be.visible')

// データ表示後にローディングが消えることを確認
cy.wait('@slowRequest')
cy.get('.loading-spinner').should('not.exist')
cy.get('.user-card').should('be.visible')

throttle(帯域制限)

cy.intercept('GET', '/api/large-data', {
  statusCode: 200,
  body: { data: '大量のデータ...' },
  throttleKbps: 50  // 50KB/秒に制限
}).as('throttledRequest')
sequenceDiagram
    participant Browser as ブラウザ
    participant Intercept as cy.intercept()
    participant Server as サーバー

    Browser->>Intercept: GETリクエスト
    Note over Intercept: delay: 3000ms
    Intercept-->>Browser: ローディング表示中...
    Intercept->>Browser: レスポンス(3秒後)
    Note over Browser: データ表示

実践:API連携画面のテスト

TODOアプリのCRUDテスト

describe('TODOアプリ API連携テスト', () => {
  const todos = [
    { id: 1, title: '買い物', completed: false },
    { id: 2, title: '掃除', completed: true },
    { id: 3, title: '料理', completed: false }
  ]

  beforeEach(() => {
    // TODO一覧取得のスタブ
    cy.intercept('GET', '/api/todos', {
      statusCode: 200,
      body: todos
    }).as('getTodos')

    cy.visit('/todos')
    cy.wait('@getTodos')
  })

  it('TODO一覧が表示される', () => {
    cy.get('.todo-item').should('have.length', 3)
    cy.get('.todo-item').first().should('contain', '買い物')
  })

  it('新しいTODOを追加できる', () => {
    cy.intercept('POST', '/api/todos', {
      statusCode: 201,
      body: { id: 4, title: '勉強', completed: false }
    }).as('createTodo')

    cy.get('#new-todo').type('勉強')
    cy.get('button.add').click()

    cy.wait('@createTodo').then((interception) => {
      expect(interception.request.body).to.deep.equal({
        title: '勉強',
        completed: false
      })
    })
  })

  it('TODOの完了状態を切り替えられる', () => {
    cy.intercept('PUT', '/api/todos/1', {
      statusCode: 200,
      body: { id: 1, title: '買い物', completed: true }
    }).as('toggleTodo')

    cy.get('.todo-item').first().find('input[type="checkbox"]').check()

    cy.wait('@toggleTodo').then((interception) => {
      expect(interception.request.body.completed).to.equal(true)
    })
  })

  it('TODOを削除できる', () => {
    cy.intercept('DELETE', '/api/todos/1', {
      statusCode: 204
    }).as('deleteTodo')

    cy.get('.todo-item').first().find('button.delete').click()
    cy.wait('@deleteTodo')
  })

  it('サーバーエラー時にエラーメッセージが表示される', () => {
    cy.intercept('POST', '/api/todos', {
      statusCode: 500,
      body: { error: 'サーバーエラー' }
    }).as('createTodoError')

    cy.get('#new-todo').type('テスト')
    cy.get('button.add').click()

    cy.wait('@createTodoError')
    cy.get('.error-message').should('be.visible')
  })

  it('ネットワーク遅延時にローディングが表示される', () => {
    cy.intercept('GET', '/api/todos', {
      statusCode: 200,
      body: todos,
      delay: 2000
    }).as('slowGetTodos')

    cy.visit('/todos')
    cy.get('.loading').should('be.visible')
    cy.wait('@slowGetTodos')
    cy.get('.loading').should('not.exist')
  })
})

リクエストの変更(req.continue)

// リクエストを変更してからサーバーに送る
cy.intercept('GET', '/api/users', (req) => {
  // ヘッダーを追加
  req.headers['X-Custom-Header'] = 'test-value'

  // サーバーに送信
  req.continue((res) => {
    // レスポンスを変更
    res.body.push({ id: 99, name: 'テストユーザー' })
    res.send()
  })
})

まとめ

カテゴリ コマンド/オプション 用途
基本 cy.intercept(method, url) リクエストを傍受
エイリアス .as('alias') 待機・参照用の名前を付ける
待機 cy.wait('@alias') リクエスト完了まで待つ
スタブ { statusCode, body } 固定レスポンスを返す
fixture { fixture: 'file.json' } 外部ファイルからレスポンスを返す
動的応答 req.reply() 動的にレスポンスを生成
エラー { statusCode: 500 } エラーレスポンスをシミュレート
遅延 { delay: 3000 } ネットワーク遅延をシミュレート
帯域制限 { throttleKbps: 50 } 帯域を制限
リクエスト検証 interception.request.body リクエスト内容を検証

重要ポイント

  1. テストの独立性を保つ - cy.intercept() でAPIをスタブ化し、バックエンドに依存しないテストを書く
  2. alias + wait パターン - リクエストにエイリアスを付けて待機する、Cypressの基本パターンを身につける
  3. fixture ファイルを活用する - テストデータはfixtureとして管理すると、再利用性が高まる
  4. エラー系を必ずテストする - 正常系だけでなく、404、500などのエラーハンドリングもテストする
  5. ローディングUIをテストする - delay オプションで遅延を再現し、ローディング状態の表示を確認する

練習問題

基本

  1. cy.intercept() を使ってGETリクエストをスタブ化し、固定データを返してください
  2. cy.wait() を使ってリクエストの完了を待機し、レスポンスのステータスコードを検証してください
  3. fixture ファイルを作成し、それをレスポンスとして返すインターセプトを設定してください

応用

  1. POST、PUT、DELETEリクエストをそれぞれインターセプトし、リクエストボディを検証してください
  2. 404と500のエラーレスポンスをシミュレートし、画面のエラー表示をテストしてください
  3. ネットワーク遅延をシミュレートし、ローディング表示の出現と消失をテストしてください

チャレンジ

  1. CRUDすべての操作をカバーするAPI連携テストスイートを作成してください
  2. 同じエンドポイントに対して、リクエストごとに異なるレスポンスを返すテストを作成してください(1回目は成功、2回目はエラーなど)

参考リンク


次回予告

Day 7では、カスタムコマンドとユーティリティを学びます。繰り返し使う操作をカスタムコマンドとして定義し、テストコードの再利用性と可読性を向上させる方法を習得しましょう。