Playwrightで開いているタブから特定の画面が存在するか確認する方法

概要

Playwrightで「いま開いているタブの中に特定の画面が存在するか」をbooleanで返すヘルパー関数の作り方について書いていきます。

E2Eテストを書いていると、「ボタンを押したら別タブで詳細画面が開く」「リダイレクト後に意図した画面が立ち上がっているかを確認したい」みたいな場面が地味に多いですよね。

毎回specの中でcontext.pages()を舐めるコードを書くと冗長になりがちなので、「URLかtitleに対象文字列が含まれていればtrue」と返す関数を1つ用意しておくとシナリオがかなりスッキリします。

この記事では、そのcheckScreen関数の実装と、土台になるBrowserContextの考え方、新規タブを待ち受けるwaitForEventの使い方、そしてforEachではなくfor...ofで逐次awaitする理由まで一通り整理しました。

短い関数ですがサクッと使い回せるので、Playwrightで複数タブを扱う必要がある方はぜひ見ていってください!

それではやっていきましょう(^^!

目次

やりたかったこと

やりたいことを具体化するとこういう構成です。

  • 同一のBrowserContext内で複数タブ(Page)が開いている可能性がある
  • 引数で渡した文字列(screenName)が、いずれかのタブのURLまたはtitleに含まれていればtrueを返したい
  • Page Object Modelのメソッドや、シナリオのassertion前段で使い回せるようにしたい

ようはX画面が立ち上がってるのかを確認するための関数というだけの話です。

E2Eだと「クリック→別タブが開く→そのタブで正しい画面が表示されているか」という流れの検証が本当によく出てきます。
そのたびにforを書いてurl()title()を比較して……とやると、specの本筋が見えづらくなるんですよね。
なので「画面が存在するか」だけを真偽値で返す薄いヘルパーに切り出しておくのがお勧めです。

checkScreen関数の実装

実装はこれだけです。

helpers.ts

1
2
3
4
5
6
7
8
9
10
11
12
async checkScreen(page: Page, screenName: string): Promise<boolean> {
const pages = page.context().pages();

for (const p of pages) {
const url = p.url();
const title = await p.title();
if (url.includes(screenName) || title.includes(screenName)) {
return true;
}
}
return false;
}
  • page.context().pages()同じBrowserContext内の全Page(タブ)の配列を取る
  • 各タブのurl()title()を見て、screenNameが含まれていれば即trueを返す
  • 全部回しても見つからなければfalse

シンプルですが、この関数を使用すると1行で「画面の存在のチェック」ができます。

引数をpageにしているのがポイントで、どのタブのPageを渡しても、そこからcontext()をたどれば同じコンテキスト内の全タブが見えるので、呼び出し側はあまり深く考えずに手元のpageを渡せばOKです。

BrowserContextと開いている全タブの取得

page.context().pages()で同コンテキスト内の全タブを取得

ここが今回の肝です。

BrowserContextはCookieやlocalStorage、セッションを共有する単位で、その中で複数のPage(=タブ)を持てます。

context.pages()を呼ぶと、そのコンテキスト内で現在開かれている全ページの配列が返ってきます。

  • page.context() … そのpageが属するBrowserContext
  • page.context().pages() … 同じコンテキスト内の全タブの配列
  • browser.contexts() … さらに上位、別コンテキストまで広げて見るとき用

ふつうのE2Eシナリオで「ログイン後に開いた別タブ」を確認したいときは、同じコンテキスト内に居るはずなのでpage.context().pages()で十分です。

コンテキストをまたぐケースとの違い

ちなみに「別タブ」と「別コンテキスト」は別物です。

同じブラウザでも、browser.newContext()で作り直したコンテキストはCookieもセッションも分離されます。
シークレットウィンドウを別で開いたようなイメージですね。

  • 同一コンテキスト内の別タブ
    • context.pages()で取得できる。ログインセッションを共有したまま別画面を開くケースはこちら
  • 別コンテキストのタブ
    • context.pages()には出てこない。browser.contexts()で各コンテキストを取り、それぞれpages()を見る必要がある

今回のcheckScreen「ログイン済みセッションのまま開いた別タブ」を見たいケースを想定しているので、page.context().pages()だけで完結させています。

URLとtitleの両方を見る理由

URLだけ、titleだけだと取りこぼすケースがあるので両方見ています。

  • URL: /users/profileのようなパス命名
  • title: <title>プロフィール | アプリ名</title>のような表示文字列

どちらかにscreenNameが含まれていれば拾えるので、ルーティング命名と画面タイトル命名のブレを吸収できます。

profileで引きたいけどtitleには「プロフィール」しか出ない」「逆にtitleが「Profile」でURLが/u/123」みたいな現場あるある対策ですね(^^

実はtitle()は同期的なurl()と違ってPromiseを返すので、ここでawaitが必要になります。
このあたりがループの書き方に効いてくるので、次の節で触れます。

for...ofを使う理由(forEachでawaitが効かない)

これはちょっとした小話ですが、ループはfor...ofを使うのがポイントです。

1
2
3
4
5
6
7
8
9
10
11
// ❌ これだと await が外側に伝わらない
pages.forEach(async (p) => {
const title = await p.title();
// ...
});

// ✅ for...of なら素直に await できる
for (const p of pages) {
const title = await p.title();
// ...
}

Array.prototype.forEachはasyncコールバックを渡しても外側は待ってくれないので、こういう「逐次でawaitしたい」処理ではfor...ofが無難です。

forEachは各コールバックの戻り値(asyncならPromise)を無視してただ回すだけなので、title()の解決を待たずにループを抜けてしまいます。
結果として、早期return trueがループ外に伝わらない/チェックが終わる前にfalseが返るといった事故になりがちです。

逐次でいいならfor...of、並列で投げたいならPromise.all(pages.map(...))、と覚えておくと迷いません。
今回は見つかった時点で即returnしたい(=早期脱出したい)のでfor...ofが素直です。

specでの使い方(waitForEventで新規タブを待つ)

実際のspecではこんな感じで使います。
「ボタンを押すと別タブでプロフィール画面が開く」ようなフローの検証パターンです。

profile.spec.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { test, expect } from "@playwright/test";
import { checkScreen } from "./helpers";

test("プロフィールリンクをクリックすると別タブでプロフィール画面が開く", async ({ page, context }) => {
await page.goto("/");

// 別タブが開くのを待ちながらリンクをクリック
const [newPage] = await Promise.all([
context.waitForEvent("page"),
page.click("a[data-testid='profile-link']"),
]);
await newPage.waitForLoadState("domcontentloaded");

// ヘルパーで「プロフィール画面が立ち上がっているか」を確認
expect(await checkScreen(page, "profile")).toBe(true);
});
  • context.waitForEvent("page")で新規タブが開くのを待ってからclickを発火
  • Promise.allで「待ち受け」と「クリック」を同時にスタートしないと、クリック後に発火するイベントを取りこぼす
  • 開いたあとにwaitForLoadState("domcontentloaded")DOM構築完了まで待ってからcheckScreenを呼ぶことで安定して実行できるようにしてます(^^

checkScreen引数のpageが属するcontextを見にいくだけなので、newPageではなく元のpageを渡してもOKです(同じcontext内なので結果は同じ)。

ここでPromise.allを使う理由がわかりにくいかもしれませんが、先にclickしてしまうと、タブが開くイベントを待ち始める前に発火が終わっていて取りこぼすからです。
「待ち受けを仕掛けてから操作する」という順番をワンセットで書けるのがPromise.allの便利なところですね。

なお、開いた直後はまだURLがabout:blankだったりtitleが空だったりすることがあります。
そのためwaitForLoadStateで読み込みを待ってからチェックするのが安定運用のコツです(^^b

まとめ

いかがでしたか?
今回はPlaywrightで「開いているタブから特定の画面が存在するか」を確認するヘルパー関数を整理してみました!

今回紹介した点をまとめると以下の通り(^^

  • page.context().pages()で同BrowserContext内の全タブを取得できる
  • ・URLとtitleの両方をチェックすることで命名のブレを吸収
  • ・ループはfor...ofで書けばawaitがそのまま直列で利用可能
  • ・新規タブはcontext.waitForEvent("page")Promise.allで待ち受けてから操作する

E2Eで複数タブを扱うシナリオは増えがちなので、こういう小さなヘルパーを1つ持っておくとシナリオがかなり読みやすくなります。

自分も仕事でも個人開発でもPlaywrightを回している民なので、こういう細かいユーティリティはどんどん記事として残していきたいですね!

ちなみに自分がPlaywrightでE2Eを組んでいる「こぴぺったり」は、コピー&ペーストを簡単に行えるツールになっております。
コピーしたいデータを登録し、登録したデータを押すと文字列がコピーされる仕組みです!
メールアドレスやメールの定型文、PGのよくある書き方集など使い道は様々、ディレクトリ階層がアプリ内にあり、分けやすさも兼ね備えております!

そしてなんと、「完全無料」「登録不要」「コピペデータのデータ送信なし」というセキュリティ的にも安心の一品です。
こぴぺったりcopype.shinpinoshi.com
ここから使用できるので、是非使用してみてください!

以上となります。
こういった細かい関数をナレッジとして残しておくとあとで誰かから感謝されたりします!
それでは、お疲れさまでした(^^b