playwright(Node.js) で E2E テスト!

記事
IT・テクノロジー
# はじめに

みなさん、Playwright をご存知ですか?

これまで、Node.js での E2E テストといえば、puppeteer、TestCafe を使っていたという方も少なくないのではないでしょうか?

Playwright は、そのうち、puppeteer と同じような記述も多く、非常に分かりやすいかと思います。

また、Microsoft によって開発、運用されているため、今後サポートされなくなるというリスクも
ある程度回避できるかと思います。

2020/12/26 時点では、バージョン 1.7.0 なので、その時点での情報になります。

# サポート環境

2020/12/26 時点でサポートしているのは以下になります。

- Node.js 10.17 以上
- Windows: Windows 及び WSL で動きます
- macOS: 10.14 以上
- Linux: ディストリビューションによる(Firefox は、Ubuntu 18.04 以上)

古い Microsoft Edge や IE 11 はサポートされていません。

また、Python や C# などでも使えますが、Java や Ruby はサポートしていません。

# インストール

```
$ yarn add -D playwright
```

# 簡単な例

Yahoo! JAPAN トップページにアクセスして、スクリーンショットを撮るテストをしてみます。

```js:test.js
const { chromium, devices } = require('playwright');

(async () => {
const browser = await chromium.launch();
const page = await browser.newPage({
...devices['iPhone 11 Pro']
});

await page.goto('ttps://m.yahoo.co.jp');
await page.screenshot({path: './screenshot.png', fullPage: true});
await browser.close();
})()
```

デバックモードで実行するとより詳細にテスト内容が確認できます。

```
$ DEBUG=pw:api node test.js
```

# 各 API について

全て書くと多すぎるので、使えそうなものだけまとめてみます。

## playwright

### playwright.chromium

Chromium ブラウザを使用する際に使います。

### playwright.firefox

FireFox ブラウザを使用する際に使います。

### playwright.webkit

Webkit ブラウザを使用する際に使います。

### playwright.devices

テストを行うデバイスを指定します。
指定できるのは、

ttps://github.com/Microsoft/playwright/blob/master/src/server/deviceDescriptors.ts

に記載のあるものになります。

また、同じ形式で連想配列にして指定することで独自のものを指定することも可能です。

## Browser

### browser.close

browserType.launch や browserType.connect で生成されたブラウザを閉じます。

browserType.launch で生成されたブラウザに対しては、全てのページも閉じます。

browserType.connect で生成されたブラウザに対しては、全ての context をリセットし、サーバとの接続も解除します。

### browser.newContext

引数に連想配列を指定して、context を生成します。

いくつか抜粋します。

ignoreHTTPSErrors<boolean>: HTTPS のエラーを無視します。デフォルトは false です。

userAgent<string>: User-Agent を指定します。

isMobile<boolean>: モバイルデバイスかどうかを指定します。Firefox は、サポートされていません。

hasTouch<boolean>: viewport がタッチイベントをサポートしているかどうかです。デフォルトは false です。

geolocation<{latitude: number, longitude: number, accuract: number}>: 位置情報について指定します。

extraHTTPHeaders<{[key: string]: string}>: HTTP ヘッダを指定します。

### browser.newPage

引数に連想配列を指定して、context の中で page を生成します。

これは、SPA や短いコードに対して便利な API になります。
プロダクションコードでは、browser.newContext を行ってから browser.newPage を行ってください。

引数は、browser.newContext とほとんど同じになります。

## BrowserContext

### browserContext.cookies

設定されている cookie のリストを取得します。

引数に url を指定することで特定の cookie に絞り込むことができます。

### browserContext.addCookies

cookie を設定します。

### browserContext.clearCookies

cookie をリセットします。

### browserContext.storageState

cookie や LocalStorage の値を取得します。

### browserContext.grantPermissions

さまざまなパーミッションを与えます。

指定できるのは、以下のものになります。

- 'geolocation'
- 'midi'
- 'midi-sysex' (system-exclusive midi)
- 'notifications'
- 'push'
- 'camera'
- 'microphone'
- 'background-sync'
- 'ambient-light-sensor'
- 'accelerometer'
- 'gyroscope'
- 'magnetometer'
- 'accessibility-events'
- 'clipboard-read'
- 'clipboard-write'
- 'payment-handler'

### browserContext.clearPermissions

パーミッションをリセットします。

### browserContext.exposeBinding

window オブジェクトに関数を追加します。

これは、全ての frame 、page に追加されます。

例:

```js
const { webkit } = require("playwright"); // Or 'chromium' or 'firefox'.

(async () => {
const browser = await webkit.launch({ headless: false });
const context = await browser.newContext();
await context.exposeBinding("pageURL", ({ page }) => page.url());
const page = await context.newPage();
await page.setContent(`
<script>
async function onClick() {
document.querySelector('div').textContent = await window.pageURL();
}
</script>
<button onclick="onClick()">Click me</button>
<div></div>
`);
await page.click("button");
})();
```

### browserContext.exposeFunction

window オブジェクトに関数を追加します。

これは、全ての frame 、page に追加されます。

例:

```js
const { webkit } = require("playwright"); // Or 'chromium' or 'firefox'.
const crypto = require("crypto");

(async () => {
const browser = await webkit.launch({ headless: false });
const context = await browser.newContext();
await context.exposeFunction("md5", (text) =>
crypto.createHash("md5").update(text).digest("hex")
);
const page = await context.newPage();
await page.setContent(`
<script>
async function onClick() {
document.querySelector('div').textContent = await window.md5('PLAYWRIGHT');
}
</script>
<button onclick="onClick()">Click me</button>
<div></div>
`);
await page.click("button");
})();
```

### browserContext.newPage

page を生成します。

### browserContext.close

context を閉じます。

### browserContext.route

ネットワークリクエストをキャッチして、特定の処理を行うようにハンドリングします。

特定の url で一度設定したら、マッチする全てのリクエストがハンドリングされます。

例えば、画像を取得するリクエストを無視する場合、以下のようになります。

```js
const context = await browser.newContext();
await context.route("**/*.{png,jpg,jpeg}", (route) => route.abort());
const page = await context.newPage();
await page.goto("ttps://example.com");
await browser.close();
```

### browserContext.unroute

設定した route を解除します。

### browserContext.setDefaultNavigationTimeout

navigation タイムアウトを設定します。

影響を与えるのは、以下の API になります。

- page.goBack
- page.goForward
- page.goto
- page.reload
- page.setContent
- page.waitForNavigation

### browserContext.setDefaultTimeout

全ての API のタイムアウトを設定します。

ただし、page.setDefaultNavigationTimeout, page.setDefaultTimeout(timeout), browserContext.setDefaultNavigationTimeout(timeout) の方が優先されます。

### browserContext.setGeolocation

位置情報を設定します。

この位置情報を取得するためには、browserContext.grantPermissions で権限を与える必要があります。

### browserContext.waitForEvent

特定の event が発火されるまで待ちます。

## Page

### page.on('dialog')

alert, prompt, confirm, beforeunload が呼ばれた時に
呼ばれるイベントリスナーです。

### page.on('request')

リクエストが発行された時に呼ばれるイベントリスナーです。

コールバックの引数となる request は、読み込み専用です。

### page.on('requestfailed')

タイムアウトなどリクエストが失敗した時に呼ばれるイベントリスナーです。

### page.on('requestfinished')

レスポンスボディの取得が成功したときに呼ばれるイベントリスナーです。

### page.on('response')

リクエストヘッダ、ステータスを受け取った時に呼ばれるイベントリスナーです。

### page.$

セレクタを1つだけ指定します。

複数マッチした時は、最初の1つ目のみのセレクタとなります。

1つもマッチしなかった場合は、null を返します。

### page.$$

セレクタを複数指定します。

1つもマッチしなかった場合は、[] を返します。

### page.$eval

セレクタを1つだけ指定して、処理を行います。
セレクタがマッチしなかった場合は、エラーを投げます。

```js
const searchValue = await page.$eval("#search", (el) => el.value);
const preloadHref = await page.$eval("link[rel=preload]", (el) => el.href);
const html = await page.$eval(
".main-container",
(e, suffix) => e.outerHTML + suffix,
"hello"
);
```

### page.$$eval

セレクタを複数指定して、処理を行います。

```js
const divsCounts = await page.$$eval(
"div",
(divs, min) => divs.length >= min,
10
);
```

### page.check

セレクタを指定して、チェックを入れます。

以下の順番で処理が行われます。

1. マッチするセレクタを探します。もしなければ、マッチする DOM が生成されるまで待ちます。
2. input の checkbox か radio かどうかを確認します。もし違った場合は、reject します。既にチェック済みの場合は、すぐに return します。
3. force オプションが指定されていない場合は、チェックされるまで待ちます。
4. 必要があれば、スクロールを行います。
5. page.mouse を使って、セレクタの真ん中をクリックします。
6. noWaitAfter オプションが指定されていない場合は、ナビゲーションが成功するかどうかを待ちます。
7. セレクタがチェックされたかを確認します。されていなければ、reject します。

### page.uncheck

セレクタを指定して、チェックを外します。

処理は page.check と同じ流れで行います。

### page.click

セレクタを指定して、クリックします。

以下の順番で処理が行われます。

1. マッチするセレクタを探します。もしなければ、マッチする DOM が生成されるまで待ちます。
2. force オプションが指定されていない場合は、クリックできるセレクタ可動かをチェックします。
3. 必要があれば、スクロールを行います。
4. page.mouse を使って、セレクタの真ん中か特定の位置をクリックします。
5. noWaitAfter オプションが指定されていない場合は、ナビゲーションが成功するかどうかを待ちます。

### page.close

page を閉じます。

runBeforeUnload オプションが false の場合は、結果は page を閉じてから返します。

runBeforeUnload オプションが true の場合は、page が閉じるのを待ちません。

### page.dblclick

セレクタを指定して、ダブルクリックします。

### page.dispatchEvent

セレクタを指定して、イベントを発火させます。

複数セレクタがマッチした場合は、最初の1つ目になります。

```js
await page.dispatchEvent("button#submit", "click");
```

### page.fill

セレクタを指定して、値を入力します。

複数セレクタがマッチした場合は、最初の1つ目になります。

input や textarea など、入力できないものにマッチした場合は、エラーを投げます。

### page.focus

セレクタを指定して、フォーカスします。

複数セレクタがマッチした場合は、最初の1つ目になります。

マッチするセレクタが見つからない場合は、見つかるまで待ちます。

### page.getAttribute

セレクタを指定して、属性値を取得します。

複数セレクタがマッチした場合は、最初の1つ目になります。

### page.goBack

一つ前の画面に戻ります。

### page.goForward

一つ先の画面に進みます。

### page.goto

指定された url に遷移します。

以下の場合は、エラーを投げます。

- SSL エラー
- 不正な URL
- タイムアウト
- サーバからレスポンスが返ってこない
- メインリソースのロードに失敗

### page.reload

ページを再読み込みします。

### page.hover

セレクタを指定して、ホバーします。

### page.innerHTML

セレクタを指定して、HTML 要素を取得します。

複数セレクタがマッチした場合は、最初の1つ目になります。

### page.innerText

セレクタを指定して、テキスト要素を取得します。

複数セレクタがマッチした場合は、最初の1つ目になります。

### page.press

セレクタを指定して、キーを押します。

複数セレクタがマッチした場合は、最初の1つ目になります。

キーは、

```
F1 - F12, Digit0- Digit9, KeyA- KeyZ, Backquote, Minus, Equal, Backslash, Backspace, Tab, Delete, Escape, ArrowDown, End, Enter, Home, Insert, PageDown, PageUp, ArrowRight, ArrowUp
```

などが選択できます。

また、複数同時に押すことも可能です。

以下に例を示します。

```js
const page = await browser.newPage();
await page.goto("ttps://keycode.info");
await page.press("body", "A");
await page.screenshot({ path: "A.png" });
await page.press("body", "ArrowLeft");
await page.screenshot({ path: "ArrowLeft.png" });
await page.press("body", "Shift+O");
await page.screenshot({ path: "O.png" });
await browser.close();
```

### page.route

マッチした url に対して、特定の処理を行います。

一度設定されると全てのリクエストに対して有効です。

```js
const page = await browser.newPage();
await page.route("**/*.{png,jpg,jpeg}", (route) => route.abort());
await page.goto("ttps://example.com");
await browser.close();
```

### page.unroute

page.route を解除します。

### page.screenshot

スクリーンショットを保存します。

保存できるのは、png または jpeg のみになります。

### page.selectOption

セレクタを指定して、select の要素を選択します。

```js
// single selection matching the value
page.selectOption("select#colors", "blue");

// single selection matching both the value and the label
page.selectOption("select#colors", { label: "Blue" });

// multiple selection
page.selectOption("select#colors", ["red", "green", "blue"]);
```

### page.setContent

HTML をページに設定します。

```js
const html = `<!DOCTYPE html>
<html>
<head><title>test</title></head>
<body>
<h1>test</h1>
</body>
</html>`;
await page.setContent(html);
```

### page.setDefaultNavigationTimeout

navigation タイムアウトを設定します。

影響を与えるのは、以下の API になります。

- page.goBack
- page.goForward
- page.goto
- page.reload
- page.setContent
- page.waitForNavigation

### page.setDefaultTimeout

全ての API のタイムアウトを設定します。

### page.textContent

セレクタを指定して、中身を取得します。

### page.title

ページのタイトルを取得します。

### page.type

セレクタを指定して、文字をタイプします。

```js
await page.type("#mytextarea", "Hello");
await page.type("#mytextarea", "World", { delay: 100 });
```

### page.url

ページの URL を取得します。

### page.waitForEvent

指定したイベントが発火されるまで待ちます。

### page.waitForFunction

引数で記述した処理が true になるまで待ちます。

```js
const { webkit } = require("playwright");

(async () => {
const browser = await webkit.launch();
const page = await browser.newPage();
const watchDog = page.waitForFunction("window.innerWidth < 100");
await page.setViewportSize({ width: 50, height: 50 });
await watchDog;
await browser.close();
})();
```

### page.waitForLoadState

"load" または "domcontentloaded" または "networkidle" の状態になるまで待ちます。

```js
const [popup] = await Promise.all([
page.waitForEvent("popup"),
page.click("button"),
]);
await popup.waitForLoadState("domcontentloaded");
console.log(await popup.title());
```

### page.waitForNavigation

ページ遷移が終わるまで待ちます。

```js
const [response] = await Promise.all([
page.waitForNavigation(),
page.click("a.delayed-navigation"),
]);
```

### page.waitForRequest

指定されたリクエストが来るまで待ちます。

戻り値には、マッチしたリクエストが返ってきます。

```js
const firstRequest = await page.waitForRequest("ttp://example.com/resource");
const finalRequest = await page.waitForRequest(
(request) =>
request.url() === "ttp://example.com" && request.method() === "GET"
);
return firstRequest.url();
```

### page.waitForResponse

指定されたレスポンスが来るまで待ちます。

戻り値には、マッチしたリクエストのレスポンスが返ってきます。

```js
const firstResponse = await page.waitForResponse(
"ttps://example.com/resource"
);
const finalResponse = await page.waitForResponse(
(response) =>
response.url() === "ttps://example.com" && response.status() === 200
);
return finalResponse.ok();
```

### page.waitForSelector

セレクタを指定して、そのセレクタが特定のステータスになるまで待ちます。

ステータスは、`attached` `detached` `visible` `hidden` から指定できます。

```js
const { chromium } = require("playwright");

(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
let currentURL;
page
.waitForSelector("img")
.then(() => console.log("First URL with image: " + currentURL));
for (currentURL of [
"ttps://example.com",
"ttps://google.com",
"ttps://bbc.com",
]) {
await page.goto(currentURL);
}
await browser.close();
})();
```

### page.waitForTimeout

指定したミリ秒数待ちます。

## Dialog

### dialog.accept

prompt ダイアログで OK されるまで待ちます。

### dialog.defaultValue

prompt ダイアログの場合は、その文字列を、それ以外のダイアログの場合は、空文字列を返します。

### dialog.dismiss

ダイアログが閉じられるまで待ちます。

```js
const { chromium } = require("playwright");

(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
page.on("dialog", async (dialog) => {
console.log(dialog.message());
await dialog.dismiss();
await browser.close();
});
page.evaluate(() => alert("1"));
})();
```

### dialog.message

表示されているダイアログのメッセージを取得します。

### dialog.type

どんなダイアログを表示しているか取得します。

取得できるのは、`alert` `beforeunload` `confirm` `prompt` です。

## Keyboard

### keyboard.down

指定したキーを keyDown イベントと一緒に入力します。

### keyboard.up

指定したキーを keyUp イベントと一緒に入力します。

### keyboard.press

指定したキーを keyPress イベントと一緒に入力します。

### keyboard.insertText

指定した文字を入力します。

`keyDown` `keyUp` `keyPress` イベントは発行しません。

### keyboard.type

指定した文字を入力します。

`keyDown` `keyUp` `keyPress` イベントは発行します。

## EvaluationArgument

Playwright は、jest による評価も可能ですが、Playwright にも評価するメソッドが存在します。

```js
// A primitive value.
await page.evaluate((num) => num, 42);

// An array.
await page.evaluate((array) => array.length, [1, 2, 3]);

// An object.
await page.evaluate((object) => object.foo, { foo: "bar" });

// A single handle.
const button = await page.$("button");
await page.evaluate((button) => button.textContent, button);

// Alternative notation using elementHandle.evaluate.
await button.evaluate((button, from) => button.textContent.substring(from), 5);

// Object with multiple handles.
const button1 = await page.$(".button1");
const button2 = await page.$(".button2");
await page.evaluate((o) => o.button1.textContent + o.button2.textContent, {
button1,
button2,
});

// Obejct destructuring works. Note that property names must match
// between the destructured object and the argument.
// Also note the required parenthesis.
await page.evaluate(
({ button1, button2 }) => button1.textContent + button2.textContent,
{ button1, button2 }
);

// Array works as well. Arbitrary names can be used for destructuring.
// Note the required parenthesis.
await page.evaluate(([b1, b2]) => b1.textContent + b2.textContent, [
button1,
button2,
]);

// Any non-cyclic mix of serializables and handles works.
await page.evaluate(
(x) => x.button1.textContent + x.list[0].textContent + String(x.foo),
{ button1, list: [button2], foo: null }
);
```

## Working with Chrome Extensions
Playwright は、ヘッドレスモードでない状態であれば、Chrome Extensions を有効にした状態でのテストも可能です。

```js
const { chromium } = require('playwright');

(async () => {
const pathToExtension = require('path').join(__dirname, 'my-extension');
const userDataDir = '/tmp/test-user-data-dir';
const browserContext = await chromium.launchPersistentContext(userDataDir,{
headless: false,
args: [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`
]
});
const backgroundPage = browserContext.backgroundPages()[0];
// Test the background page as you would any other page.
await browserContext.close();
})();
```

# おわりに

いかがだったでしょうか?

自分が作成したプロダクトに対して、E2E テストを書くことも出来ますし、
さまざまなブラウザを使った自動化にも役立つかと思います。

日々の面倒な作業を Playwright を使って自動化するのも良いかもしれません。

puppeteer を使っていた方であれば、より多くのブラウザをサポートしている Playwright には
メリットも感じられるかと思うので、ぜひ移行してみてください。

# 参考

ttps://playwright.dev

サービス数40万件のスキルマーケット、あなたにぴったりのサービスを探す