Puppeteer+Jest+TypeScript で E2Eテスト をつくって Azure DevOps Pipeline で実行する
概要
- Azure AppService の API を呼び出す Azure CDN にデプロイされた React アプリを Puppeteer で E2Eテストする
- Puppeteer で Azure ActiveDirectory B2C での認証を突破し、トップ画面のロゴが表示されたら OK とする
- AD B2C の認証情報は環境変数で受け渡す
- DevOps Pipeline でデプロイ完了後に E2Eテストを実行し、テストが NG なら Pipeline を失敗とする
- E2Eテストコードは Webアプリとは別のプロジェクトとする
- すでに Webアプリは単体テストコードが含まれていて、混在すると面倒くさそう
- これ以上 Webアプリ側は複雑にしたくない
ChatGPT と協議して、E2Eテストのプロジェクト名は e2e-orchestrator
とすることに。
mkdir e2e-orchestrator
cd e2e-orchestrator
npm init -y
必要そうなパッケージをインストール
npm i env-cmd puppeteer jest ts-jest typescript jest-puppeteer @types/jest
TypeScript の設定ファイルを生成
npx tsc --init
{
...
"include": ["src"]
}
必要そうな環境変数を .env
にまとめておく
# テスト対象の URL
TARGET_URL='http://localhost:3000'
# 認証情報
USERNAME='E2E_USER'
EMAIL='e2e_user@example.com'
PASSWORD='...'
jest-puppeteer
を使うように jest
の設定ファイルを作る
B2C のログイン時に認証画面のリダイレクトやトークンの更新が走るので、jest 既定のタイムアウト時間では間に合わない。
都度 test
メソッドに指定するのは面倒なので、一括して設定しておく。
touch 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
};
jest-puppeteer
のデフォルトではヘッドレスブラウザを使用するので、意図せずテストがコケたときに何が起きたか判別するのが難しい。
テストの様子が目視できるようにブラウザを表示するよう設定する。
touch jest-puppeteer.config.js
module.exports = {
launch: {
// ブラウザを表示したいときは false、非表示のときは 'new' にする
headless: false,
},
};
Puppeteer には sleep するメソッドがないので、自前で用意する
また、認証時にリダイレクトのチェックを何度か行うので、ユーティリティ関数として定義しておく
mkdir src
touch 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);
}
}
jest-puppeteer
では global に browser と page のインスタンスを生成するけど、型定義が抜けているので追加する
mkdir src/types
touch src/types/global.d.ts
import { Browser, Page } from 'puppeteer';
declare global {
const browser: Browser;
const page: Page;
}
テストコードの実装。
- Web アプリにアクセス
- Azure AD B2C の認証画面にリダイレクトされる
- 認証情報を入力して submit
- Web アプリのダッシュボード画面が表示されることを確認
touch 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);
});
npm test
で jest
を実行する
{
...
"scripts": {
"test": "env-cmd jest"
},
...
}
❯ 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.
MaxListenersExceededWarning
を消したいけど分からん。
Azure DevOps の Pipeline から実行する準備。
結果を junit の xml フォーマットで出力するために追加のパッケージをインストール
npm i cross-env jest-junit
package.json に Pipeline から実行するテストのコマンドを登録
{
...
"scripts": {
"test": "env-cmd jest",
"test:ci": "cross-env CI=true npm run test -- --testResultsProcessor=\"jest-junit\""
},
...
}
実行してみる
npm run test:ci
junit.xml
が生成されることを確認する。
.gitignore
に junit.xml
を追加しておく。
Azure DevOps の Pipeline に E2Eテストを実行する Job を追加する
すでに Build や Deploy は構築済みの前提で、E2Eテストの部分のみピックアップ
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
こんな感じで E2E テストで NG があると Pipeline 全体が失敗となる