LoginSignup
4
2

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-11-05

問題

出典1

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

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

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

解決

playwright@xstate/testを使う

テストする対象のサイトはxstateを使ってるかどうかは関係ない

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

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

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

という風にEventを分けて、

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

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

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()
  })
})
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.
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

参考


  1. https://codepen.io/davidkpiano/pen/dc81af7260581c1fbbf5b5154caa2228 

  2. codepenにtest掛けるとロボット判定されるので... 

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2