Closed18

Puppeteer+Jest+TypeScript で E2Eテスト をつくって Azure DevOps Pipeline で実行する

ピン留めされたアイテム
Kazunori KimuraKazunori Kimura

概要

  • Azure AppService の API を呼び出す Azure CDN にデプロイされた React アプリを Puppeteer で E2Eテストする
  • Puppeteer で Azure ActiveDirectory B2C での認証を突破し、トップ画面のロゴが表示されたら OK とする
  • AD B2C の認証情報は環境変数で受け渡す
  • DevOps Pipeline でデプロイ完了後に E2Eテストを実行し、テストが NG なら Pipeline を失敗とする
Kazunori KimuraKazunori Kimura
  • E2Eテストコードは Webアプリとは別のプロジェクトとする
    • すでに Webアプリは単体テストコードが含まれていて、混在すると面倒くさそう
    • これ以上 Webアプリ側は複雑にしたくない
Kazunori KimuraKazunori Kimura

ChatGPT と協議して、E2Eテストのプロジェクト名は e2e-orchestrator とすることに。

mkdir e2e-orchestrator
cd e2e-orchestrator
npm init -y
Kazunori KimuraKazunori Kimura

必要そうなパッケージをインストール

npm i env-cmd puppeteer jest ts-jest typescript jest-puppeteer @types/jest

TypeScript の設定ファイルを生成

npx tsc --init
tsconfig.json
{
    ...
    "include": ["src"]
}
Kazunori KimuraKazunori Kimura

必要そうな環境変数を .env にまとめておく

# テスト対象の URL
TARGET_URL='http://localhost:3000'

# 認証情報
USERNAME='E2E_USER'
EMAIL='e2e_user@example.com'
PASSWORD='...'
Kazunori KimuraKazunori Kimura

jest-puppeteer を使うように jest の設定ファイルを作る
B2C のログイン時に認証画面のリダイレクトやトークンの更新が走るので、jest 既定のタイムアウト時間では間に合わない。
都度 test メソッドに指定するのは面倒なので、一括して設定しておく。

touch jest.config.js
jest.config.js
module.exports = {
    preset: 'jest-puppeteer',
    moduleFileExtensions: ['js', 'ts'],
    transform: {
        '^.+\\.ts$': 'ts-jest',
    },
    testMatch: ['<rootDir>/src/**/*.test.+(ts|js)'],
    testTimeout: 600000, // 1000 * 60 * 10 = 10min
};
Kazunori KimuraKazunori Kimura

jest-puppeteer のデフォルトではヘッドレスブラウザを使用するので、意図せずテストがコケたときに何が起きたか判別するのが難しい。
テストの様子が目視できるようにブラウザを表示するよう設定する。

touch jest-puppeteer.config.js
jest-puppeteer.config.js
module.exports = {
    launch: {
        // ブラウザを表示したいときは false、非表示のときは 'new' にする
        headless: false,
    },
};
Kazunori KimuraKazunori Kimura

Puppeteer には sleep するメソッドがないので、自前で用意する
また、認証時にリダイレクトのチェックを何度か行うので、ユーティリティ関数として定義しておく

mkdir src
touch src/utils.ts
src/utils.ts
import { Browser, Page } from 'puppeteer';

/**
 * 指定の秒数だけ待つ
 * @param delay 秒
 * @returns
 */
export async function sleep(delay: number) {
    return new Promise((resolve) => setTimeout(resolve, delay * 1000));
}

export async function waitForRedirected(
    page: Page,
    title: string,
    interval?: number,
    timeout?: number
): Promise<Page>;
export async function waitForRedirected(
    page: Browser,
    title: string,
    interval?: number,
    timeout?: number
): Promise<Page>;

/**
 * リダイレクトの完了を待つ
 */
export async function waitForRedirected(
    arg1: Page | Browser,
    title: string,
    interval = 1,
    timeout = 10000
): Promise<Page> {
    const isBrowser = 'pages' in arg1;

    if (isBrowser) {
        return waitBrowserForRedirected(arg1, title, interval, timeout);
    } else {
        return waitPageForRedirected(arg1, title, interval, timeout);
    }
}

async function waitPageForRedirected(
    page: Page,
    title: string,
    interval: number,
    timeout: number
): Promise<Page> {
    const start = Date.now();
    while (true) {
        const pageTitle = await page.title();
        if (pageTitle === title) {
            return page;
        }

        // タイムアウト時間を過ぎたらエラー
        if (Date.now() - start > timeout) {
            throw new Error('Timeout');
        }

        // 1sec 待つ
        await sleep(interval);
    }
}

async function waitBrowserForRedirected(
    browser: Browser,
    title: string,
    interval: number,
    timeout: number
): Promise<Page> {
    const start = Date.now();
    while (true) {
        const pages = await browser.pages();
        const page = pages[0];
        const pageTitle = await page.title();
        if (pageTitle === title) {
            return page;
        }

        // タイムアウト時間を過ぎたらエラー
        if (Date.now() - start > timeout) {
            throw new Error('Timeout');
        }

        // 1sec 待つ
        await sleep(interval);
    }
}
Kazunori KimuraKazunori Kimura

jest-puppeteer では global に browser と page のインスタンスを生成するけど、型定義が抜けているので追加する

mkdir src/types
touch src/types/global.d.ts
src/types/global.d.ts
import { Browser, Page } from 'puppeteer';

declare global {
    const browser: Browser;
    const page: Page;
}
Kazunori KimuraKazunori Kimura

テストコードの実装。

  1. Web アプリにアクセス
  2. Azure AD B2C の認証画面にリダイレクトされる
  3. 認証情報を入力して submit
  4. Web アプリのダッシュボード画面が表示されることを確認
touch src/login.test.ts
src/login.test.ts
import { sleep, waitForRedirected } from './utils';

// 環境変数から各種パラメータを取得
const url = process.env.TARGET_URL || 'http://localhost:3000';
const username = process.env.USERNAME || '';
const email = process.env.EMAIL || '';
const password = process.env.PASSWORD || '';

/**
 * ログイン画面のタイトル
 */
const loginTitle = 'Sign up or sign in';
/**
 * ダッシュボード画面のタイトル
 */
const dashboardTitle = 'MystructureNote';

test('SignIn', async () => {
    // ページを開く
    await page.goto(url);
    // 3秒待つ
    await sleep(3);

    // AD B2C のログインページが表示されるまで待つ
    await waitForRedirected(page, loginTitle);

    // ログインフォームが表示されるまで待つ
    await page.waitForSelector('form#localAccountForm');
    // フォームに入力
    await page.type('input#email', email);
    await page.type('input#password', password);

    // ログインボタンをクリックしてページ遷移
    await Promise.all([
        page.waitForNavigation({ waitUntil: ['load', 'networkidle2'] }),
        page.click('button#next'),
    ]);

    // 3秒待つ
    await sleep(3);

    // ページが表示されるまで待つ
    await waitForRedirected(page, dashboardTitle);
    // ロゴが表示されるまで待つ
    await page.waitForSelector('#Logo');

    // ロゴが存在することを検証
    const logo = await page.$('#Logo');
    expect(logo).not.toBeNull();

    // ユーザー名が表示されることを検証
    const userName = await page.$eval('#UserName', (el) => el.textContent);
    expect(userName).toBe(username);
});
Kazunori KimuraKazunori Kimura

npm testjest を実行する

package.json
{
    ...
    "scripts": {
        "test": "env-cmd jest"
    },
    ...
}
Kazunori KimuraKazunori Kimura
npm test

> e2e-orchestrator@1.0.0 test
> env-cmd jest

Determining test suites to run...(node:67719) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 exit listeners added to [process]. Use emitter.setMaxListeners() to increase limit
(Use `node --trace-warnings ...` to show where the warning was created)
(node:67719) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 SIGINT listeners added to [process]. Use emitter.setMaxListeners() to increase limit
(node:67719) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 SIGTERM listeners added to [process]. Use emitter.setMaxListeners() to increase limit
(node:67719) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 SIGHUP listeners added to [process]. Use emitter.setMaxListeners() to increase limit
 PASS  src/login.test.ts (25.862 s)
  ✓ SignIn (16586 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        26.302 s
Ran all test suites.
Kazunori KimuraKazunori Kimura

Azure DevOps の Pipeline から実行する準備。
結果を junit の xml フォーマットで出力するために追加のパッケージをインストール

npm i cross-env jest-junit
Kazunori KimuraKazunori Kimura

package.json に Pipeline から実行するテストのコマンドを登録

package.json
{
    ...
    "scripts": {
        "test": "env-cmd jest",
        "test:ci": "cross-env CI=true npm run test -- --testResultsProcessor=\"jest-junit\""
    },
    ...
}

Kazunori KimuraKazunori Kimura

実行してみる

npm run test:ci

junit.xml が生成されることを確認する。
.gitignorejunit.xml を追加しておく。

Kazunori KimuraKazunori Kimura

Azure DevOps の Pipeline に E2Eテストを実行する Job を追加する
すでに Build や Deploy は構築済みの前提で、E2Eテストの部分のみピックアップ

e2etest-pipeline.yml
parameters:
  - name: projectName
    type: string
  - name: envFile
    type: string
  - name: nodeVersion
    type: string
    default: "16.x"

jobs:
  - job: E2ETest
    displayName: E2E Test
    pool:
      vmImage: ubuntu-latest
    steps:
      - task: NodeTool@0
        inputs:
          versionSpec: ${{ parameters.nodeVersion }}
        displayName: "install Node.js"

      - task: DownloadSecureFile@1
        name: envFile
        inputs:
          secureFile: ${{ parameters.envFile }}
        displayName: "Download secure file"

      - script: |
          cd ${{ parameters.projectName }}
          mv $(envFile.secureFilePath) ./.env
          export NODE_OPTIONS="--max-old-space-size=4096"
          export REDIRECT_WAIT_TIME=10
          npm install
          npm run test:ci
        displayName: "E2E Test"

      - task: PublishTestResults@2
        inputs:
          testResultsFormat: 'JUnit'
          testResultsFiles: '**/junit.xml'
          failTaskOnFailedTests: true
このスクラップは4ヶ月前にクローズされました