CypressでGoogleのOAuth認証のログインをする

CypressでGoogle, Facebook, Microsoft, GitHub などのOAuth認証のログインを実施する方法を紹介します。CypressでOAuth認証を実施する方法はいくつかあるのですが、この記事ではGoogleのOAuthに対してフォーム入力する方法を試しています。

CypressでOAuth認証の実施方法

多くのOAuthプロバイダーは、ロボット判定や、リクエスト制限などにより、CypressでOAuth認証が難しい場合があります。

Cypressの Best Practice を見ると、この問題を軽減する方法が以下のように4つほど記載されております。これらの方法は、OAuthプロバイダーや利用アプリでの実装方法によりうまくいかないケースもあるので状況に応じて使い分ける必要があります。

  • 方法1. cy.origin()OAuth認証のフォーム入力をする
  • 方法2. OAuthプロバイダーからのレスポンスをスタブ化してログインする
  • 方法3. cy.request()でOAuthプロバイダーの公開APIを直接リクエストしてログインする
  • 方法4. サーバーでテスト用のログインAPIを作成してOAuthプロバイダーを省略してログインする

このうち、この記事では方法1をご紹介します。

OAuth認証のサンプルアプリの用意

実際にCypressでOAuth認証を検証するためのサンプルアプリを用意します。Cypressで実際に動かして検証しない方は、この項目をスキップしても問題ありません。

今回は、Auth0のReactのサンプルアプリのauth0-samples/auth0-react-samplesを使います。Auth0のアカウント作成(無料)が必要になります。

Auth0のセットアップ

  • まず、Auth0の開発用のアカウント(無料)を作成します。
  • 次に、Auth0のダッシュボードから、「Applications」を選択し、「Default App」を選択します。
  • Default App 内の Settingsタブ内でいくつか操作します。
    • 「Domain」と「Client ID」の値をメモしておく
    • Application Type を「Single Page Application」に変更
    • Allowed Callback URLsAllowed Logout URLs, Allowd Web Origins に「http://localhost:3000」を追加

Auth0のReactサンプルアプリの設定

Auth0のReactサンプルアプリのauth0-samples/auth0-react-samplesをダウンロードします。

git clone https://github.com/auth0-samples/auth0-react-samples.git

# サンプルアプリのルートディレクトリに移動
cd auth0-react-samples/Sample-01

次に、Auth0の設定情報を記載する設定ファイルを作成します。

touch src/auth_config.json

そして、先ほどAuth0のSettingsタブでコピーした「Domain」と「Client ID」をsrc/auth_config.jsonに記述します。

{
  "domain": "<Your Auth0 Domain>",
  "clientId": "<Your Auth0 Client ID>"
}

最後に、サンプルアプリを起動します。

npm install
npm run dev

サンプルのアプリが表示されることが確認できればOKです。

方法1. cy.origin()OAuth認証のフォーム入力をする

CypressからOAuth認証のフォーム入力を実施してOAuth認証を実現します。通常のCypressだとうまくいかないので、cy.originを使うことでAuth0やGoogleページにアクセスします。

ここからは、Cypressがインストールされているリポジトリで操作をします。Cypressのインストール方法や簡単な使い方を知りたい場合はCypressをさわってみるを参照ください。

# Cypressがインストールされているリポジトリに移動
cd your-installed-cypress-project

妨害するサードパーティコードの変更を有効にする

まずcypress.config.js内でexperimentalModifyObstructiveThirdPartyCodetrueにします。若干力技ですが、Cypressの操作を妨害するサードパーティのコードをCypressが変更するようになります。

const { defineConfig } = require("cypress");

module.exports = defineConfig({
  // 妨害するサードパーティコードの変更を有効にする
  // Doc: https://docs.cypress.io/guides/guides/web-security#Modifying-Obstructive-Third-Party-Code
  experimentalModifyObstructiveThirdPartyCode: true,
  e2e: {
    setupNodeEvents(on, config) {
      // implement node event listeners here
    },
  },
});

cypress.env.jsonの設定

cypress.env.jsonファイルにAuth0のドメインGoogleのログイン情報を設定します。もしファイルがない場合を追加してください。cypress.env.json環境変数は、Cypress.envで取得できるようになります。

{
  "BASE_URL": "http://localhost:3000",
  "AUTH0_DOMAIN": "<Your Auth0 Domain>",
  "GOOGLE_USERNAME": "<Your Google Account Username>",
  "GOOGLE_PASSWORD": "<Your Google Account Password>",
}

Googleログイン用の関数を追加

cypress/support/oauth-login.jsのファイルを作成して、Googleログイン用の関数を追加します。cy.originを使うことでAuth0やGoogleの別ドメインのページにアクセスできるようにしています。

// Googleログイン用の関数
function loginToGoogle(username, password) {
  Cypress.on(
    'uncaught:exception',
    (err) =>
      !err.message.includes('ResizeObserver loop') &&
      !err.message.includes('Error in protected function')
  );

  cy.visit(Cypress.env('BASE_URL'));

  cy.get('#qsLoginBtn').click();

  // Auth0のログインページでGoogleボタンをクリック
  // doc: https://docs.cypress.io/api/commands/origin
  cy.origin(Cypress.env('AUTH0_DOMAIN'), () => {
    cy.scrollTo('bottom');
    cy.get('form[data-provider="google"]').submit();
  });

  // Googleのログインページでユーザー名とパスワードを入力
  // doc: https://docs.cypress.io/api/commands/origin
  cy.origin(
    'https://accounts.google.com',
    {
      args: {
        username,
        password,
      },
    },
    ({ username, password }) => {
      Cypress.on(
        'uncaught:exception',
        (err) =>
          !err.message.includes('ResizeObserver loop') &&
          !err.message.includes('Error in protected function')
      );

      cy.get('input[type="email"]').type(username, { log: false });
      // NOTE: 要素はフォームに存在するが、非表示になって再レンダリングされるためwait()を追加
      cy.contains('次へ').click().wait(4000);

      cy.get('[type="password"]').type(password, { log: false });
      cy.contains('次へ').click().wait(4000);
    }
  );

  // ログイン時に表示されるプロフィールドロップダウンが存在することを確認
  cy.get('#profileDropDown').should('exist');
}

また、今後OAuth認証のプロバイダーを増やしやすくするために、loginViaOAuthという関数を追加しておきます。

// OAuth経由でログインする関数
export function loginViaOAuth(provider) {
  switch (provider) {
    case 'google':
      loginToGoogle(
        // cypress.env.json に設定したGoogleのログイン情報を使う
        Cypress.env('GOOGLE_USERNAME'),
        Cypress.env('GOOGLE_PASSWORD')
      );
      break;
      // NOTE: ここに他のプロバイダーの認証を追加できる
    default:
      throw new Error('no provider configured!');
  }
}

CypressのOAuth認証コマンドを作成する

作成したOAuth認証の関数をCypressから呼び出せるようにするためにCypress.Commands.addloginViaOAuthという名前で登録します。

import { loginViaOAuth } from './oauth-login';

Cypress.Commands.add('loginViaOAuth', (provider) => {
  cy.log(`loginViaOAuth : ${provider}`);
  loginViaOAuth(provider)
});

Cypressのテスト内でOAuth認証コマンドを呼び出す

では、cypress/e2e/oauth-login.cy.jsというCypressのテストファイルを作成します。そして、cy.loginViaOAuthを呼び出して、OAuth認証を実行します。

describe('Google Login Demo', () => {
  beforeEach(() => {
    // GoogleのOAuth認証を実施
    cy.loginViaOAuth('google')
  })

  it('shows logged in user profile page', () => {
    cy.visit('http://localhost:3000/profile')
    cy.location('pathname').should('eq', '/profile')
    cy.contains(Cypress.env('GOOGLE_USERNAME')).should('exist')
  })
})

GoogleOAuth認証のテストが書けたので、npx cypress openでテストを実行してみます。

cy.sessionでログイン情報を保持する

現状だと、テストのたびに毎回ログインフローを実行してしまい、テスト実行時間がとても長くなってしまいます。そのため、cy.sessionを使ってログイン情報をブラウザコンテキスト間でキャッシュするようにします。

import { loginViaOAuth } from './oauth-login';

Cypress.Commands.add('loginViaOAuth', (provider) => {
  cy.log(`loginViaOAuth : ${provider}`);

  // cy.sessionを使ってログイン情報をキャッシュ
  // Doc: https://docs.cypress.io/api/commands/session
  cy.session(`oauth-${provider}`,
    () => loginViaOAuth(provider),
    {
      // ログイン時のプロフィールドロップダウンが表示されていない場合
      // loginViaOAuth(provider) を実行する
      validate: () => {
        cy.visit(Cypress.env('BASE_URL'))
        cy.get('#profileDropDown').should('exist')
      },
    }
  );
});

Cypressのブラウザ画面をリロードすると、キャッシュが再利用されてGoogleのログインフローが省略されることが確認できます。

関連する情報

E2EテストツールのCypressをさわってみる

Cypressをさわりながら基本的な機能の紹介をしていきます。CypressはE2Eテストツールとして人気のツールです。ぜひ、基本的なコンセプトについて理解を深めてください。

動作確認バージョン

  • cypress: 13.8.1

Cypressのインストール

npmコマンドでcypressをインストールします。

# フォルダ作成
mkdir cypress-test
cd cypress-test

# npm初期化
npm init -y

# Cypressのインストール
npm install cypress --save-dev

補足:npm以外のコマンドでのインストール方法やシステム要件を確認したい場合は、 Installing Cypress を参照ください。

E2Eテストを作成する

では、CypressでE2Eテストを作成していきます。

Cypressの特徴としては、ブラウザでCypressのWebアプリを立ち上げでテスト作成やテスト実行をインタラクティブに実施できます。もちろん、コマンドラインからヘッドレスブラウザ(画面表示なし)で実行も可能です。

Cypress Appを起動する

npx cypress openコマンドでCypress Appを立ち上げます。

npx cypress open

補足:公式ドキュメントでも記載されていますが、package.jsonscriptsに追加しておくと便利です。

{
  "scripts": {
    "cy:open": "cypress open"
  }
}

立ち上がった、Cypress Appのテスト種別で「E2E Testing」を選びます。すると、CypressでE2Eテストを実施するために必要な設定ファイルが作成されます。

次に、「Continue」ボタンを押します。すると、ブラウザを選択する画面が表示されます。 補足:Cypressは、ChromeMicrosoft EdgeFirefox、Electronなどのブラウザをサポートしています。この画面では、システムにインストールされているブラウザが表示されます。

今回は、「Chrome」を選択し、「Start E2E Testing in Chrome」ボタンを押します。すると、別ウインドウでChromeが立ち上がります。このウインドウ上で、Cypressのテストの実行やデバッグなどが実施できます。

テストを作成する

では、テストを作成していきます。https://demo.playwright.dev/todomvc/ に対して、TODOを追加するテストを作成してみます。

まずは、cypress/e2e/todo-mvc.spec.jsというファイルを作成します。

mkdir cypress/e2e
touch cypress/e2e/todo-mvc.spec.js

そして、cy.visitでTodoMVCの画面を開いてみます。Cypressでは自動で要素の表示を待ってくれるため、基本的にawaitなどを記載する必要はありません。

describe('TodoMVC', () => {
  it('should add a new todo item', () => {
    cy.visit('https://demo.playwright.dev/todomvc/');
  });
});

次に、インプットフィールドに、TODO項目を入力して、Enterキーを押すテストを書いてみます。getfindで要素のclassやidやdata属性などを利用して要素を取得し、typeで入力、{enter}でEnterキーを押します。他にも、clickcheckなどのアクションも用意されています。(参考: Interacting with Element

describe('TodoMVC', () => {
  it('should add a new todo item', () => {
    cy.visit('https://demo.playwright.dev/todomvc/');

    cy.get('.new-todo').type('寝る前に歯を磨く{enter}');
  });
});

そして、アサーションとして、TODO項目が表示されているか確認します。Cypressでは、shouldアサーションを実施しており、Chaiを使っています。

describe('TodoMVC', () => {
  it('should add a new todo item', () => {
    cy.visit('https://demo.playwright.dev/todomvc/');

    cy.get('.new-todo').type('寝る前に歯を磨く{enter}');

    cy.get('[data-testid="todo-title"]').contains('寝る前に歯を磨く').should('exist');
  });
});

補足:対象の要素の取得方法がわからない場合は、Cypressのウインドウの右側のパネルからマウス操作でCypressのコードを確認できます。

では、Cypress Appの上部のアイコンからテストを実行します。PASSすることが確認できます。

また、コマンドラインからnpx cypress runコマンドでヘッドレスでテストを実行することもできます。

$ npx cypress run

# ....

====================================================================================================

  (Run Finished)


       Spec                                              Tests  Passing  Failing  Pending  Skipped
  ┌────────────────────────────────────────────────────────────────────────────────────────────────┐
  │ ✔  todo-mvc.cy.js                           00:01        1        1        -        -        - │
  └────────────────────────────────────────────────────────────────────────────────────────────────┘
    ✔  All specs passed!                        00:01        1        1        -        -        -

補足:コマンドラインからのテスト実行時には、ブラウザ指定や特定ファイルのみの実行などもできます。詳細は、Command Lineを参照ください。

より詳しい情報

E2EテストツールのPlaywrightをさわりながら基本的な機能を知る

Playwrightをさわりながら基本的な機能の紹介をしていきます。PlaywrightはE2Eテストとして人気が高まっているツールです。ぜひ、基本的なコンセプトについて理解を深めてください。

動作確認バージョン

  • @playwright/test: 1.43.1

Playwrightのインストール

npmコマンドでplaywrightをインストールします。

mkdir playwright-test
cd playwright-test

npm init playwright@latest

実行すると、@playwright/testがインストールされ、設定ファイル(playwright.config.ts)、サンプル用のテストファイルが生成されます。また、テスト用のブラウザとして、ChromiumFirefoxWebkitもダウンロードされます。

Playwrightのサンプルのテストを動かしてみる

Playwrightは、ブラウザを操作するためのツールです。Playwrightを使って、ブラウザを操作するテストを書いてみましょう。

サンプルのテストを確認する

tests/example.spec.tsにサンプルのテストが生成されています。このテストは、Playwrightの公式サイトにアクセスし、タイトルがPlaywrightを含むことを確認するテストです。

import { test, expect } from '@playwright/test';

test('has title', async ({ page }) => {
  // https://playwright.dev/ にアクセス
  await page.goto('https://playwright.dev/');

  // titleタグに"Playwright"が含まれていることを確認
  await expect(page).toHaveTitle(/Playwright/);
});

test('get started link', async ({ page }) => {
  // https://playwright.dev/ にアクセス
  await page.goto('https://playwright.dev/');

  // "Get started"というリンクをクリックし、ページ遷移する
  await page.getByRole('link', { name: 'Get started' }).click();

  // ページの見出しに"Installation"が表示されていることを検証する
  await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});

サンプルのテストを実行する

npx playwright testコマンドでテストを実行します。Playwrightはデフォルトでヘッドレス(ブラウザを表示しない)でテストを実行します。

npx playwright test

#### その他のテストの実行方法 ###
# 特定のテストファイルやテストケースのみ実行する
npx playwright test example.spec.ts:10

# Headed modeでテストを実行する
# ブラウザを表示してテストを実行するようになる
npx playwright test --headed

# UI Modeでテストを実行する
# UI Modeはブラウザを表示しながらインタラクティブにテストを実行することができる
# Doc: https://playwright.dev/docs/test-ui-mode
npx playwright test --ui

# Debug Modeでテストを実行する
# 1行ずつデバッグしながらテストを実行できる
# また、`await page.pause();`でブレイクポイントを設定できる
# Doc: https://playwright.dev/docs/debug#run-in-debug-mode-1
npx playwright test --debug

サンプルのテスト結果を確認する

テストを実行したら、npx playwright show-reportコマンドでテスト結果のレポートを表示することができます。

npx playwright show-report

デフォルトでは、HTMLでレポートが表示されます。また、ChromiumFirefoxWebkitの3つのブラウザでテストが実行されるので、それぞれのブラウザでのテスト結果も確認できます。

Playwrightのテストコードを書いてテスト作成

ここまでで、Playwrightの基本的なテストの実行方法とテスト結果のレポートの表示方法がわかりました。では、 https://demo.playwright.dev/todomvc/ に対して、コードを書いてテストを作成してみます。

Playwrightのテストの基本構成はシンプルで、次の3つになります。

  1. ロケーターで要素を見つける
  2. アクションでユーザー操作を実行する
  3. マッチャーを使って要素をアサーションする

0. テストファイルを作成する

まずは、テストファイルを作成します。

touch tests/todo-mvc.spec.ts

そして、次のようなテストを書いてみます。

import { test, expect } from '@playwright/test';

// 全てのテストの前に実行される処理
// https://demo.playwright.dev/todomvc にアクセスする
test.beforeEach(async ({ page }) => {
  await page.goto('https://demo.playwright.dev/todomvc');
});

1. ロケーターで要素を見つける

ロケーターは、ページ上の要素を見つけるために使います。ロケーターには、自動で要素が利用可能になるまで待機する機能がついており、E2Eテストの信頼性が高まるので積極的に利用しましょう。

主要なロケーターとしては次のようなものがあります。

// ロール属性で要素を探す
const submitButton = page.getByRole('button', { name: 'Submit' });

// フォーム内のフォームコントロールに関連付いたラベルのインプット要素を探す
const firstNameInput = page.getByLabel('First Name');

// プレースホルダーによってインプット要素を探す
// 関連づいたラベルがないフォームコントロールで利用する
const firstNameInput = page.getByPlaceholder('Input your first name');

// テキストで要素を探す
const helloWorld = page.getByText('Hello, World!');

// alt属性によって要素を探す。主に画像に使う
const beautifulImage = page.getByAltText('A beautiful image');

// data-testid属性の要素を探す
// getByRole()、getByLabel()、getByText()などが使えない場合に使う
const someElement = page.getByTestId('some-test-id');

// より詳細のロケーターは https://playwright.dev/docs/locators を参照ください

E2Eテストはユーザー操作をシミュレートするため、ユーザーが実際に操作する要素を使うことが重要です。そのため、XSSCSS要素ではなく、ユーザー向けの属性の利用を推奨します。また、可能な範囲でtest-idではなくgetByRolegetByTextなどのロケーターを使うことをおすすめします。

// Bad: DOM構造やCSSは変わりやすいのでテストが失敗しやすくなる
page.locator('button.buttonIcon.episode-actions-later');

// Soso: テキストは変わりやすいのでテストが不安定になる
page.getByTestId('submit-button');

// Good: ロールは変わりづらいのでテストが安定する
page.getByRole('button', { name: 'submit' });

では、todo-mvc.spec.tsでロケーターを使って要素を見つけてみましょう。

test.describe('New Todo', () => {
  test('should add todo items', async ({ page }) => {
    // プレースホルダーでインプット要素を探す
    const newTodoInput = page.getByPlaceholder('What needs to be done?');
  });
});

2. アクションでユーザー操作を実行する

次に、ロケーターで見つけたHTML要素に対してアクションで操作を実行します。よく使うアクションとしては次のようなものがあります。

// goto: 画面遷移
await page.goto('https://playwright.dev/');

// click : クリック操作
const getStartedButton = page.getByRole('link', { name: 'Get Started' });
await getStartedButton.click();

// fill : テキスト入力
await page.getByRole('textbox').fill('Peter');
await page.getByLabel('Birth day').fill('2020-02-02');

// check : チェックボックスにチェックをつける
await page.getByLabel('I agree to the terms above').check();

// selectOption : セレクトボックスにチェックをつける
await page.getByLabel('Choose a color').selectOption('blue');
await page.getByLabel('Choose multiple colors').selectOption(['red', 'green', 'blue']);

// press : キーボード操作
await page.getByText('Submit').press('Enter');
await page.getByRole('textbox').press('Control+ArrowRight');

// その他のアクションは、 https://playwright.dev/docs/input を参照ください

では、todo-mvc.spec.tsでアクションを使ってTODO項目を入力して、Enterキーを押してみましょう。

test.describe('New Todo', () => {
  test('should add todo items', async ({ page }) => {
    // プレースホルダーでインプット要素を探す
    const newTodoInput = page.getByPlaceholder('What needs to be done?');

    // テキスト入力
    await newTodoInput.fill('寝る前に歯を磨く');
    // Enterキーを押す
    await newTodoInput.press('Enter');
  });
});

3. マッチャーを使って要素をアサーションする

では、expectとマッチャーを使って要素のアサーション(検証)を行います。いくつかのアサーションはパスするかタイムアウトまで自動でリトライする機能があります。

// 一般的なアサーション
// Doc: https://playwright.dev/docs/api/class-genericassertions
// toBeTruthy : 真偽値がtrueか
const success = true;
expect(success).toBeTruthy();
// toEqual : オブジェクトのプロパティが一致するか
const value = { prop: 1 };
expect(value).toEqual({ prop: 1 });

// Pageのアサーション
// Doc: https://playwright.dev/docs/api/class-pageassertions
// toHaveTitle : タイトルが一致するか
await expect(page).toHaveTitle(/Playwright/);
// toHaveURL : URLが一致するか
await expect(page).toHaveURL('https://playwright.dev/');

// ロケーターのアサーション
// Doc: https://playwright.dev/docs/api/class-locatorassertions
// toBeChecked : チェックボックスがチェックされているか
await expect(locator).toBeChecked();
// toBeEnabled : 要素が有効か
await expect(locator).toBeEnabled();
// toBeVisible : 要素が表示されているか
await expect(locator).toBeVisible();
// toHaveAttribute : 要素が指定した属性を持っているか
await expect(locator).toHaveAttribute();
// toHaveText : 要素内の子要素も含むテキストが一致するか
await expect(locator).toHaveText(/Hello/);
// toHaveValue : 要素のValue属性の値が一致するか
await expect(locator).toHaveValue(/[0-9]/);

では、todo-mvc.spec.tsで入力したTODO項目が表示されているか検証します。

import { test, expect } from '@playwright/test';

// 全てのテストの前に実行される処理
// https://demo.playwright.dev/todomvc にアクセスする
test.beforeEach(async ({ page }) => {
  await page.goto('https://demo.playwright.dev/todomvc');
});

test.describe('New Todo', () => {
  test('should add todo items', async ({ page }) => {
    // プレースホルダーでインプット要素を探す
    const newTodoInput = page.getByPlaceholder('What needs to be done?');

    // テキスト入力
    await newTodoInput.fill('寝る前に歯を磨く');

    // Enterキーを押す
    await newTodoInput.press('Enter');

    // 入力したTODOのテキストが表示されているか確認
    await expect(page.getByTestId('todo-title')).toHaveText([
      '寝る前に歯を磨く',
    ]);
  });
});

では、最後にテストを実行してPASSすることを確認します。

$ npx playwright test tests/todo-mvc.spec.ts

Running 3 tests using 3 workers
  3 passed (3.6s)

To open last HTML report run:

  npx playwright show-report

PlaywrightのCodegen機能でブラウザ操作からテスト作成

Playwrightには、ブラウザ操作を記録してテストコードを生成するCodegenという機能があります。これを使うと、ブラウザ操作からPlaywrightのテストコードを簡単に作成することができます。

1. Codegenを起動する

まず、npx playwright codegenCodegenを起動します。起動すると、ブラウザ操作をレコーディングするためのウィンドウとテストコードのウインドウ(Inspector)の2つのウインドウが表示されます。

npx playwright codegen

補足:レコーディングのウインドウの上部にあるアイコンは左から、「レコーディング実施・停止」「ロケーター」「表示のアサーション」「テキストのアサーション」「Valueアサーション」となっています。

2. ブラウザ操作でテストコードを作成する

では、ブラウザのURLに https://demo.playwright.dev/todomvc/#/ を入力します。すると、自動的にInspectorにgotoメソッドが追加されます。

ブラウザ操作を行いながらテストコードを作成してみましょう。自動でテストコードが生成されていきます。 (@playwright/test: 1.43.1 では日本語を上手く認識できないことがあるかもです)

最後にInspecorのコードをコピーして、テストファイルを作成すれば完成です。

VS CodeのExtensionを使うことでより簡単にPlaywrightのコードを生成することができます。興味がある方は、 https://playwright.dev/docs/codegen の「Generate tests in VS Code」を参照くださいませ。

Playwrightのより詳しい情報

Apollo Client のエラーポリシーやApollo Linkでエラーハンドリングする

GrahqQLサーバーではさまざまな種類のエラーが発生します。Apollo Clientはサーバーでエラーが発生した時にエラーハンドリングをエラーポリシーやApollo Linkによって制御することができます。

エラーの種類

  • GraphQLエラー: シンタックスエラー、バリデーションエラー、リゾルバーエラーなどGraphQLのクエリに起因するエラーです。
  • ネットワークエラー: サーバーとのネットワークやサーバーの状態に起因するエラーです。ステータスとしては、4xxや5xxなどのステータスコードが返ってくるようなエラーです。

GraphQLエラー

GraphQLエラーには、次のようなエラーが含まれます。シンタックスエラーとバリデーションエラーはサーバー上でGraphQLクエリは実行されませんが、リゾルバーエラーはサーバー上でクエリは実行されて部分的なデータを返すことが可能です。

  • シンタックスエラー:クエリの文法が間違っているなど
  • バリデーションエラー:クエリにスキーマに存在しないフィールドが指定されているなど
  • ゾルバーエラー:リゾルバーの処理を実行時にエラーが発生した場合など
// シンタックスエラーのレスポンス例
{
  "errors": [
    {
      "message": "Cannot query field \"authoraaa\" on type \"Book\". Did you mean \"author\"?",
      "locations": [ { "line": 5, "column": 5 }],
      "extensions": {
        "code": "GRAPHQL_VALIDATION_FAILED",
        "stacktrace": [
          "GraphQLError: Cannot query field \"authoraaa\" on type \"Book\". Did you mean \"author\"?",
          "...",
        ]
      }
    }
  ]
}

ネットワークエラー

ネットワークエラーは、サーバーとの通信時のネットワークやサーバーの状態に起因するエラー(ステータスとしては、4xxや5xxなど)です。ネットワークエラーが発生すると、error.networkErrorにエラー内容が設定されます。

Apollo Clientのエラーポリシーの種類

GraphQLのリゾルバーエラーが発生したときは、レスポンスはerrorsにエラー内容が含まれます。また、dataに部分的なエラーが含まれる可能性があります。

// リゾルバーエラーのレスポンス例
// エラー時にもdataに値が部分的に含まれている
{
  "errors": [
    {
      "message": "Invalid argument value",
      "locations": [ ... ],
      "path": [ ... ],
      "extensions": {
        "code": "BAD_INPUT",
      }
    }
  ],
  "data": {
    "books": [
      {
        "id": 1,
        "title": "The Awakening",
      },
    ],
    "failedBook": null
  }
}

エラーポリシーを設定することで、エラー時のデータの取り扱いを制御することができます。

エラーポリシー 説明 errorの値 dataの値
none(デフォルト) エラーのみ表示 error.graphQLErrorsにエラーを設定 undefined
ignore エラーが起きてないように振る舞う undefined 部分的なデータが設定
all 部分的なデータとエラーの両方をレンダリングできる error.graphQLErrorsにエラーを設定 部分的なデータを設定
// 'all'の場合の例
const { loading, error, data } = useQuery(GET_BOOKS, {
  errorPolicy: 'all',
});

if (!loading) {
  console.log(data); //=> 部分的なデータが存在
  console.log(error); //=> ApolloError
  console.log(error.graphQLErrors) //=> GraphQLのエラーが設定
}

詳細は、GraphQL error policiesを参照ください。

Apollo Clientのエラーポリシーの設定方法

エラーポリシーを設定するには、主に次の2つの方法があります

  • グローバルにエラーポリシーを設定
  • useQueryなどのオペレーション時にエラーポリシーを設定

グローバルにエラーポリシーを設定

ApolloClientの初期化時にdefaultOptionsからエラーポリシーを設定することができます。

import { ApolloClient, InMemoryCache } from '@apollo/client';

const client = new ApolloClient({
  cache: new InMemoryCache(),
  uri: 'http://localhost:4000/',
  defaultOptions: {
    query: {
      errorPolicy: 'all',
    },
    mutate: {
      errorPolicy: 'ignore',
    },
  },
});

参考: class Apollo Client

オペレーション時にエラーポリシーを設定

useQueryなどのフックのオプションにエラーポリシーを設定することで、個別にエラーポリシーを設定することができます。

const { loading, error, data } = useQuery(GET_TODOS, {
  // 個別のクエリでerrorPolicyを指定
  errorPolicy: 'all',
});

Apollo Linkで高度なエラーハンドリングをする

Apollo Link ライブラリを使うことで、高度なエラーハンドリングを実現できます。

Apollo Linkは、Apollo ClientがGraphQLサーバーと通信をする間のデータの流れを制御することができます。 Apollo ClientはデフォルトでHttpLinkを利用しており、HTTPでGraphQLサーバーにデータを通信する制御をしています。

Apollo Linkの概要(引用元: https://www.apollographql.com/

onErrorリンクを追加することで、エラー内容に基づいて処理を実行することができます。

import { ApolloClient, InMemoryCache, HttpLink, from } from "@apollo/client";
import { onError } from "@apollo/client/link/error";

// HttpLinkはGraphQLサーバーとHTTPでやり取りするために必要
const httpLink = new HttpLink({
  uri: "http://localhost:4000/graphql"
});

// onErrorはGraphQLサーバーからのレスポンスのgraphQLErrorsかnetworkErrorをハンドリングできる
const errorLink = onError(({ graphQLErrors, networkError }) => {
  if (graphQLErrors)
    graphQLErrors.forEach(({ message, locations, path }) =>
      console.log(
        `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`,
      ),
    );

  if (networkError) console.log(`[Network error]: ${networkError}`);
});

const client = new ApolloClient({
  // linkオプションで定義したApollo Linkをチェインさせる
  link: from([errorLink, httpLink]),
  cache: new InMemoryCache()
});

Apollo Linkについてより詳しく知りたい場合は、Apollo Link Overviewを参照してください。

Apollo Client のキャッシュの仕組み

Apollo クライアントは、GraphQLクエリの結果を正規化してローカルのメモリ内に保存します。そして、メモリ内のクエリ結果をキャッシュとして利用します。 GraphQLクエリの実行時にクエリ結果がキャッシュにある場合は、ネットワークリクエストを省略して即時に応答できます。

Apollo Clietn のキャッシュの概要

公式ドキュメントのシーケンス図を引用しつつ、具体例を説明します。

GetBook(bookId: "5")のGraphQLクエリを実行するケースを考えてみましょう。

Apollo Client」の初回のGetBook(bookId: "5")のGraphQLクエリ実行時は「InMemoryCache」にBook:5のデータが存在しないため、「GraphQL Server」にクエリを実行して結果を取得します。そして、クエリ結果を「InMemoryCache」に正規化して保存し、「Apollo Client」にクエリ結果を返します。

初回リクエスト時のシーケンス図(引用元: https://www.apollographql.com/

その後、2回目以降のGetBook(bookId: "5")のGraphQLクエリを実行時は、「InMemoryCache」にBook:5のデータが存在するため、「GraphQL Server」にネットワークリクエストを実施せず、そのままキャッシュの値を「Apollo Client」に返します。

2回目以降のリクエスト時のシーケンス図(引用元: https://www.apollographql.com/

このように、キャッシュを有効に使うことで、アプリケーションは即時に応答することができます。しかし、キャッシュを使うことでメモリとサーバーの状態がずれてバグの温床にもなり得るので注意が必要です。

サポートされているFetch Policy

Apollo Client でサポートされているフェッチポリシーは次の6つになります。キャッシュの挙動に影響を与えるため、どのポリシーを使っているかを理解することが大切です。

ポリシー名 説明
cache-first (デフォルト) ネットワークリクエストの数を最小限に抑えることを優先している。
キャッシュに全てのフィールドのデータがあれば返す。なければGraphQLサーバーにクエリをリクエストする。クエリ結果をキャッシュする。
cache-and-network 高速な応答を提供しつつ、サーバーとローカルデータの一貫性を維持できる。
キャッシュとGraphQLサーバーの両方にクエリを実行し、サーバ側のクエリ結果をキャッシュする。
network-only サーバーとローカルデータの一貫性を常に保てる。
キャッシュを参照せず、GraphQLサーバーに常にリクエストする。クエリ結果はキャッシュする。
no-cache サーバーとローカルデータの一貫性を常に保つが、キャッシュは更新しない。
キャッシュを参照せず、GraphQLサーバーに常にリクエストする。クエリ結果はキャッシュしない。
cache-only キャッシュに全てのフィールドのデータがあれば返す。なければ、エラーをスローする。
standby cache-firstと同じロジックだが、フィールドが更新されても自動的に更新しない。

Fetch Policyの設定方法

フェッチポリシーの設定方法は、主に次の2つがあります。

  • new ApolloClient時にfetchPolicyを指定
  • useQueryfetchPolicy を指定

new ApolloClient時にfetchPolicyを指定」するとデフォルトのフェッチポリシーを指定することができます。(参考: class Apollo Client

const client = new ApolloClient({
  uri: 'https://flyby-router-demo.herokuapp.com/',
  cache: new InMemoryCache(),
  defaultOptions: {
    query: {
      fetchPolicy: 'cache-first',
    },
    watchQuery: {
      fetchPolicy: 'cache-and-network'
    },
  },
});

また、「useQueryfetchPolicy を指定」すると、個別でフェッチポリシーを指定できます。

const { loading, error, data } = useQuery(GET_TODOS, {
  fetchPolicy: 'network-only', // キャッシュを利用せずGraphQLサーバーに常にリクエスト。クエリ結果はキャッシュする
});

Apollo Client のキャッシュを可視化する

ブラウザ拡張の Apollo Client Devtools を利用することで、キャッシュを可視化できます。

また、キャッシュは正規化されて格納されます。How is data stored? でキャッシュの構造について理解を深めることができます。

Apollo Client のキャッシュを操作する

Apollo Client のキャッシュを直接操作することができます。

メソッド 説明
readQuery / writeQuery / updateQuery GraphQLのクエリ結果を取得・変更できる
readFragment / writeFragment / updateFragment / useFragment フラグメントの値を取得・変更できる
cache.modify キャッシュの値を直接変更できる

より詳細は Reading and writing data to the cacheを参照ください。

GraphQLクライアントのApollo Clientをさわってみる

この記事では、GraphQLクライアントのApollo Clientをさわってみて、感触を掴んでもらうための記事です。

動作確認バージョン

  • create-react-app : 5.0.1
  • react : 18.3.1
  • @apollo/client : 3.10.1
  • graphql : 16.8.1

Apollo Client とは

Apollo Client は、JavaScriptのGraphQL APIのクライアントのライブラリです。また、GraphQLのQueryやMutationを実行するだけでなく、ローカルの状態管理やキャッシュの機能も備えています。

コアとなる@apollo/clientライブラリは、デフォルトでReactと統合できます。また、Apolloコミュニティにより Angular, Vue, Svelte などとも統合できます。

Apollo Clientのインストール

まずはnpx create-react-appコマンドでReact環境を作成します。(react 18.3.1 で確認しています)

npx create-react-app apollo-client-react-test
cd apollo-client-react-test

# 起動することを確認
npm run start

Apollo Clientを使うには次の2つのライブラリが必要です。

  • @apollo/clientApollo Clientのコアライブラリ
  • graphql: GraphQLのクエリパースするロジック
npm install @apollo/client graphql

ApolloClient の初期化

new ApolloClientインスタンスを初期化します。

// index.js

// ...既存のコード

import { ApolloClient, InMemoryCache, ApolloProvider, gql } from '@apollo/client';

const client = new ApolloClient({
  // GraphQLサーバーのURLを指定
  // herokuのデモ用のGraphQL APIサーバーを指定してます
  uri: 'https://flyby-router-demo.herokuapp.com/',
  // クエリ結果をキャッシュする時に使うキャッシュ方法
  cache: new InMemoryCache(),
});

GraphQLのQueryを実行する

次に gqlでGraphQLのクエリを作成し、client.queryでGraphQLのクエリを実行できます。

// index.js

// const client = ...

// GraphQLのクエリでlocationsを取得する
const query = gql`
  query GetLocations {
    locations {
      id
      name
      description
      photo
    }
  }
`;

// GraphQLのクエリを実行
client
  .query({ query })
  .then((result) => console.log(result));

npm startで実行すると以下のようにコンソールにresultの値が表示されます。

React で Apollo Client を使う

ここからは React で Apollo Client を使えるようにします。Apollo ClientはデフォルトでReactに対応しています。

ReactアプリケーションをApolloProviderで囲むことでReactにApollo Clientを接続することができます。

// index.js
import React from 'react';
import * as ReactDOM from 'react-dom/client';
import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client';
import App from './App';

const client = new ApolloClient({
  uri: 'https://flyby-router-demo.herokuapp.com/',
  cache: new InMemoryCache(),
});

// NOTE:  以下のコードは不要なので削除する
// const query = gql` ...
// client ...

// Supported in React 18+
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <ApolloProvider client={client}>
      <App />
    </ApolloProvider>
  </React.StrictMode>
);

useQueryでGraphQLサーバーからデータを取得する

次にuseQueryでデータを取得します。useQueryはReact Hooksになっており、GraphQLからデータを取得してUIに反映する時に利用します。既存のApp.jsxの内容を次のコードに置き換えます。

// App.jsx
import { useQuery, gql } from '@apollo/client';

// ロケーションを取得するクエリ
const GET_LOCATIONS = gql`
  query GetLocations {
    locations {
      id
      name
      description
      photo
    }
  }
`;

// ロケーションの表示
const Locations = () => {
  const { loading, error, data } = useQuery(GET_LOCATIONS);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error : {error.message}</p>;

  return data.locations.map(({ id, name, description, photo }) => (
    <div key={id}>
      <h3>{name}</h3>
      <img width="400" height="250" alt="location-reference" src={`${photo}`} />
      <br />
      <b>About this location:</b>
      <p>{description}</p>
      <br />
    </div>
  ));
}

export default function App() {
  return (
    <div style={{ width: '80%', margin: '0 auto' }}>
      <h2>My first Apollo app 🚀</h2>
      <br />
      <Locations />
    </div>
  );
}

では、画面を確認すると無事ロケーションが取得できていることがわかります。

GitHub Actionsでワークフローから他のワークフローのpushイベントがトリガーされない問題の対応方法

GitHub Actions内でpushを実行しているのに、他のワークフローのpushイベントがトリガーされない事象が発生しました。いろいろ調べて対応できたので、メモ書き程度に記載してみました。

ワークフローから他のワークフローのpushイベントがトリガーされないケース

まず、ワークフローAでブランチを作成しpushをします。

# .github/workflows/create-release-branch.yml
name: Create release branch

on: workflow_dispatch

jobs:
  branch:
    name: Create and push branch
    runs-on: ubuntu-latest
    timeout-minutes: 5

    steps:
      - uses: actions/checkout@v4
        with:
          token: ${{ secrets.MY_GITHUB_TOKEN }}
      - name: create and push release branch
        run: |
          branch_name=release-$(date +'%Y-%m-%d-%H-%M-%S')
          git switch -c ${branch_name}
          git push origin ${branch_name}

次に、ワークフローBではpushイベントをトリガーにしてジョブを実行します。しかし、なぜかpushをしても、このワークフローBが動きません。

# .github/workflows/deploy-release-branch.yml
name: Deploy release branch

on:
  push:
    branches:
      - release-*

jobs:
  deploy:
    name: Deploy release branch
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - uses: actions/checkout@v4

      # ... Do deployment

ワークフローから他のワークフローをトリガーしない原因

GitHub Actionsでは暗黙的にデフォルトでGITHUB_TOKENというシークレットが自動で作成されます。(参考: 自動トークン認証 - GitHub Docs

このGITHUB_TOKENを使っている場合は、ワークフローからワークフローをトリガーしないようになっています。これは、「意図せずワークフローからワークフローがトリガーされて、再起的なワークフローの実行がされてしまう」ということを防ぐためとのことです。(参考:ワークフローのトリガー - GitHub Docs

ワークフローから他のワークフローをトリガーする方法

これに対応するには、GITHUB_TOKENの代わりにパーソナルアクセストークンを指定する必要があります。パーソナルアクセストークンを指定すると次のようになります。これで、無事にdeploy-release-branch.ymlがトリガーされるようになります。(参考: 個人用アクセス トークンを管理する - GitHub Docs

# .github/workflows/create-release-branch.yml
name: Create release branch

on: workflow_dispatch

jobs:
  branch:
    name: Create and push branch
    runs-on: ubuntu-latest
    timeout-minutes: 5

    steps:
      - uses: actions/checkout@v4
        with:
          # 追加: checkout時にパーソナルアクセストークンを指定
          token: ${{ secrets.MY_PERSONAL_ACCESS_TOKEN }}
      - name: create and push release branch
        run: |
          branch_name=release-$(date +'%Y-%m-%d-%H-%M-%S')
          git switch -c ${branch_name}
          git push origin ${branch_name}

上記はcheckout時のトークンの指定方法なので、他のactions/github-scriptghコマンドの場合はそれぞれ次のようにパーソナルアクセストークンを指定する必要があります。

# actions/github-scriptの場合
- uses: actions/github-script@v7
  with:
    github-token: ${{ secrets.MY_PERSONAL_ACCESS_TOKEN }}
    script: |
      github.rest.issues.addLabels({
        issue_number: context.issue.number,
        owner: context.repo.owner,
        repo: context.repo.repo,
        labels: ['Triage']
      })

# ghコマンドの場合
- env:
    GH_TOKEN: ${{ secrets.MY_TOKEN }}
    ISSUE_URL: ${{ github.event.issue.html_url }}
  run: |
    gh issue edit $ISSUE_URL --add-label "triage"

※ そもそもpushイベントのトリガーではなく、ワークフローの流れやファイル内容を見直して、workflow_runworkflow_callなどでも対応することは可能です

以上です。こちら参考になれば嬉しいです。