@xstate/test + playwrightで効率よくe2eテストをする方法

# 問題

https://app-jet.vercel.app/

出典[^1]

[こういうアプリ][1][^2]があるとして全てのルートを網羅するには普通のやり方だとかなり面倒臭い。


- 開く、エスケープで逃げる
- 開く、閉じるボタンで逃げる
- 開く、GOODを押して表示が変わるのを確認
- 開く、GOODを押してエスケープで逃げる
- 開く、GOODを押して閉じるボタンで逃げる
- ...

> これはまだ簡単な方で、複数ステップのあるフォームでステップ間を行ったり来たり出来るとかだとテストがカオス

# 解決

<iframe width="560" height="315" src="https://www.youtube.com/embed/r8cLzO9I-GA" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>


[playwright][4]で[@xstate/test][5]を使う
> テストする対象のサイトはxstateを使ってるかどうかは関係ない

1. playwrightを通常通りインストールして[tests/foo.spec.ts][3]が動けばOK。
1. @xstate/testをインストール
1. テストを下みたいに書く


> より効率の良い書き方としては、フォームを記入して送信するみたいな場合だったとして、ダラダラ1つのイベントに一式の操作を書くのでなく

> 1. フォームを開く
1. エラーのある記入をする
1. エラーのない記入をする
1. 送信する

> という風にEventを分けて、

> 1. フォームが開いたstate
1. エラーのある記入state
1. エラーのない記入state
1. 送信後のstate

> という各stateへtargetしていてやるとテストできるpathが自動生成されるのであらゆるパターンに対応できる。

```ts
import { expect, Page, test } from "@playwright/test"
import { createModel } from "@xstate/test"
import { createMachine } from "xstate"

// ここで取り出してtestに使える
// meta: {
//   test: async ({ page }: TestContext) => {
//   },
// }
interface TestContext {
  page: Page
  // page以外にも好きなものを入れられる
}
interface Context {}

// 大文字、dot区切りとかにしておくとstate名と見分けが付き易い
type Event =
  | { type: "GOOD" }
  | { type: "BAD" }
  | { type: "COMPLAIN.VALID" }
  | { type: "COMPLAIN.SUBMIT" }
  | { type: "CLOSE" }
  | { type: "ESCAPE" }

const machine = createMachine<Context, Event>({
  // idを付けておかないとフルパスでtargetが指定できない
  id: "feedback",
  initial: "idle",
  // contextは通常は要らない
  // 同じ箇所だけど1回目、2回目とかでテストの内容が違うとかの場合は、counter的なものを入れる
  // https://xstate.js.org/docs/packages/xstate-test/#testmodel-getshortestpathplans-options
  // ここにあるサンプルコードみたいにfilterを入れておかないと
  // context: {},
  on: {
    // どのstateからでも呼ばれるからroot位置のEvent
    // bad stateのchild stateから呼ばれる事もあるのでmachineのIDからのフルパスで指定しておく
    CLOSE: {
      target: "#feedback.closed",
    },
    ESCAPE: {
      target: "#feedback.closed",
    },
  },
  states: {
    idle: {
      on: {
        GOOD: {
          target: "thanks",
        },
        BAD: { target: "bad" },
      },
      // 各stateに必ずmeta/testが要る
      // そうすることで一番下にある model.testCoverage() がパスする
      meta: {
        test: async ({ page }: TestContext) => {
          await expect(page.locator(`.ui-app header`)).toContainText(
            "How was your experience?"
          )
        },
      },
    },
    bad: {
      initial: "idle",
      on: {
        // 今回の場合、未記入・記入済どちらでもsubmitは出来るし、同じ結果が表示されるので
        "COMPLAIN.SUBMIT": { target: "thanks" },
      },
      states: {
        idle: {
          on: {
            // 今回はエラーが起きないが、入力必須などはこういうstateでチェックできる
            // "COMPLAIN.INVALID": { target: "invalid" },
            "COMPLAIN.VALID": { target: "valid" },
          },
          meta: {
            test: async ({ page }: TestContext) => {
              await expect(page.locator(`.ui-app header`)).toContainText(
                "Care to tell us why?"
              )
            },
          },
        },
        // 今回はエラーが起きないが、入力必須などはこういうstateでチェックできる
        // invalid: {
        //   meta: {
        //     test: async ({ page }: TestContext) => {},
        //   },
        // },
        valid: {
          meta: {
            test: async ({ page }: TestContext) => {
              // https://playwright.dev/docs/selectors#id-data-testid-data-test-id-data-test-selectors
              await expect(page.locator(`data-testid=response-input`)).toHaveValue(
                "this is the message"
              )
            },
          },
        },
      },
      meta: {
        test: async ({ page }: TestContext) => {
          // idleでテストしているのでココは不要だが、無いとmodel.testCoverage()が検知してしまう
        },
      },
    },
    thanks: {
      meta: {
        test: async ({ page }: TestContext) => {
          await expect(page.locator(`.ui-app header`)).toContainText(
            "Thanks for your feedback."
          )
        },
      },
    },
    closed: {
      type: "final",
      meta: {
        test: async ({ page }: TestContext) => {
          await expect(page.locator(`.ui-app header`)).toHaveCount(0)
        },
      },
    },
  },
})

// 宣言したEventに対して実際にはどういう事を行うのかを書く
// expectを使うようなテストは書かない（書いても問題ないけど、それは各stateのmeta/testで書く）
// Eventを宣言する時は行動（Action）をよく考えて分割したほうが良い
// 例）
// フォームを開いて、記入して、エラーがないので送信
// 1．フォームを開く（formを開いたというstateに移動してテスト）
// 2．記入する（記入したというstateに移動してテスト）
// 3．送信する（送信したというstateに移動してテスト）
// という風にActionとstateを区切ってそれぞれテストする方がシンプルにできる
const model = createModel<TestContext>(machine).withEvents({
  CLOSE: {
    exec: async ({ page }) => {
      // https://playwright.dev/docs/selectors#id-data-testid-data-test-id-data-test-selectors
      await page.locator(`data-testid=close-button`).click()
    },
  },
  ESCAPE: {
    exec: async ({ page }) => {
      await page.locator(`body`).click({
        // ただのclickだとド真ん中を押して、そこにtextareaがあるとそこにフォーカスが行ってしまうので
        position: {
          x: 0,
          y: 0,
        },
      })
      await page.keyboard.press("Escape")
    },
  },
  GOOD: {
    exec: async ({ page }) => {
      await page.locator(`text=GOOD`).click()
    },
  },
  BAD: {
    exec: async ({ page }) => {
      await page.locator(`text=BAD`).click()
    },
  },
  "COMPLAIN.VALID": {
    exec: async ({ page }) => {
      // https://playwright.dev/docs/selectors#id-data-testid-data-test-id-data-test-selectors
      await page.locator(`data-testid=response-input`).fill("this is the message")
    },
  },

  // COMPLAIN: {
  //   // event:anyにするしか無い？
  //   exec: async ({ page }, event: any) => {
  //     await page.locator(`data-testid=response-input`).fill(event.value)
  //   },
  //   // https://xstate.js.org/docs/packages/xstate-test/#model-withevents-eventsmap
  //   // casesとしてあらゆるパターンのデータで同じeventを起こせる
  //   // inputのエラーパターンが有る場合とかで便利
  //   cases: [{ value: "this is the message" }, { value: "another message" }],
  // },
  "COMPLAIN.SUBMIT": {
    exec: async ({ page }) => {
      await page.locator(`text=SUBMIT`).click()
    },
  },
})

// データベースが絡んだりすると面倒なことになりやすいので、.serialにして順番に実行したほうが良い
test.describe.serial("feedback", () => {
  // getShortestPathPlans
  // https://xstate.js.org/docs/packages/xstate-test/#testmodel-getshortestpathplans-options
  // getSimplePathPlans
  // https://xstate.js.org/docs/packages/xstate-test/#testmodel-getsimplepathplans-options
  // getShortestPathPlansは最短ルートしか辿らないので、testが漏れる可能性がある
  // contextを使って制御できたりもするが...
  // https://www.youtube.com/watch?v=9gQulfnG7HM
  // getSimplePathPlansだと、とりあえず全部一通り通る
  const plans = model.getSimplePathPlans()

  plans.forEach((plan) => {
    test.describe(plan.description, () => {
      plan.paths.forEach((path) => {
        test(path.description, async ({ page }) => {
          await page.goto(`/`, {
            waitUntil: "networkidle",
          })

          // interface TestContextは.testに渡すオブジェクト（オブジェクトでなくても何でも良い）
          await path.test({ page })
          // await path.test({ page, data })
          // test.beforeEachとかでページ個別のdataを保持してここで渡してテストに使うとか、何でも良い
        })
      })
    })
  })

  test("should have full coverage", async () => {
    model.testCoverage()
  })
})
```

```terminal
pwuser@f8ab7943ea45:/app$ yarn test
yarn run v1.22.10
warning package.json: No license field
$ playwright test
Using config at /app/playwright.config.ts

Running 19 tests using 1 worker

  ✓  tests/feedback.spec.ts:203:9 › feedback › reaches state: "idle"  › via  (5s)
  ✓  tests/feedback.spec.ts:203:9 › feedback › reaches state: "closed"  › via CLOSE (5s)
  ✓  tests/feedback.spec.ts:203:9 › feedback › reaches state: "closed"  › via ESCAPE (5s)
  ✓  tests/feedback.spec.ts:203:9 › feedback › reaches state: "closed"  › via GOOD → CLOSE (5s)
  ✓  tests/feedback.spec.ts:203:9 › feedback › reaches state: "closed"  › via GOOD → ESCAPE (5s)
  ✓  tests/feedback.spec.ts:203:9 › feedback › reaches state: "closed"  › via BAD → COMPLAIN.VALID → COMPLAIN.SUBMIT → CLOSE (5s)
  ✓  tests/feedback.spec.ts:203:9 › feedback › reaches state: "closed"  › via BAD → COMPLAIN.VALID → COMPLAIN.SUBMIT → ESCAPE (5s)
  ✓  tests/feedback.spec.ts:203:9 › feedback › reaches state: "closed"  › via BAD → COMPLAIN.VALID → CLOSE (5s)
  ✓  tests/feedback.spec.ts:203:9 › feedback › reaches state: "closed"  › via BAD → COMPLAIN.VALID → ESCAPE (5s)
  ✓  tests/feedback.spec.ts:203:9 › feedback › reaches state: "closed"  › via BAD → COMPLAIN.SUBMIT → CLOSE (5s)
  ✓  tests/feedback.spec.ts:203:9 › feedback › reaches state: "closed"  › via BAD → COMPLAIN.SUBMIT → ESCAPE (5s)
  ✓  tests/feedback.spec.ts:203:9 › feedback › reaches state: "closed"  › via BAD → CLOSE (5s)
  ✓  tests/feedback.spec.ts:203:9 › feedback › reaches state: "closed"  › via BAD → ESCAPE (5s)
  ✓  tests/feedback.spec.ts:203:9 › feedback › reaches state: "thanks"  › via GOOD (5s)
  ✓  tests/feedback.spec.ts:203:9 › feedback › reaches state: "thanks"  › via BAD → COMPLAIN.VALID → COMPLAIN.SUBMIT (5s)
  ✓  tests/feedback.spec.ts:203:9 › feedback › reaches state: "thanks"  › via BAD → COMPLAIN.SUBMIT (5s)
  ✓  tests/feedback.spec.ts:203:9 › feedback › reaches state: {"bad":"idle"}  › via BAD (5s)
  ✓  tests/feedback.spec.ts:203:9 › feedback › reaches state: {"bad":"valid"}  › via BAD → COMPLAIN.VALID (5s)
  ✓  tests/feedback.spec.ts:217:3 › feedback › should have full coverage (1ms)

  Slow test: tests/feedback.spec.ts (2m)

  19 passed (2m)
Done in 92.00s.
```

```ts:playwright.config.ts
import { PlaywrightTestConfig } from "@playwright/test"
const config: PlaywrightTestConfig = {
  timeout: 10000,
  use: {
    baseURL: "https://app-jet.vercel.app/",

    // Context options
    viewport: { width: 800, height: 600 },

    // Artifacts
    screenshot: "only-on-failure",
    video: "retain-on-failure",
  },
}
export default config
```

# 参考

https://qiita.com/github0013@github/items/299d90e62cf436c537eb


[1]: https://youtu.be/pA3DXExjKqI?t=694
[2]: https://codepen.io/davidkpiano/pen/dc81af7260581c1fbbf5b5154caa2228
[3]: https://playwright.dev/docs/intro#first-test
[4]: https://playwright.dev/docs/intro
[5]: https://xstate.js.org/docs/packages/xstate-test/

[^1]: https://codepen.io/davidkpiano/pen/dc81af7260581c1fbbf5b5154caa2228
[^2]: codepenにtest掛けるとロボット判定されるので...