gaugeのテストでブラウザ操作にPlaywrightを使う

## はじめに

E2EテストツールとしてgaugeとPlaywrightをそれぞれ触ってみました。

https://gauge.org/

https://playwright.dev/

gaugeとPlaywrightはそれぞれ、ざっくり以下だと理解しています。

- gauge
    - 自動テストをMarkdownで自然言語で記述できるようにするツール
    - 類似ツールはCucumberなど（らしい。。まだ使ったことない）
- Playwright
    - ブラウザ操作を自動化するツール
    - 類似ツールはSelenium、Puppeteer、Cypressなど

ただ、gaugeはTAIKOというブラウザ操作自動化ツールが一緒に付いてきたり、Playwrightは@playwright/testというテスティングフレームワークがあったりで、微妙にカバーしている部分が重なっているので、最初よくわかっていないうちは特に紛らわしく感じました。

gaugeをブラウザ操作自動化ツールと組み合わせて使用するケースとして、TAIKOの他にPuppeteerやSeleniumを使う例は見かけたものの、Playwrightと合わせて使用する例はググってもあまり見つからなかったのですが、とりあえず試してみました。

E2Eまわりの全体像や考え方について、以下を参考にさせていただきました。

https://speakerdeck.com/hgsgtk/real-world-e2e-testing



## Playwrightのインストール

ドキュメントに従います。
[https://playwright.dev/docs/intro](https://playwright.dev/docs/intro)

```bash
$ mkdir playwright-test
$ cd playwright-test

$ npm init playwright@latest
Getting started with writing end-to-end tests with Playwright:
Initializing project in '.'
✔ Do you want to use TypeScript or JavaScript? · TypeScript
✔ Where to put your end-to-end tests? · tests
✔ Add a GitHub Actions workflow? (y/N) · false
✔ Install Playwright browsers (can be done manually via 'npx playwright install')? (Y/n) · true
Initializing NPM project (npm init -y)…
Wrote to /path/to/playwright-test/package.json:

{
  "name": "playwright-test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

Installing Playwright Test (npm install --save-dev @playwright/test)…

added 3 packages, and audited 4 packages in 2s

found 0 vulnerabilities
Downloading browsers (npx playwright install)…
Writing playwright.config.ts.
Writing tests/example.spec.ts.
Writing tests-examples/demo-todo-app.spec.ts.
Writing package.json.
✔ Success! Created a Playwright Test project at /path/to/playwright-test

Inside that directory, you can run several commands:

  npx playwright test
    Runs the end-to-end tests.

  npx playwright test --project=chromium
    Runs the tests only on Desktop Chrome.

  npx playwright test example
    Runs the tests in a specific file.

  npx playwright test --debug
    Runs the tests in debug mode.

  npx playwright codegen
    Auto generate tests with Codegen.

We suggest that you begin by typing:

    npx playwright test

And check out the following files:
  - ./tests/example.spec.ts - Example end-to-end test
  - ./tests-examples/demo-todo-app.spec.ts - Demo Todo App end-to-end tests
  - ./playwright.config.ts - Playwright Test configuration

Visit https://playwright.dev/docs/intro for more information. ✨

Happy hacking! 🎭
```

サンプル実行も問題ありませんでした。

```bash
$ npx playwright test

Running 3 tests using 3 workers

  3 passed (5s)

To open last HTML report run:

  npx playwright show-report
```


## gaugeのインストール

こちらもドキュメントに従います。
[https://docs.gauge.org/getting_started/installing-gauge.html?os=macos&language=javascript&ide=vscode](https://docs.gauge.org/getting_started/installing-gauge.html?os=macos&language=javascript&ide=vscode)

MacなのでHomebrewでインストールしています

```bash
$ brew install gauge
```

はじめはTypesScript版でプロジェクトを作成しました。

```bash
$ mkdir gauge-ts-test
$ cd gauge-ts-test
$ gauge init ts
```

しかし、サンプルを実行してみると以下のエラーが出てテストが動作しませんでした。

```bash
$ gauge run
[ValidationError] /path/to/gauge-ts-test/specs/example.spec:11 Step implementation not found => 'Open todo application'
[ValidationError] /path/to/gauge-ts-test/specs/example.spec:14 Step implementation not found => 'Add task "first task"'
...
[ValidationError] /path/to/gauge-ts-test/specs/example.spec:53 Step implementation not found => 'Clear all tasks'
Add the following missing implementations to fix `Step implementation not found` errors.

@Step("Open todo application")
public async implementation05f6f4ee44e29d1f1a5d() {
        throw new Error("Method not implemented.");
}
...
@Step("Clear all tasks")
public async implementationc554134dfe781c78e204() {
        throw new Error("Method not implemented.");
}
Successfully generated html-report to => /path/to/gauge-ts-test/reports/html-report/index.html

Specifications: 0 executed      0 passed        0 failed        1 skipped
Scenarios:      0 executed      0 passed        0 failed        2 skipped
```

tests/StepImplementation.tsには確かに実装は存在するのですが。。。

ぱっと解決できなかったので、試しにJavaScript版で別途プロジェクト作成して試したところ、こちらは問題なく動作しました。

```bash
$ mkdir gauge-js-test
$ cd gauge-js-test

$ gauge init js

$ gauge run
# Getting Started with Gauge
  ## Display number of items     ✔ ✔ ✔ ✔ ✔ ✔
  ## Must list only active tasks         ✔ ✔ ✔ ✔ ✔ ✔ ✔

Successfully generated html-report to => /path/to/gauge-js-test/reports/html-report/index.html

Specifications: 1 executed      1 passed        0 failed        0 skipped
Scenarios:      2 executed      2 passed        0 failed        0 skipped

Total time taken: 20.743s
```

試すのを優先したいので、いったんこのままJavaScript版で進めます。

ちなみにgaugeのバージョンはこちらです。

```bash
$ gauge -v
Gauge version: 1.4.3
Plugins
-------
html-report (4.1.4)
js (2.4.0)
screenshot (0.1.0)
ts (0.1.0)
```


## @playwright/testでテストを記述

ローカルで実行している単純なCRUDアプリ画面の操作を試します。

まずはcodegenで手動操作を記録してみました。
[https://playwright.dev/docs/codegen-intro](https://playwright.dev/docs/codegen-intro)

```bash
$ npx playwright codegen localhost:5173
```

ブラウザが開くので、適当に登録・更新・削除の操作をしてみると、順次コードが出力されます。
![スクリーンショット 2022-10-19 16.31.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123840/c684f8d3-cdd1-9ef4-b371-aa184c7a678f.png)

```typescript
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
  await page.goto('http://localhost:5173/');
  await page.locator('input[type="text"]').first().click();
  await page.locator('input[type="text"]').first().fill('4');
  await page.locator('input[type="text"]').nth(1).click();
  await page.locator('input[type="text"]').nth(1).fill('aaa');
  await page.getByRole('button', { name: 'add' }).click();
  await page.locator('li:has-text("4 updatedelete") input[type="text"]').click();
  await page.locator('li:has-text("4 updatedelete") input[type="text"]').fill('bbb');
  await page.locator('li:has-text("4 updatedelete")').getByRole('button', { name: 'update' }).click();
  await page.locator('li:has-text("4 updatedelete")').getByRole('button', { name: 'delete' }).click();
});
```

これを元に、assertionを追加して多少整えてみます。

```typescript:playwright-test/tests/items.spec.ts
import { test, expect } from '@playwright/test';

const DOMAIN = 'localhost:5173';

const randomId = (length: number) => {
  const randNum = Math.random() * (10 ** length);
  const randStr = Math.floor(randNum).toFixed();
  return randStr;
};

test.beforeEach(async ({ page }) => {
  await page.goto(`http://${DOMAIN}`);
});

test.describe('Items', () => {
  const id = randomId(8);

  test('Add item', async ({ page }) => {
    console.log(id, 'create');
    await expect(page.locator(`li:has-text("${id}") input[type="text"]`)).toHaveCount(0);

    await page.locator('input[type="text"]').first().fill(id);
    await page.locator('input[type="text"]').nth(1).fill('aaa');
    await page.getByRole('button', { name: 'add' }).click();

    await expect(page.locator(`li:has-text("${id}") input[type="text"]`)).toHaveCount(1);
    await expect(await page.inputValue(`li:has-text("${id}") input[type="text"]`)).toEqual('aaa');
  });

  test('Update item', async ({ page }) => {
    console.log(id, 'update');
    await expect(await page.inputValue(`li:has-text("${id}") input[type="text"]`)).toEqual('aaa');

    await page.locator(`li:has-text("${id}") input[type="text"]`).fill('bbb');
    await page.locator(`li:has-text("${id}")`).getByRole('button', { name: 'update' }).click();
    // click直後だとassertタイミングで値がまだ反映されていないので、仕方なくwaitを挟む
    await page.waitForTimeout(100);

    await expect(await page.inputValue(`li:has-text("${id}") input[type="text"]`)).toEqual('bbb');

    await page.goto(`http://${DOMAIN}`);
    await expect(await page.inputValue(`li:has-text("${id}") input[type="text"]`)).toEqual('bbb');
  });

  test('Delete item', async ({ page }) => {
    console.log(id, 'delete');
    await expect(await page.inputValue(`li:has-text("${id}") input[type="text"]`)).toEqual('bbb');

    await page.locator(`li:has-text("${id}")`).getByRole('button', { name: 'delete' }).click();

    await expect(page.locator(`li:has-text("${id}") input[type="text"]`)).toHaveCount(0);
  });
});
```

デフォルトで作成されたplaywright.config.tsのまま実行すると、add,update,deleteの３つのテストが並列実行されて状態不整合となりエラーになってしまったので、playwright.config.tsの`fullyParallel`の設定を削除しました。
デフォルトでは、テストファイル単位で並列実行になるようです。

```typescript:playwright-test/playwright.config.ts抜粋
fullyParallel: true,
```

それでもまだ、複数ブラウザ（chromium,firefox,webkit）での並列実行があるので、DB上でデータ競合しないように登録データのIDをランダムにしています。（とりあえず`describe`ブロック直下でIDの値を定義して使い回していますが、これがテスト間の共有方法として適切なのかはよくわかってません。）

```typescript:playwright-test/tests/items.spec.ts抜粋
...
test.describe('Items', () => {
  const id = randomId(8);
...
```

実行するとこんな感じです。

```bash
$ npx playwright test

Running 12 tests using 4 workers
[chromium] › items.spec.ts:18:3 › Items › add item
84041943 create
[chromium] › items.spec.ts:30:3 › Items › update item
84041943 update
[chromium] › items.spec.ts:44:3 › Items › delete item
84041943 delete
[webkit] › items.spec.ts:18:3 › Items › add item
87787011 create
[firefox] › items.spec.ts:18:3 › Items › add item
78605968 create
[webkit] › items.spec.ts:30:3 › Items › update item
87787011 update
[webkit] › items.spec.ts:44:3 › Items › delete item
87787011 delete
[firefox] › items.spec.ts:30:3 › Items › update item
78605968 update
[firefox] › items.spec.ts:44:3 › Items › delete item
78605968 delete

  12 passed (9s)

To open last HTML report run:

  npx playwright show-report
```


## gaugeでテストを記述

今度はgaugeで書いてみます。

まずはMarkdown側のサンプルをいじります。

```markdown:gauge-js-test/specs/items.spec
# Getting Started with Gauge

This is an example markdown specification file.
Every heading in this file is a scenario.
Every bulleted point is a step.

To execute this specification, use
	npm test

This is a context step that runs before every scenario
* Open items application

## CRUD Items
* Add task id = "4" , item = "aaa"

* Must have id = "4" , item = "aaa"

* Update task id = "4" , item = "bbb"

* Must have id = "4" , item = "bbb"

* Delete task id = "4"

* Must not have id = "4"
```

次に、サンプルのテストコードではTAIKOでブラウザ操作している部分をPlaywrightに書き換えたいのですが、@playwright/testの`test`や`expect`は、gaugeの役割とかぶっていてそのまま使えなさそうです。

そこで、純粋なブラウザ操作自動化ツールとしてのPlaywrightを別途インストールして使用します。

```bash
# gauge-js-testのプロジェクトフォルダで
$ npm i -D playwright
```

@playwright/testでは`test`メソッドのコールバック引数で渡されていた`page`を、代わりにgaugeの`beforeSuite`内で生成して使い回しています。
[https://playwright.dev/docs/api/class-browser](https://playwright.dev/docs/api/class-browser)

また、@playwright/testの`expect`メソッドはassertで代用しています。

ブラウザ操作のコードは前述のPlaywrightのコードをコピペします。

```javascript:gauge-js-test/tests/step_implementation.js
/* globals gauge*/
"use strict";
const assert = require("assert");
const playwright = require('playwright');
const browserType = 'chromium';

let browser;
let page;

const DOMAIN = 'localhost:5173';

beforeSuite(async () => {
    browser = await playwright[browserType].launch();
    const context = await browser.newContext();
    page = await context.newPage();
});

afterSuite(async () => {
    await browser.close();
});

step("Open items application", async function () {
    await page.goto(`http://${DOMAIN}`);
});

step("Add item id = <id> , item = <item>", async (id, item) => {
    assert.strictEqual(await page.locator(`li:has-text("${id}") input[type="text"]`).count(), 0);

    await page.locator('input[type="text"]').first().fill(id);
    await page.locator('input[type="text"]').nth(1).fill(item);
    await page.getByRole('button', { name: 'add' }).click();
    await page.waitForTimeout(100);
});

step("Must have id = <id> , item = <item>", async function (id, item) {
    assert.strictEqual(await page.locator(`li:has-text("${id}") input[type="text"]`).count(), 1);
    assert.strictEqual(await page.inputValue(`li:has-text("${id}") input[type="text"]`), item);
});

step("Update item id = <id> , item = <newItem>", async (id, newItem) => {
    assert.strictEqual(await page.locator(`li:has-text("${id}") input[type="text"]`).count(), 1);
    assert.notStrictEqual(await page.inputValue(`li:has-text("${id}") input[type="text"]`), newItem);

    await page.locator(`li:has-text("${id}") input[type="text"]`).fill(newItem);
    await page.locator(`li:has-text("${id}")`).getByRole('button', { name: 'update' }).click();
    await page.waitForTimeout(100);
});

step("Delete item id = <id>", async function (id) {
    await page.locator(`li:has-text("${id}")`).getByRole('button', { name: 'delete' }).click();
    await page.waitForTimeout(100);
});

step("Must not have id = <id>", async function (id) {
    assert.strictEqual(await page.locator(`li:has-text("${id}") input[type="text"]`).count(), 0);
});
```

操作のstepとassertのstepを分けて書いていると特に、Playwrightのauto-waitがうまく効いてくれず、エラーになってしまうので、手動で`page.waitForTimeout(100)`を挟んでいます。これが対処方法として適切なのかは自信ないです。

これで実行もうまくいきました。

```bash
$ gauge run
# Getting Started with Gauge
  ## CRUD items  ✔ ✔ ✔ ✔ ✔ ✔ ✔

Successfully generated html-report to => /path/to/gauge-js-test/reports/html-report/index.html

Specifications: 1 executed      1 passed        0 failed        0 skipped
Scenarios:      1 executed      1 passed        0 failed        0 skipped

Total time taken: 1.382s
```


## Playwrightのコードを共通化

最後に、ちょっと実際にニーズがあるか微妙ですが、Playwrightの似たようなコードを、gaugeと@playwright/testの両方で使い回したいこともあるかもと思い、Playwright部分のコードを切り出してみました。

```javascript:gauge-js-test/operations/items.operation.js
const playwright = require('playwright');

const DOMAIN = 'localhost:5173';

class ItemsOperation {

  constructor(page) {
    this.page = page
  }

  async open() {
    await this.page.goto(`http://${DOMAIN}`);
  }

  async add(id, item) {
    await this.page.locator('input[type="text"]').first().fill(id);
    await this.page.locator('input[type="text"]').nth(1).fill(item);
    await this.page.getByRole('button', { name: 'add' }).click();
    await this.page.waitForTimeout(500);
  }

  async update(id, item) {
    await this.page.locator(`li:has-text("${id}") input[type="text"]`).fill(item);
    await this.page.locator(`li:has-text("${id}")`).getByRole('button', { name: 'update' }).click();
    await this.page.waitForTimeout(500);
  }

  async delete(id) {
    await this.page.locator(`li:has-text("${id}")`).getByRole('button', { name: 'delete' }).click();
    await this.page.waitForTimeout(500);
  }

  async itemExists(id) {
    return await this.page.locator(`li:has-text("${id}") input[type="text"]`).count() === 1;
  }

  async itemValueOf(id) {
    return await this.page.inputValue(`li:has-text("${id}") input[type="text"]`);
  }

  static async create() {
    const browserType = 'chromium';
    const browser = await playwright[browserType].launch();
    const context = await browser.newContext();
    const page = await context.newPage();
    const itemsOperation = new ItemsOperation(page);
    return [
      browser,
      itemsOperation
    ];
  }
}

module.exports = ItemsOperation;
```

これを使用すると、gaugeと@playwright/testのテストコードはそれぞれ以下になります。

- gaugeの場合

```javascript:gauge-js-test/tests/gauge/step_implementation.js
/* globals gauge*/
"use strict";
const assert = require("assert");

const ItemsOperation = require('../../operations/items.operation.js');

let browser;
let itemsOperation;

beforeSuite(async () => {
    [ browser, itemsOperation ] = await ItemsOperation.create();
});

afterSuite(async () => {
    await browser.close();
});

step("Open items application", async function () {
    await itemsOperation.open();
});

step("Add item id = <id> , item = <item>", async (id, item) => {
    assert.ok(!(await itemsOperation.itemExists(id)));
    await itemsOperation.add(id, item);
});

step("Must have id = <id> , item = <item>", async function (id, item) {
    assert.ok(await itemsOperation.itemExists(id));
    assert.strictEqual(await itemsOperation.itemValueOf(id), item);    
});

step("Update item id = <id> , item = <newItem>", async (id, newItem) => {
    assert.ok(await itemsOperation.itemExists(id));
    assert.notStrictEqual(await itemsOperation.itemValueOf(id), newItem);    
    await itemsOperation.update(id, newItem);
});

step("Delete item id = <id>", async function (id) {
    await itemsOperation.delete(id);
});

step("Must not have id = <id>", async function (id) {
    assert.ok(!(await itemsOperation.itemExists(id)));
});
```

- @playwright/testの場合

gaugeのプロジェクトフォルダに@playwright/testをインストールしておきます。

```bash
# gauge-js-testプロジェクトで
npm i -D @playwright/test
```

```typescript:gauge-js-test/tests/playwright/items.spec.ts
import { test, expect } from '@playwright/test';

import ItemsOperation from '../../operations/items.operation';

const randomId = (length: number) => {
  const randNum = Math.random() * (10 ** length);
  const randStr = Math.floor(randNum).toFixed();
  return randStr;
};

let itemsOperation;

test.beforeEach(async ({ page }) => {
  itemsOperation = new ItemsOperation(page);
  await itemsOperation.open();
});

test.describe('New Item', () => {
  const id = randomId(8);

  test('should allow me to add item', async ({ page }) => {
    console.log(id, 'create');
    await expect(await itemsOperation.itemExists(id)).toBeFalsy();

    await itemsOperation.add(id, 'aaa');

    await expect(await itemsOperation.itemExists(id)).toBeTruthy();
    await expect(await itemsOperation.itemValueOf(id)).toEqual('aaa');
  });

  test('should allow me to update items', async ({ page }) => {
    console.log(id, 'update');
    await expect(await itemsOperation.itemValueOf(id)).toEqual('aaa');

    await itemsOperation.update(id, 'bbb');

    await expect(await itemsOperation.itemValueOf(id)).toEqual('bbb');

    await itemsOperation.open();
    await expect(await itemsOperation.itemValueOf(id)).toEqual('bbb');
  });

  test('should allow me to delete items', async ({ page }) => {
    console.log(id, 'delete');
    await expect(await itemsOperation.itemValueOf(id)).toEqual('bbb');

    await itemsOperation.delete(id);

    await expect(await itemsOperation.itemExists(id)).toBeFalsy();
  });
});
```

gaugeと@playwright/testの間での共用はないとしても、ブラウザ操作のコードを切り出して抽象化することで、テストコードがすっきりして意図が伝わりやすくなったり、複数のシナリオで共通のブラウザ操作を使い回せるメリットはあるかと思いました。

ちなみに最終的なpackage.jsonとディレクトリ構造は以下です。

```jsonc:gauge-js-test/package.json
{
  "name": "gauge-taiko-template",
  "description": "Starter template for writing JavaScript tests for Gauge",
  "scripts": {
    "test:gauge": "gauge run specs/", // 追加
    "test:playwright": "playwright test" // 追加
  },
  "devDependencies": {
    "@playwright/test": "^1.27.1",
    "playwright": "^1.27.1"
  }
}
```

```bash
gauge-js-test
├── .dockerignore
├── .gauge
│   ├── executionStatus.json
│   ├── failures.json
│   ├── lastRunCmd.json
│   └── screenshots
├── .gitignore # Playwrightプロジェクトの.gitignoreとマージした
├── Dockerfile # gaugeプロジェクト作成時にできたが、TAIKO前提に見えたので使ってない
├── env
│   └── default
│       ├── default.properties
│       ├── headless.properties
│       └── js.properties
├── logs
│   └── gauge.log
├── manifest.json
├── node_modules
├── operations
│   └── items.operation.js
├── package-lock.json
├── package.json
├── playwright-report
│   └── index.html
├── playwright.config.ts # Playwrightプロジェクトからコピペ
├── reports
│   └── html-report
├── specs
│   └── items.spec
└── tests
    ├── gauge
    │   └── step_implementation.js
    └── playwright
        └── items.spec.ts
```


## おわりに

gaugeとPlaywrightを組み合わせてE2Eテストの実行を試すことができました。

Playwrightについては、Puppeteerは触ったことがあったので、今回のような初歩的な実装は特に違和感なくできました。
gaugeのようなツールは初めて触りましたが、過去にドメイン層のユニットテストのテキストをユビキタス言語で書いたうえでテスト実行ログを非エンジニアの方に確認してもらっていたのに近いものを感じました。markdown部分とテスト実装部分の作成を非エンジニアを含めてどのように分担・連携していくかをよく考える必要がありそうです。

次は、できればこれをGithub ActionsなどでCIでも実行させたいところですが、テスト対象の環境をどう用意するかが要検討です。

ちなみに、gaugeで検索すると別のライブラリ（CUIでProgress barを出すやつ？）の情報もよく出てくるので紛らわしいです。Githubリポジトリはgetgaugeの方です。