テスト・CI

Playwrightのメールテスト:本物の受信箱からコードを読む

Playwrightのテストは、サインアップフォームを2秒で入力できても、その先で何が起きたかはわかりません。次のステップがメールだからです。ここでは、それを読み取るフィクスチャ — APIキー不要で、本物のメールボックスから届く本物のメッセージを読む — と、テストを不安定にしないための3つの習慣を紹介します。

  • 中級
  • 読了20分
青いカーソル矢印のある灰色のブラウザーウィンドウ、側面のスロットに滑り込む青い封筒、手前の灰色のストップウォッチ

Playwrightのテストが、たいてい止まってしまう場所

ほとんどのサインアップテストは、「受信箱を確認してください」という一文で終わります。フォームは入力され、ボタンはクリックされ、ページは正しいメッセージを表示した — そして、その一文より先で起きることは、すべて「そうなっているはず」で済まされます。メールが実際に送信されたか、その中のコードがサーバーの期待するコードと一致するか、確認リンクが正しく動くページを開くか。そのすべてが、本番環境に委ねられたままになっています。

そうなってしまうのは、次のステップが非同期で、ブラウザの外側で起きることであり、Playwrightにはクリックできる対象が何もないからです。それを回避する一般的な3つの方法は、それぞれ違うことを証明します。

メーラーをモックする
自分のコードがsend()を呼び出したことは証明できますが、テンプレートやリンク、メッセージを拒否したプロバイダについては何も証明できません。
ローカルのSMTPシンク(Mailpit、MailHog、smtp4dev)
正しい形式のメッセージがアプリケーションから送出されたことは証明できます。CIにサービスが1つ増えるうえに、公開インターネット上でしか起きないこと — 実際のMXルックアップ、実際のプロバイダ、実際の受信者 — は、ここでは何ひとつ起きません。
本物の使い捨てメールボックス
メッセージがアプリケーションから送出され、インターネットを渡り、本物のメールサーバーに受理され、機能するコードを運んでいることまで証明できます。唯一のコストは、テストが正しく待たなければならないことです — それがこのガイドのすべてです。

その裏にあるAPIは、キー不要のエンドポイント3つで、リファレンスにまとまっています。Playwright固有の話に入る前に一般的な原則を知りたい場合は、確認フローをエンドツーエンドでテストするで、どのテストランナーにも通用する内容を扱っています。こちらはそのPlaywright版であり、それを快適にするフィクスチャ付きです。

すべてのテストに専用の受信箱を与えるフィクスチャ

Playwrightのtest.extendが、これにふさわしい置き場所です。受信箱は、pageのように名前で要求できるものになり、アドレスは毎回新しく作られます。サーバー側で何かを作成する必要はありません — メールボックスはメールが届いた瞬間に存在するようになります — そのため、このフィクスチャはランダムなアドレスを持つクラスで、3つの小さなメソッドを備えています。

tests/fixtures.ts
import { test as base, expect } from '@playwright/test';

const API = 'https://grabmail.io/api/v1';
const DOMAIN = 'grabmail.io';

export type Summary = { id: string; from: string; subject: string; date: string };
export type Message = {
  id: string; from: string; to: string; subject: string; date: string;
  text: string | null; html: string | null;
};

const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

export class Inbox {
  readonly address: string;

  /** A mailbox nothing else in this run, or any previous run, is using. */
  constructor(prefix = 'e2e') {
    this.address = `${prefix}-${Math.random().toString(36).slice(2, 10)}@${DOMAIN}`;
  }

  /** Block until a matching message arrives, or the deadline passes. */
  async waitFor(opts: { timeoutMs?: number; subjectContains?: string; fromContains?: string } = {}): Promise<Message> {
    const deadline = Date.now() + (opts.timeoutMs ?? 60_000);

    while (Date.now() < deadline) {
      const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(this.address)}`);

      if (res.status === 429) {                       // slow down, do not fail
        await sleep(Number(res.headers.get('retry-after') ?? 1) * 1000);
        continue;
      }
      if (!res.ok) throw new Error(`GET /mailbox answered ${res.status} for ${this.address}`);

      const { messages } = (await res.json()) as { messages: Summary[] };
      const hit = messages.find(m =>
        (!opts.subjectContains || m.subject.toLowerCase().includes(opts.subjectContains.toLowerCase())) &&
        (!opts.fromContains    || m.from.toLowerCase().includes(opts.fromContains.toLowerCase())));
      if (hit) return this.read(hit.id);

      await sleep(1000);                              // one read a second, never throttled
    }
    throw new Error(`no message for ${this.address} within the deadline`);
  }

  async read(id: string): Promise<Message> {
    const res = await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`);
    if (!res.ok) throw new Error(`GET /message answered ${res.status}`);
    return res.json() as Promise<Message>;
  }

  /** Optional: everything expires on its own after a few days. Idempotent. */
  async delete(id: string): Promise<void> {
    await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`, { method: 'DELETE' });
  }
}

export const test = base.extend<{ inbox: Inbox }>({
  inbox: async ({}, use) => {
    await use(new Inbox());
  },
});

export { expect };

あのファイルの中で意図的にそうしている点が2つあります。アドレスは、ファイル単位や実行単位ではなく、テスト単位でランダムです。そのため、並列に動くワーカー同士が互いのメールを読んでしまうことは決してありません。そしてwaitForは、要約ではなくメッセージ全体を返します — 実際には次に本文が欲しくなることがほとんどなので、すべてのテストで呼び出しが1回減ることが積み重なっていきます。

スリープを使わずにメッセージを待つ

メールは同期的ではありません。通常は2〜3秒で届きますが、まれに20秒かかることもあり、テストがどう待つかによって、そのスイートを信頼できるかどうかが決まります。ルールは短いものです。

  • 試行回数ではなく期限で判断する。for (let i = 0; i < 30; i++)は、ループがたまたま動く速度での30回の試行にすぎません — APIが速くなれば短くなり、送信元が遅くなれば長くなります。実時間の期限であれば、どのマシンでも同じ意味を持ちます。
  • 1秒に1回の読み取り。これはドキュメントに定められたペースであり、これより遅くする必要はありません。これより速くすると429Retry-Afterヘッダーで拒否されますし、速くポーリングしたところでメールが早く届くわけでもありません。
  • waitForTimeoutは使わない。固定のスリープは、遅い日には短すぎ、それ以外のほとんどの日には長すぎます。ループは、メッセージが存在するようになった瞬間に止まります。
  • フィルタする。最新のメッセージを無条件に採用しない。subjectContainsfromContainsを指定してください。1つのフローが2通のメッセージ — ウェルカムメールとコード — を送る場合、最新のものが欲しいメッセージとは限りません。

ループが出会うことになるステータスコードと、それぞれに対して何をすべきか。

コード意味ループの動作
200メールボックスは読み取れました。count0のこともあります — 空のメールボックスが404になることは決してありません。一致するものを探します。なければ1秒スリープして、もう一度試します。
400アドレスの形式が不正です。例外を投げます。タイプミスは、リトライしても直りません。
404そのドメインは、ここではホストされていません。例外を投げます。独自ドメインの場合は、MXレコードを確認してください。
429そのアドレスに対して1秒に1回を超えて読み取ったか、このランナーから1分間に1200リクエストを超えて送っています。Retry-After秒だけスリープして続行します。429でテストを失敗させることは絶対にしないでください。

メッセージからコードやリンクを取り出す

メッセージは両方の部分を含んで返ってきます。どちらをパースするかは、アプリケーションが何を送っているかによります。

text
プレーンテキストの部分です。これが存在する場合はこちらをパースしてください — マークアップがなく、6桁のコードはそのまま6桁のコードです。
html
HTMLの部分です。送信元がテキストのみを送った場合はnullになります。確認用のリンクは、<a href>の中、ここにしか存在しないことが多く、&&amp;として書かれています。
tests/extract.ts
import type { Message } from './fixtures';

/** The whole body, both parts, with HTML entities a link may carry undone. */
const bodyOf = (m: Message) => `${m.text ?? ''}\n${m.html ?? ''}`.replace(/&amp;/g, '&');

/** Anchored on your own wording, so a reworded template fails loudly. */
export function codeFrom(m: Message, pattern = /code is\s*([0-9]{6})/i): string {
  const hit = bodyOf(m).match(pattern);
  if (!hit) throw new Error(`no confirmation code in "${m.subject}"`);
  return hit[1];
}

/** The link whose path contains a fragment you know — never "the first URL". */
export function linkFrom(m: Message, pathContains: string): string {
  const re = new RegExp(`https?://[^\\s"'<>]*${pathContains}[^\\s"'<>]*`);
  const hit = bodyOf(m).match(re);
  if (!hit) throw new Error(`no link containing "${pathContains}" in "${m.subject}"`);
  return hit[0];
}

このパターンは、意図的に自分のテンプレートの文言に固定されています。[0-9]{6}だけでは、たまたま最初に出てくる年号や価格、注文番号にも簡単にマッチしてしまいます。code is ([0-9]{6})なら、自分のコードだけにマッチし、それ以外には一切マッチしません — そして誰かがメールの文言を変えた日には、間違った数字で通ってしまうのではなく、テストが失敗してそれを教えてくれます。

リンクは、「最初に出てきたURL」ではなく、/confirm//reset/のように自分が知っているパスの断片で照合してください。トランザクションメールには、たいてい5つのURLが含まれているからです — ロゴ、配信停止、ヘルプセンター、アプリストアのバッジ、そして本当に欲しいもの。

3つのフロー、エンドツーエンドで

フィクスチャと抽出ロジックさえあれば、各テストは、それが検証している機能そのものを読んでいるかのようになります。待機とポーリングとパースはどこか別の場所にあり、それこそがそれらをそこに置いた理由です。

確認コードによるサインアップ

tests/signup.spec.ts
import { test, expect } from './fixtures';
import { codeFrom } from './extract';

test('a new account confirms its email address', async ({ page, inbox }) => {
  await page.goto('/signup');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByLabel('Password').fill('correct-horse-battery-staple');
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByText('Check your inbox')).toBeVisible();

  const message = await inbox.waitFor({ subjectContains: 'confirm' });

  await page.getByLabel('Confirmation code').fill(codeFrom(message));
  await page.getByRole('button', { name: 'Confirm' }).click();
  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});

ユーザーをサインインさせるマジックリンク

入力するものは何もありません。テストはメッセージが運んでくるリンクにアクセスし、どこに着地するかを検証します。

tests/magic-link.spec.ts
import { test, expect } from './fixtures';
import { linkFrom } from './extract';

test('a magic link signs the user in', async ({ page, inbox }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Email me a link' }).click();

  const message = await inbox.waitFor({ subjectContains: 'sign in' });
  await page.goto(linkFrom(message, '/auth/magic/'));

  await expect(page).toHaveURL(/\/dashboard/);
});

パスワードのリセットと、新しいパスワードでのログイン

リセットのテストには、すでに存在するユーザーが必要です。これはブラウザの仕事ではなく、アプリケーション自身のテスト用の入り口 — 内部エンドポイント、データベースのフィクスチャ、CLIなど — の仕事です。そこから先のフローは他と同じ形です。リクエストし、待ち、たどり、検証する。

tests/password-reset.spec.ts
import { test, expect } from './fixtures';
import { linkFrom } from './extract';

test('a password reset link changes the password', async ({ page, inbox, request }) => {
  // Your application's own test seam: an internal endpoint, a DB fixture, a CLI.
  await request.post('/internal/test/users', { data: { email: inbox.address, password: 'old-password-1' } });

  await page.goto('/forgot-password');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Send reset link' }).click();

  const message = await inbox.waitFor({ subjectContains: 'reset' });
  await page.goto(linkFrom(message, '/reset/'));
  await page.getByLabel('New password').fill('new-password-2');
  await page.getByRole('button', { name: 'Change password' }).click();

  await page.goto('/login');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByLabel('Password').fill('new-password-2');
  await page.getByRole('button', { name: 'Log in' }).click();
  await expect(page).toHaveURL(/\/dashboard/);
});

CIを生き延びさせる

ここまでの内容はすべて、ノートPC上では問題なく動きます。ここから先は、1日に20回、他人のマシンで実行されて初めて壊れるものです。

症状原因対処法
ローカルでは通るが、CIでは失敗するランナーが公開インターネットに到達できないか、アウトバウンド通信がフィルタされています。grabmail.ioへのHTTPSを許可してください。それ以外は不要です — SMTPポートもインバウンドも不要です。
1回目は失敗し、リトライすると通る送信元がメールをキューイングしていて、期限がそのキューより短くなっています。他の何かをいじる前に、まず期限を延ばしてください。トランザクションメールなら60秒が妥当な上限です。
429がバースト的に発生する複数のテストが1つのアドレスをポーリングしているか、ランナー全体で1分間に1200リクエストを超えています。テストごとに1アドレス — フィクスチャがそれを保証します。クライアント側の上限は、1秒に1回ポーリングするメールボックス20個分です。
グリーンのビルド、壊れた機能使い回されたアドレスが、古いメッセージを返しています。テストごとにランダムなアドレスを使ってください。これが最も重要です。
ワーカーが複数のときだけ不安定になる2つのテストが1つのメールボックスを共有しているか、どれが最新のメッセージかをアサーションしています。テストごとに新しいアドレスを使い、subjectContainsでフィルタしてください。最新のメッセージを無条件に使ってはいけません。
1週間は動くが、その後まったく動かなくなるメッセージIDをキャッシュしているフィクスチャです。ここではすべてのメッセージが5日後に削除されます。テストは、実行のたびに自分自身でメールを発生させる必要があります。5日を超えて残るものは何もありません。

保存すべきシークレットはありません。公開ドメインはキーもアカウントもヘッダーも不要です — パイプラインがこれらのテストを実行するのに認証情報を必要としているなら、どこかで誤解が生じています。書き留めておく価値がある設定はタイムアウトだけです。メールを待つテストにおいて、Playwrightのデフォルト値が唯一間違っている部分だからです。

playwright.config.ts
// playwright.config.ts — the project that reads mail gets a timeout above the mail deadline
export default defineConfig({
  timeout: 120_000,
  expect: { timeout: 10_000 },
  fullyParallel: true,          // safe: every test has its own inbox
});

このスイートを実行するGitHub Actionsのワークフローは、期限とアウトバウンド通信の問題を解決した形で、CIガイドに書き出してあります。

アプリケーションが使い捨てドメインを拒否する場合

サインアップフォームの中には、アドレスを使い捨てドメインの公開リストと照合し、grabmail.ioを見た瞬間に拒否するものがあります。それはアプリケーションの仕様であって、テストの不具合ではありません — そして、正しい対処法はテスト環境向けにチェックを緩めることではありません。代わりに、独自ドメインをこのサービスに向けてください。MXレコード1件、アカウント不要で、そのドメイン上のすべてのアドレスが、定数を1つ変えるだけで同じフィクスチャから読み取れるメールボックスになります。

ドメインをキャッチオール受信箱に変えるではその設定方法を、1つのドメインで無制限のテストアカウントを持つではそれをテストスイートの中でどう使うかを扱っています。

完了と呼ぶ前に

  • すべてのテストで、フィクスチャから受け取る別々のアドレスを使う — 定数は使わない。
  • 実時間の期限を使い、失敗時には待っていたアドレスを名指しする。
  • 429は失敗させるのではなく、Retry-Afterの分だけスリープして処理する。
  • コードやリンクは、単なるパターンではなく、自分の文言に対して照合する。
  • テストのタイムアウトは、メールの期限より余裕を持って長くする。
  • 件名や送信者でフィルタし、2通届いたときに正しいメッセージが選ばれるようにする。
  • メールがどれだけ速く届いたかは検証せず、届いたことだけを検証する。

これがすべての原則です。Playwrightでメールをテストすることについて、それ以外の部分は、他の非同期な処理をテストするのと変わりません。同じヘルパーをCypressのコマンドとして書いたものはCypressガイドにあり、どのテストランナーにも通用する抽出ルールだけを取り出したものは自動テストにおけるOTPコードにあります。

質問

Playwrightからメールボックスを読み取るのにAPIキーは必要ですか?

いいえ。公開ドメインはキーもアカウントもヘッダーも不要です。使い捨てメールのブロックリストに載らない有料ドメインプールだけがAuthorization: Bearerヘッダーを必要としますが、それは別製品です。

テストを並列のワーカーで実行できますか?

できます。それこそが、テストごとにランダムなアドレスを使う理由です。2つのワーカーが互いのメールを読んでしまうことは決してありません。クライアントごとの上限は1分間に1200リクエストで、これは1秒に1回ポーリングするメールボックス20個分にあたります — スイートには十分すぎる量ですし、そもそもこのフィクスチャは1秒に1回より速くポーリングすることはありません。

fetchの代わりにPlaywrightのrequestフィクスチャを使うべきですか?

どちらでも構いません。ここでfetchを使っているのは、そうすればこのヘルパーが、素のNodeスクリプトやグローバルセットアップ、他のテストランナーでも、そのまま変更なしで動くからです。Playwrightのrequestは呼び出しのトレースを追加してくれるので、ポーリングをトレースビューアーに表示したいなら使う価値があります。

テストがポーリングを始める前にメールが届いていたらどうなりますか?

何も変わりません。最初のポーリングでそれが返ってきます。メールボックスは、誰かが読んでいるかどうかにかかわらず、届いたものを5日間保持します。そのため、クリックの最中に届いたメッセージも、次のリクエストの時点で単にそこにあるだけです。

テストがメールボックスを使っている間、それはプライベートですか?

いいえ。公開ドメインでも独自ドメインでも、アドレスを知っている人なら誰でも読めます。11秒しか存在せず、使い捨てのコードを1つだけ保持するランダムなアドレスにとって、これは無関係な話です。しかし、実際の顧客宛てメールを送るステージング環境にとっては、これは決定的な問題です — そちらをここに向けてはいけません。

テストの後、どうやってクリーンアップすればいいですか?

必要であれば、メッセージに対して冪等なDELETEを呼んでください。いずれにしてもすべては5日後に期限切れになるため、クリーンアップを省略した実行にもコストはかかりません — 削除しておくと、次に失敗したときに読みやすくなるというだけのことです。

新しいうちに試してみてください

アドレスの取得はワンクリックで、アカウントもカードも不要です。このガイドの内容はすべて、そのアドレスですぐに試せます。

おかえりなさい

受信箱とドメインを、ひとつの場所に。