0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Playwrightで「固定秒数待ち」がタグ入力を黙って飛ばした話

0
Posted at

Web画面を自動操作する投稿ツールで、本文は公開できているのにタグだけ入らない、という事故がありました。

先に、今回の根拠になった運用ログを公開向けに匿名化して引用します。

「公開に進む」クリック後に固定4秒待ちのみで公開設定画面を前提にしていた

本文が長く「保存中」が続く記事では、公開設定画面へ未到達のままタグ入力欄を探した

hashtag input not found - skip を出して、黙ってタグ0件のまま公開した

同じ経路で別記事はタグ付与できていたため、タイミング依存の再現性なきバグだった

要するに、原因は「タグ入力欄のセレクタ」ではなく、「その画面にまだ到達していないのに探しに行ったこと」でした。

起きたこと

自動投稿の流れは、おおまかに次のようなものでした。

  1. エディタ画面にタイトルと本文を入力する
  2. 「公開に進む」ボタンを押す
  3. 公開設定画面でタグを入力する
  4. 公開ボタンを押す
  5. 公開後にAPIで状態を検証する

問題のある実装では、2 のあとに固定時間だけ待っていました。

await page.getByRole("button", { name: "公開に進む" }).click();
await page.waitForTimeout(4000);

const tagInput = page.locator('input[placeholder*="タグ"]');

if (await tagInput.count()) {
  await tagInput.fill("Playwright");
} else {
  console.warn("hashtag input not found - skip");
}

短い本文では、4秒後には公開設定画面に移動していたため、このコードでも動いているように見えました。

しかし本文が長いと、クリック後もしばらく「保存中」のままです。4秒後の時点では、まだエディタ画面に残っていることがあります。その状態でタグ入力欄を探しても、当然見つかりません。

ここでさらに悪かったのは、タグ入力欄が見つからない場合にエラーにせず、警告だけ出して処理を続けたことです。結果として、投稿自体は成功し、タグだけが欠落しました。

最小再現

実サービスを使わなくても、画面遷移が遅いページを用意すると同じ構造を再現できます。

<!-- index.html -->
<button id="next">公開に進む</button>
<div id="status"></div>

<script>
  document.querySelector("#next").addEventListener("click", () => {
    document.querySelector("#status").textContent = "保存中...";

    setTimeout(() => {
      document.body.innerHTML = `
        <label>
          タグ
          <input aria-label="タグ" />
        </label>
        <button>公開</button>
      `;
    }, 7000);
  });
</script>

これに対して、固定4秒待ちでタグ欄を探すテストを書きます。

import { test } from "@playwright/test";

test("fixed timeout can miss the tag input", async ({ page }) => {
  await page.goto("http://localhost:3000");

  await page.getByRole("button", { name: "公開に進む" }).click();
  await page.waitForTimeout(4000);

  const tagInput = page.getByLabel("タグ");

  if (await tagInput.count()) {
    await tagInput.fill("Playwright");
  } else {
    console.warn("tag input not found - skip");
  }
});

このテストは「失敗」せずに終わります。だから危険です。

本当はタグ入力が必須なのに、コード上は「見つからなければスキップ」という任意処理になっています。これだと、期待した状態になっていないことを自動化コード自身が見逃します。

修正方針

修正は大きく3つです。

  1. 固定秒数ではなく、URLや要素の状態を待つ
  2. 必須要素が見つからない場合はハードエラーにする
  3. 投稿前後に、期待した状態を検証する

たとえば Playwright なら、画面遷移を明示的に待ちます。

await page.getByRole("button", { name: "公開に進む" }).click();
await page.waitForURL(/\/publish\//, { timeout: 30_000 });

const tagInput = page.getByLabel("タグ");
await tagInput.waitFor({ state: "visible", timeout: 20_000 });

await tagInput.fill("Playwright");
await page.keyboard.press("Enter");

ただし、URLだけに依存するのも十分ではありません。

SPAではURLが変わっても、必要なフォームがまだ描画されていないことがあります。そこで、URLの到達と入力欄の表示を分けて待ちます。

async function waitForPublishSettings(page) {
  await page.waitForURL(/\/publish\//, { timeout: 30_000 });

  const tagInput = page.getByLabel("タグ");
  await tagInput.waitFor({ state: "visible", timeout: 20_000 });

  return { tagInput };
}

「見つからなければスキップ」をやめる

今回のような公開フローでは、タグが必須ならスキップしてはいけません。

async function addTags(page, tags) {
  const { tagInput } = await waitForPublishSettings(page);

  for (const tag of tags) {
    await tagInput.fill(tag);
    await page.keyboard.press("Enter");
  }
}

もしタグ入力欄が出なければ、waitFor がタイムアウトして処理は止まります。

この時点では、まだ公開ボタンを押していません。失敗しても未公開で止まるので、後から修復するより安全です。

投稿前に画面上の状態を確認する

タグを入力したつもりでも、UI側でチップ化されていない可能性があります。

そのため、公開ボタンを押す前に「画面上のタグ数」を確認します。

async function assertTagChips(page, expectedCount) {
  const chips = page.locator("[data-testid='tag-chip']");
  await chips.first().waitFor({ state: "visible", timeout: 10_000 });

  const actualCount = await chips.count();

  if (actualCount !== expectedCount) {
    throw new Error(`tag count mismatch: expected=${expectedCount}, actual=${actualCount}`);
  }
}

実際のサービスでは data-testid がないことも多いので、その場合はロール、ラベル、テキスト、近傍要素などから、できるだけ安定した locator を作ります。

重要なのは、タグ入力操作を「やったつもり」で終わらせず、UI上の結果まで見ることです。

投稿後はAPIで検証する

画面操作の成功ログだけでは、公開後の状態は保証できません。

公開後に取得APIがあるなら、別経路で検証します。

async function verifyPublishedArticle({ itemId, expectedTags, token }) {
  const res = await fetch(`https://example.com/api/items/${itemId}`, {
    headers: {
      Authorization: `Bearer ${token}`,
    },
  });

  if (!res.ok) {
    throw new Error(`verify failed: ${res.status}`);
  }

  const item = await res.json();
  const actualTags = item.tags.map((tag) => tag.name).sort();
  const expected = [...expectedTags].sort();

  if (JSON.stringify(actualTags) !== JSON.stringify(expected)) {
    throw new Error(
      `tag mismatch: expected=${expected.join(",")}, actual=${actualTags.join(",")}`,
    );
  }
}

APIトークンは環境変数から読む前提です。ブラウザに露出するフロントエンドコードへ直接埋め込まないようにします。

const token = process.env.PUBLISH_API_TOKEN;

if (!token) {
  throw new Error("PUBLISH_API_TOKEN is required");
}

修正後の全体像

最終的には、次のような流れにしました。

async function publishArticle(page, article) {
  await page.goto("https://example.com/editor");

  await page.getByLabel("タイトル").fill(article.title);
  await page.getByLabel("本文").fill(article.body);

  await page.getByRole("button", { name: "公開に進む" }).click();

  const { tagInput } = await waitForPublishSettings(page);

  for (const tag of article.tags) {
    await tagInput.fill(tag);
    await page.keyboard.press("Enter");
  }

  await assertTagChips(page, article.tags.length);

  await page.getByRole("button", { name: "公開" }).click();
  await page.waitForURL(/\/items\//, { timeout: 30_000 });
}

ポイントは、待機を「時間」ではなく「状態」に寄せたことです。

  • 公開設定画面のURLに到達したか
  • タグ入力欄が表示されたか
  • タグチップが期待数だけ作られたか
  • 公開後APIでタグが反映されているか

ここまで見ると、同じようなタイミング依存の失敗をかなり見つけやすくなります。

学び

固定秒数待ちは、ローカルでは動いているように見えます。

でも、本文量、ネットワーク、保存処理、サーバー側の混雑、ブラウザの描画タイミングが少し変わるだけで壊れます。しかも今回のように「見つからなければスキップ」という実装と組み合わさると、壊れたことに気づきにくくなります。

自動化コードでは、次の2つを分けて考えるのが大事でした。

  • 待つべきものは何か
  • 見つからなかったときに続行してよいものか

タグ、価格、公開範囲、送信先のように、公開結果に影響する項目は「任意に見えるUI部品」でも、業務上は必須です。

必須なら、スキップではなく停止。

このルールを入れるだけで、Playwrightの自動操作はかなり堅くなります。

0
0
1

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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?