PlaywrightでカスタムURLスキームへの遷移をテストする

こんにちは。メディアサービス開発部Webアプリケーション開発課の奥川です。ニコニコ漫画のバックエンド開発を担当しています。

ニコニコ漫画ではe2eテストに Playwright を導入しており、OpenID Connectでの認証フローやAPIテストなど主にiOS/Androidアプリとの連携部分の検証に利用しています。

アプリ連携ではカスタムURLスキームを用いてアプリを起動することがあり、この際に意図通りのURLやパラメータを応答できているかを確認したいテストケースが存在します。カスタムURLスキームとは、 http://https:// の代わりに example:// のような独自のプレフィックスを用いて特定のアプリを直接起動できるURLパターンです。これにより、ウェブページや他のアプリから特定のアプリ内の特定のページや機能を直接呼び出すことが可能になります。

PlaywrightでこのようなカスタムURLスキームに対するテストを実現するには若干の工夫が必要となるため、今回はその方法について紹介します。

テストシナリオ

  • リンククリックでカスタムURLスキームに遷移する
  • エンドポイントへのアクセスでカスタムURLスキームにリダイレクトされる

という2種類のシナリオを想定して、どちらも example://foo/bar?baz=xxx へ遷移されるものとします。サンプルとしてサーバ側は以下のようなコードを想定しています。

import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { html } from "hono/html";

const app = new Hono();

const redirectUrl = "example://foo/bar?baz=xxx";
const indexHtml = html`
  <!DOCTYPE html>
  <html>
    <body>
      <h1>Link to Custom Scheme</h1>
      <div>
        <a href="${redirectUrl}" data-testid="redirect-link">${redirectUrl}</a>
      </div>
    </body>
  </html>
`;

app.get("/", (c) => c.html(indexHtml));
app.get("/redirect", (c) => c.redirect(redirectUrl));
app.get("/redirect-twice", (c) => c.redirect("/redirect"));

serve(app);

/ へのアクセスでカスタムURLスキームへのリンクを含むHTMLが返り、/redirect へのアクセスでカスタムURLスキームへのリダイレクトが返ります。/redirect-twice は複数回のリダイレクト後にカスタムURLスキームへリダイレクトされるケースを再現します。

このアプリケーションを使ってカスタムURLスキームへの遷移をテストしていきたいと思います。

テストの実践

リンクをクリックしてカスタムURLスキームに遷移する

テストページにアクセスしてリンクをクリックします。このリンク先はカスタムスキームとなっているので遷移先のURLを確認すればテストができるような気がします。以下のようなコードを動かしてみましょう。

// テストページにアクセスしてリンクをクリック
await page.goto("/");
await page.getByTestId("redirect-link").click();

// 遷移後のURLを検証
expect(page.url()).toBe("example://foo/bar?baz=xxx");

このテストは失敗します。

Error: expect(received).toBe(expected) // Object.is equality

Expected: "example://foo/bar?baz=xxx"
Received: "http://localhost:3000/"

page.url() が元のページURLを返すのは、PlaywrightがカスタムURLスキームを標準のナビゲーションフローとして認識しないためです。このようにサーバからのエラー応答などを受信せずにナビゲーションが失敗するようなケースでは Page#on('requestfailed')*1 イベントリスナーを使用して失敗したリクエストのURLをキャプチャし、テストのアサーションで期待された遷移先を検証します。

let redirectedUrl = null;

page.on("requestfailed", async (request) => {
  redirectedUrl = request.url();
});

最終的なテストコードはこのようになります。

test("click link to custom scheme", async ({ page }) => {
  let redirectedUrl = null;

  page.on("requestfailed", async (request) => {
    redirectedUrl = request.url();
  });

  await page.goto("/");
  await page.getByTestId("redirect-link").click();

  const { protocol, hostname, pathname, search } = new URL(redirectedUrl);
  const params = new URLSearchParams(search);

  expect(protocol).toBe("example:");
  expect(hostname).toBe("foo");
  expect(pathname).toBe("/bar");
  expect(params.get("baz")).toBe("xxx");
});

カスタムURLスキームにリダイレクトされる (Page編)

リンククリックではなく、ページアクセス時にカスタムURLスキームにリダイレクトされるケースをテストしてみます。/redirect にアクセスして遷移先のURLを検証するコードを書いてみます。

// リダイレクトされるページにアクセス
await page.goto("/redirect");

// 遷移後のURLを検証
expect(page.url()).toBe("example://foo/bar?baz=xxx");

このテストもエラーが発生して失敗します。

Error: page.goto: net::ERR_ABORTED at http://localhost:3000/redirect

リダイレクト先がカスタムURLスキームの場合、Page は net::ERR_ABORTED でナビゲーションを中断します。また、このままではエラー発生時に前述の Page#on('requestfailed') でリクエスト失敗をハンドリングすることもできません。

このような場合は net::ERR_ABORTED のエラーを catch して処理が中断されないようにします。

await page.goto("/redirect").catch((e) => console.debug(e));

その上で先ほどと同様に Page#on('requestfailed') を使用して遷移先のURLを取得することでリダイレクトのテストが可能になります。

test("redirected to custom scheme", async ({ page }) => {
  let redirectedUrl = null;

  page.on("requestfailed", async (request) => {
    redirectedUrl = request.url();
  });

  await page.goto("/redirect").catch((e) => console.debug(e));

  const { protocol, hostname, pathname, search } = new URL(redirectedUrl);
  const params = new URLSearchParams(search);

  expect(protocol).toBe("example:");
  expect(hostname).toBe("foo");
  expect(pathname).toBe("/bar");
  expect(params.get("baz")).toBe("xxx");
});

どちらも遷移失敗を前提にテストをするという部分で気持ち悪さがありますが、カスタムURLスキームがブラウザにとって正常な遷移として扱われない以上はやむを得ないでしょう。

カスタム URL スキームにリダイレクトされる (APIRequestContext編)

APIRequestContext でリダイレクトを処理するケースについてもテストしてみます。

// リダイレクトされるページへのリクエスト
const response = await request.get("/redirect");

// HTTPステータスコード 302 が返り、カスタムスキームのURLがリダイレクト先として指定されること
expect(response.status()).toBe(302);
expect(response.headers()["location"]).toBe("example://foo/bar?baz=xxx");

このテストは非対応のプロトコルであるとして以下のようなエラーが発生します。

TypeError: apiRequestContext.get: Protocol "example:" not supported. Expected "http:"

この問題への対処として、リダイレクト回数が1回のみと分かっている場合には送信時オプションとして maxRedirects: 0*2 を渡すことでリダイレクトに追従しない動作となり、応答のステータスコードやヘッダを取得することができます。

test("redirected to custom scheme", async ({ request }) => {
  const response = await request.get("/redirect", {
    maxRedirects: 0,
  });
  expect(response.status()).toBe(302);

  const redirectedUrl = response.headers()["location"];
  const { protocol, hostname, pathname, search } = new URL(redirectedUrl);
  const params = new URLSearchParams(search);

  expect(protocol).toBe("example:");
  expect(hostname).toBe("foo");
  expect(pathname).toBe("/bar");
  expect(params.get("baz")).toBe("xxx");
});

ただし、リダイレクト回数が不定のときにはこのままでは不十分で、リダイレクトの遷移をトラッキングして応答ヘッダを逐次確認するような泥臭い対応が必要になります。APIRequestContext では Page#on('response') のように応答タイミングでのイベントフックもできないため、 maxRedirects: 0 でリダイレクトを抑止しながら遷移先を保存して手動でリクエストを辿っていくことになります。

test("multiple redirects led to custom scheme", async ({ request }) => {
  let redirectedUrl = null;

  const getWithRedirectTracking = async (url) => {
    try {
      const response = await request.get(url, {
        maxRedirects: 0,
      });

      if (response.status() == 302) {
        const headers = await response.headers();
        redirectedUrl = headers["location"] ?? redirectedUrl;

        await getWithRedirectTracking(redirectedUrl);
      }
    } catch (e) {
      console.debug(e);
    }
  };

  await getWithRedirectTracking("/redirect-twice");

  const { protocol, hostname, pathname, search } = new URL(redirectedUrl);
  const params = new URLSearchParams(search);

  expect(protocol).toBe("example:");
  expect(hostname).toBe("foo");
  expect(pathname).toBe("/bar");
  expect(params.get("baz")).toBe("xxx");
});

ここまでくると強引すぎる感じもしますが、APIRequestContext でカスタムURLスキームのリダイレクトを厳密にテストするには必要な手順になるようです。

まとめ

PlaywrightでカスタムURLスキームへの遷移をテストする方法について紹介しました。

各テストを見ても分かるようにPlaywrightとカスタムスキームは相性が悪く、いずれのパターンでも異常系をハンドリングすることでテストを通す書き方となっています。 Playwrightは標準のHTTPプロトコルを想定して設計されているため、 example:// のようなカスタムスキームは自動的に認識されません。これはテストプロセスにおいて、標準的なウェブブラウザとは異なる振る舞いをするための特別な考慮が必要であることを意味します。 本記事で紹介した手法は直感的ではありませんが、webページやAPIサーバとアプリが密接に連携している場合など同じフレームワーク内でテストを実施したいというニーズを満たすことができるでしょう。同じようなケースでテストに悩んでいる方の一助になれば幸いです。

テストに使ったコード一式は how-to-test-custom-scheme-in-playwright に置いてあります。

補足

カスタムURLスキームは古いスマートフォンでも利用可能で現在もアプリ連携で使われる仕組みですが、不正なアプリによるスキームの乗っ取りというセキュリティリスクが伴うため*3、現在ではUniversal LinksAndroid App Linksを利用することでより安全かつ柔軟にアプリ連携を行うことが推奨されています。ドメインやサーバを用意する必要があるため簡単に導入できる仕組みではありませんが、これらに対応できる場合にはアプリ連携にHTTPSスキームでのリンクが使用できるためPlaywrightでも容易に取り扱うことができるでしょう。

最後に

ブックウォーカーでは新しい技術に興味を持ち、創造的な問題解決を楽しむ仲間を募集しています。もし興味がありましたら、是非ブックウォーカーの採用情報ページからご応募ください。