以前Mentorで作ったODCのAppを対象に、E2Eテストを"Happy Path"1つに対して作成してみる。
E2EテストツールにはPlaywrightを使い、ボイラープレートとテストのほとんどは、GitHub Copilotに作ってもらった。
環境情報
GitHub Copilot Pro
モデルはAuto(GTP-5.3-Codexが選ばれていた)
Visual Studio Code (Version: 1.111.0 (Universal))
Playwright ("@playwright/test": "^1.58.2")
テストシナリオ
- ログイン実施
- Productsリンクをクリックし、Product一覧画面へ
- Add Productボタンをクリックし、詳細画面へ
- フォーム入力
- Saveボタンをクリックして保存
- 一覧画面に遷移するので、フィルタとして入力に使ったProduct Nameを入力して確かに追加されていることを確認する
実際の画面は以下の通り。この流れをE2Eテストケースとして実現する。
1

4, 5 詳細画面では一覧中で目立つところに表示されるInput2つと、必須のDropDownList2つに入力。その後Saveボタンをクリック

.envに保存する情報
テスト対象AppのURL、ログインに使うアカウントは.envに保存。値は実際に使う情報を埋める。
ODC_BASE_URL=
ODC_USERNAME=
ODC_PASSWORD=
ODC_BASE_URLは、以下の場所でテスト対象AppのBaseURLとして使う(アカウントについては後続のログイン部分で)
export default defineConfig({
// 省略
use: {
baseURL: process.env.ODC_BASE_URL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure"
},
ログイン部分
テストシナリオでいう「1. ログイン実施」に該当する。
projectsをsetupと本体に分割し、本体の依存としてsetupを指定する。
これによって、setupを、テストの一連の実行ごとに一回のみ動くように指定している。
const AUTH_FILE = "playwright/.auth/user.json";
export default defineConfig({
// 省略
projects: [
{
name: "setup",
testMatch: /.*\.setup\.ts/
},
{
name: "chromium",
use: { ...devices["Desktop Chrome"], storageState: AUTH_FILE },
dependencies: ["setup"]
}
]
});
"setup" projectのtestMatch指定により、ファイル名がsetup.tsで終わる以下のファイルが(setup実行タイミングで)自動で実行される。
import { test as setup, expect } from "@playwright/test";
const AUTH_FILE = "playwright/.auth/user.json";
setup("authenticate", async ({ page }) => {
await page.goto("");
await page.getByLabel("Email").fill(process.env.ODC_USERNAME ?? "");
await page.getByRole("textbox", { name: "Password" }).fill(process.env.ODC_PASSWORD ?? "");
await page.getByRole("button", { name: /^(sign in|log in)$/i }).click();
// Wait until login flow is settled before persisting storage state.
await expect(page).not.toHaveURL(/\/Login(?:\?|$)/i);
await page.context().storageState({ path: AUTH_FILE });
});
やっていることは、Appにアクセス(暗黙でログイン画面へリダイレクト)、EmailとPasswordに.envの情報を入力、Log inボタンをクリック、Home画面に遷移するまで待って1 から情報をファイルに保存する。
問題点:フォーム入力中に値がクリアされてしまう
これはAggregateの終了より早く入力が始まるため、2項目ほど入力した後でAggregateの値によって上書きされるため。
要するに、Aggregateの終了を待ってから入力を始めれば良い。Aggregateの場合、画面アクセスと同時に「ScreenDataSet<Aggregate名>」のエンドポイントに通信を行うため、その通信が終わるのを待てば良い(以下の例はフォームへの入力項目をGetProductByIdというAggregateで取得している場合)。
// wait for the Aggregate to end
const formDataLoaded = page.waitForResponse((response) =>
response.url().includes("/ScreenDataSetGetProductById") &&
response.status() === 200
);
await formDataLoaded;
DropDownListの選択も、対応するAggregateの終了を待たなければいけないはずだが、上記の通信終了を待った上で、Input2項目の入力後に選択するのでタイミング的にうまくいくようなので待たないことにした。テストの安定性を考えると待った方がいいかもしれない。
テストシナリオ実装
テストシナリオの番号と、コード内のコメントに付与したStep NのNを対応させてある。
import { expect, test, type Locator } from "@playwright/test";
test("Create new Product - happy path", async ({ page }) => {
const productCode = `PRD-${Date.now().toString().slice(-3)}`;
const productName = `E2E Product ${Date.now()}`;
// Step 1: Login is done by auth setup project; verify we are not on login.
await page.goto("");
await expect(page).not.toHaveURL(/\/Login(?:\?|$)/i);
// Step 2: Navigate to Products screen from the side menu.
const productsLink = page.getByRole("link", { name: /^products$/i });
await productsLink.click();
await expect(page).toHaveURL(/\/Products(?:\?|$)/i);
// Step 3: Open New Product screen.
const addProductButton = page.getByRole("button", { name: /^add product$/i });
await addProductButton.click();
// Step 4: Fill required fields.
// wait for the Aggregate to end
const formDataLoaded = page.waitForResponse((response) =>
response.url().includes("/ScreenDataSetGetProductById") &&
response.status() === 200
);
await expect(page.getByRole("heading", { name: /^new product$/i })).toBeVisible();
await formDataLoaded;
const productCodeField = page.getByLabel(/^product code$/i);
await productCodeField.fill(productCode);
await expect(productCodeField).toHaveValue(productCode);
const productNameField = page.getByLabel(/^product name$/i);
await productNameField.fill(productName);
await expect(productNameField).toHaveValue(productName);
const warehouse = page.getByRole("combobox", { name: /warehouse/i }).first();
await warehouse.selectOption({ label: "Leonard PLC" });
await expect(warehouse.locator("option:checked")).toHaveText(/leonard plc/i);
const storageLocation = page.getByRole("combobox", { name: /storage location/i }).first();
await storageLocation.selectOption({ label: "Cold Storage" });
await expect(storageLocation.locator("option:checked")).toHaveText(/cold storage/i);
// Step 5: Save the product.
const saveButton = page.getByRole("button", { name: /^save$/i });
await expect(saveButton).toBeEnabled();
await saveButton.click();
await expect(page).toHaveURL(/\/Products(?:\?|$)/i);
await expect(page.getByRole("heading", { name: /^products$/i })).toBeVisible();
// Step 6: Filter by product name and confirm created record exists.
const filterInput = page.getByRole("searchbox", { name: /^search$/i });
await filterInput.fill(productName);
await expect(filterInput).toHaveValue(productName);
await filterInput.press("Enter");
const createdRow = page.getByRole("row").filter({ hasText: new RegExp(productName, "i") }).first();
await expect(createdRow).toBeVisible({ timeout: 15_000 });
});
テストシナリオ実行結果
-
expect(page).not.toHaveURL(//Login(?:?|$)/i)は、URLにLoginが含まれないという状態を待つという意味 ↩



