Open8

【WIP】ElectronアプリをPlaywrightでE2Eテストしてみる

t-yngt-yng

テストしたいこと

・GitHubでのOAuthログイン
・自分にアサインされたプルリクエストの一覧を表示
・自分が作成したプルリクエストの一覧を表示
・新しくアサインされたプルリクエストがあった場合に通知がされる
 ・E2Eテストでチェックできる?

今回の目標

・GitHubでのOAuthログインのフローを自動テストする

なぜE2Eテストをしたいのか?

・機能改修とかしたときに、GitHub上でプルリク作成したりする必要があり手動でのテストがかなり面倒
・リファクタ、機能改修をしたときに自信を持ってマージできない
・テストケースがカバーする範囲が広いのでjsdom環境だとインテグレーションテストを書くの大変
 ・というか書く気にならない
 ・ログインに関しては外部認証を使っている関係でjsdom環境だとモック祭りになって、テストの意味がなくなりそう。後はシンプルにテスト書くの大変そう
 ・E2Eテスト環境でうまく出来るかもチョット怪しい

t-yngt-yng

E2Eテスト環境の構築

モノリポ構成なので、どうやるか悩む。。。
とりあえず、E2Eテスト用のパッケージを作ってみる。

E2Eテスト用のパッケージを作成

$ mkdir e2e
$ tree . -L 1
.
├── e2e
├── main
└── web

Playwrightをインストール

$ cd e2e
$ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 yarn add -D playwright @playwright/test

Playwrightの設定ファイルを作成

// playwright.config.ts
import { defineConfig } from '@playwright/test';

/**
 * See https://playwright.dev/docs/test-configuration.
 */
export default defineConfig({
  testDir: './playwright',
  /* Run tests in files in parallel */
  fullyParallel: true,
});
t-yngt-yng

テスト書いてみる

アプリを起動してログイン画面を表示するテストを書いてみる。

サンプルがjsファイルだったので、一旦ビルドしたコードを参照するようにする。
毎回ビルドしてからテスト実行は地味に面倒なので、他に最適なやり方があれば修正したい。
とりあえず、対応したら時間がかかりそうなので後回しにする。

// playwight/launch.spec.ts
test('アプリを起動', async () => {
  // アプリを起動
  const electronApp = await electron.launch({
    args: [require.resolve('main/dist/app.js')],
  });

  const page = await electronApp.firstWindow();

  // コンソールの表示をテスト環境のターミナルに表示する
  page.on('console', console.log);

  // スクリーンショットを取る
  await page.screenshot({ path: 'intro.png' });

  await electronApp.close();
});

テストを実行

$ yarn playwright test

スクリーンショットの画像が真っ白だった。( ´•ω•` )

react-routerへの対応

ナビゲーションのイベントが発火せずreact-routerでのコンポーネントの描画ができていなかった。
https://github.com/microsoft/playwright/issues/15889#issuecomment-1194683031

ナビゲーションを発火する処理を追加して再トライ

// playwight/launch.spec.ts
test('アプリを起動', async () => {
  // アプリを起動
  const electronApp = await electron.launch({
    args: [require.resolve('main/dist/app.js')],
  });

  const page = await electronApp.firstWindow();

  // react-routerのページ遷移を実行するために、ナビゲーションのイベントを発火
  await page.evaluate(() => {
    window.history.pushState({}, '', '/');
  });

  // コンソールの表示をテスト環境のターミナルに表示する
  page.on('console', console.log);

  // スクリーンショットを取る
  await page.screenshot({ path: 'intro.png' });

  await electronApp.close();
});

再実行したらログイン画面が表示された🎉

とりあえず、最低限のテスト環境は構築できた。

t-yngt-yng

GitHubログインのテスト

2要素認証の関係やテストアカウントの問題で、素直にGitHubログインを進めるのは割と大変そうになりそう。

GitHubログインの流れは、

  1. GitHubログインのページを別ウィンドウで表示する
  2. ユーザーがGitHubにログインする
  3. 2段階認証を実施
  4. ログイン完了後にGitHub Appとして指定したURLにリダイレクト
  5. リダイレクトURLにセットされている認証コードを取得
  6. 認証コードと認証トークンをGitHubAPIで交換

という流れになっている。

// リダイレクトURLから認証コードを取得している処理
const handleCallback = async (url: string) => {
  const { code, error } = await handleGithubOAuthUrl(url);

  if (code) {
    resolve(code);
  } else if (error) {
    reject(new Error(error));
  }

  if (code || error) {
    oAuthWindow.destroy();
  }
};

oAuthWindow.webContents.on('will-navigate', function (event, url) {
  handleCallback(url);
});

認証コードを取得

GitHub認証後の遷移先URLを擬似的にhttps://github.com/authorizedとして、route()メソッドで認証コード付きのURLにリダイレクトさせることで、GitHubログインの処理をエミュレートできそう。

以下のテストコードでGitHub認証で認証コードを取得するテストができた!

test('GitHubでログインできる', async () => {
  // アプリを起動
  const electronApp = await electron.launch({
    args: [require.resolve('main/dist/app.js')],
  });

  const mainWindow = await electronApp.firstWindow();
  mainWindow.on('console', console.log);

  // react-routerのページ遷移を実行するために、ナビゲーションのイベントを発火
  await mainWindow.evaluate(() => {
    window.history.pushState({}, '', '/');
  });

  // ログインボタンをクリック
  const loginButton = await mainWindow.getByRole('button', {
    name: 'Login To GitHub',
  });
  await loginButton.click();

  // 認証用のウィンドウを取得
  const authWindow = await electronApp.waitForEvent('window');
  await authWindow.waitForLoadState('domcontentloaded');

  // GitHub認証のページ遷移をモック
  await authWindow.route('https://github.com/authorized', (route) => {
    return route.fulfill({
      status: 302,
      headers: { Location: 'http://localhost?code=1234567890' },
    });
  });
  try {
    await authWindow.goto('https://github.com/authorized', {
      waitUntil: 'commit',
    });
  } catch (error) {
    // アプリケーションの仕様として、認証後にリダイレクトされたURLから認証コードを取得したらウィンドウが閉じられる
    // ERROR_ABORTEDは正常な動作なので、エラーとして扱わない
    // それ以外のエラーは異常な動作なので、エラーとして扱う
    if (!error.message.includes('net::ERR_ABORTED')) {
      throw error;
    }
  }

  await mainWindow.route(
    'https://github.com/login/oauth/access_token',
    (route) => {
      console.log('route');
    }
  );

  await mainWindow.screenshot({ path: 'intro.png' });

  await electronApp.close();
});

認証コードとアクセストークンを交換

APIで認証コードとアクセストークンを交換することで、GitHubログインの処理が完了する。
https://docs.github.com/ja/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#2-users-are-redirected-back-to-your-site-by-github

認証コードとアクセストークンの交換はメインプロセス側で実施しているので、メインプロセスのGitHub API のレスポンスをモックする必要がある。

mswでやってみる。

test('GitHubログイン', () => {
  const server = setupServer();
  server.listen();
  server.use(
    rest.post(
      'https://github.com/login/oauth/access_token',
      (req, res, ctx) => {
        return res(
          ctx.status(200),
          ctx.body(JSON.stringify({ access_token: 'token' }))
        );
      }
    )
  );

  // (省略)
})

Playwrightのテスト実行のプロセスとElectronのメインプロセスが異なるプロセスになるので、HTTPリクエストのモックがmswを使った方法だとできなかった。

t-yngt-yng

ElectronのメインプロセスのHTTPリクエストのモックを考える

ipcMainの結果をモックする手段もあるので、最悪その方法で解決はできそう。
処理をまるっとモックして本来テストしたいコードがテストできなくなるので、可能であれば避けたい。

プロキシサーバーを経由する(解決策)

プロセスの枠組みで対応するのは厳しそうなので、テスト実行時だけモックサーバーをプロキシとして経由する対応をする。

APIクライアントのbaseURLを変更する対応は、複数のリクエスト先が必要になったときに対応しづらいのでやらない。

対応

  • HTTPクライアントライブラリをaxiosに変更
  • テスト実行時だけプロキシサーバーを指定
  • Playwrightでテスト実行時にプロキシサーバーを動的に起動してレスポンスをモックする

これで無事にメインプロセスのAPIリクエストもモックできる体制が整った。🎉

// app.ts
if (process.env.NODE_ENV === 'test') {
  axios.defaults.proxy = {
    protocol: 'http',
    host: '127.0.0.1',
    port: 4400,
};
import { _electron as electron } from 'playwright';
import { test } from '@playwright/test';
import jsonServer from 'json-server';

// モックサーバーをセットアップ
const server = jsonServer.create();
server.use(jsonServer.bodyParser);

test.beforeAll(() => {
  // モックサーバーを起動
  server.listen(4400);
});

test('GitHubでログインできる', async () => {

  // (省略)

  // GitHubのアクセストークン交換のAPIをモック
  server.post('/login/oauth/access_token', (req, res) => {
    if (req.body.code === '1234567890') {
      return res.status(200).json({
        access_token: 'mock_token',
      });
    } else {
      return res.status(401);
    }
  });
});
t-yngt-yng

ここまでのまとめ

  • E2EのテストフレームワークとしてPlaywrightを利用
  • 初回のレンダリングを行うために、意図的に遷移イベントを発生させるハックを追加
  • playwrightのPage.route() でレンダラープロセスのページ遷移処理をモック
  • テスト実行時にプロキシサーバーを利用してメインプロセスのAPIレスポンスをモック