問題
出典1
こういうアプリ2があるとして全てのルートを網羅するには普通のやり方だとかなり面倒臭い。
- 開く、エスケープで逃げる
- 開く、閉じるボタンで逃げる
- 開く、GOODを押して表示が変わるのを確認
- 開く、GOODを押してエスケープで逃げる
- 開く、GOODを押して閉じるボタンで逃げる
- ...
これはまだ簡単な方で、複数ステップのあるフォームでステップ間を行ったり来たり出来るとかだとテストがカオス
解決
テストする対象のサイトはxstateを使ってるかどうかは関係ない
- playwrightを通常通りインストールしてtests/foo.spec.tsが動けばOK。
- @xstate/testをインストール
- テストを下みたいに書く
より効率の良い書き方としては、フォームを記入して送信するみたいな場合だったとして、ダラダラ1つのイベントに一式の操作を書くのでなく
- フォームを開く
- エラーのある記入をする
- エラーのない記入をする
- 送信する
という風にEventを分けて、
- フォームが開いたstate
- エラーのある記入state
- エラーのない記入state
- 送信後の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
参考
-
https://codepen.io/davidkpiano/pen/dc81af7260581c1fbbf5b5154caa2228 ↩
-
codepenにtest掛けるとロボット判定されるので... ↩