email-testing について
登録確認、パスワードリセット、マジックリンク、OTPなど、メール依存フローのエンドツーエンドテスト向けスキルです。実際のメール取得、受信箱のポーリング、内容検証を案内し、配信到達性の確認を機能テストから分離します。
主な機能
- 01テストメールの実取得
- 02固定待機なしの受信箱ポーリング
- 03OTPと許可されたリンクの抽出
- 04宛先による並列テストの分離
- 05件名、送信元、リンクの検証
- 06配信到達性チェックの分離
活用シーン
- 01受信から完了までの登録確認テスト
- 02パスワードリセットとマジックリンクの検証
- 03OTPとMFAのメールフロー確認
- 04SPF、DKIM、DMARCの個別チェック
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ petrkindlmann/ qa-skills --skill email-testingインストール後、Claude Code で/email-testingを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/deliverability.mdhosted-inboxes.mdmailpit-playwright.md詳細
クイックガイド
| 状況 | 移動先 |
|---|---|
| メール取得ツールを選ぶ | 下記の「メール受信箱取得ツールの選択ツリー」 |
| 固定時間待たずに受信箱をポーリングする | references/mailpit-playwright.md(ポーリングヘルパー) |
| 本文から OTP/リンクを取り出す | references/mailpit-playwright.md(抽出) |
| パスワードリセット/登録/マジックリンクの E2E 全体 | references/mailpit-playwright.md |
| ステージングで実在のアドレスを使う | references/hosted-inboxes.md(Mailosaur) |
| テストごとの使い捨て受信箱 | references/hosted-inboxes.md(MailSlurp / ShazaMail) |
| ローカルでテンプレートをプレビューするだけ | references/hosted-inboxes.md(Ethereal) |
| SPF/DKIM/DMARC の問題を検出する | references/deliverability.md |
| ローカルではテストが通るのに、CI では「まだメールが届いていない」 | 下記の「CI でのメールの不安定さ」 |
確認事項
まず .agents/qa-project-context.md を確認してください。ファイルが存在する場合は、その情報を使い、記載済みの項目はスキップしてください。
- メールはどこで受信する必要がありますか? ローカルでの取得(Mailpit)を使えば、ほとんどの機能フローを無料で確認できます。実際に外部へ配信できるアドレス(ステージング、サードパーティの ESP、実際の DNS)が必要なら、ホスト型受信箱(Mailosaur / MailSlurp)を使います。これはツール選択を左右する最大の要因です。
- スイートはどの程度並列実行されますか? 並列度が高いと「最新のメール」が曖昧になります。共有メールボックスではなく、テストごとに固有のアドレスか受信箱が必要です。
- どのフローを対象にしますか? 登録確認、パスワードリセット、マジックリンクログイン、OTP/MFA、通知メールなどは、いずれも「取得、抽出、完了」という共通の流れですが、抽出するもの(リンクか6桁のコードか)が異なります。
- 配信性も対象ですか? 「受信箱に届く/SPF、DKIM、DMARC に合格する」という確認は、機能テストの OTP 確認には含めず、ブロックしない別のスイートで行います。あらかじめ決めてください。
- ローカルでのプレビューだけですか。それとも CI でのアサーションも必要ですか? 開発中にテンプレートをプレビューする場合は Ethereal、CI でアサーションを行う場合は Mailpit/Mailosaur/MailSlurp/ShazaMail を使います。この2つを混同しないでください。
基本原則
-
実際のメールを取得し、近道をしないでください。 リセットエンドポイントを直接呼び出したり、トークンをハードコードしたりすると、テストが対象としている正確な連携(テンプレート処理、宛先の解決、リンクの生成、トークンの署名)を飛ばしてしまいます。UI から送信し、受信トレイを確認してください。
-
固定時間の待機ではなく、ポーリングしてください。 メールの到着は非同期です。固定の
waitForTimeoutは短すぎて(不安定なテストになる)か、長すぎて(無駄に時間がかかる)しまい、メールテストが不安定になる最大の原因です。expect.poll/.toPassを使って/api/v1/search?query=...を確認するか、すでにポーリングしてくれる組み込みの待機機能(messages.get、waitForLatestEmail)を使ってください。レスポンス本文の解析を含め、各 HTTP 呼び出しにはクライアント側でタイムアウトを設定してください。プロバイダーの待機時間だけでは、接続が停止した場合に対処できません。 -
テストごとに固有の宛先を1つ使用してください。 2つの並列テストがどちらも「最新のサインアップメール」を取得すると、互いのメールをつかんでしまいます。各テストのアドレスを固有にし(プラスアドレス方式またはテストごとの受信トレイを使用)、読み取る際は宛先で絞り込んでください。ShazaMail では、専用のテスト受信トレイ ID と
received_afterマッチャーを使って、テストごとに分離することもできます。テスト間で受信トレイをクリアしても、並列実行には対応できません。 -
実際のテンプレートを基準に抽出し、曖昧さを排除してください。 メール本文から、ラベルに特化した正規表現で OTP を抽出し、重複しないコードが1つであることを確認してください。HTML リンクは DOM パーサーで解析し、承認済みのオリジンと、そのフローのパスおよびトークンに完全一致することを確認してください。一致するものがない場合や曖昧な場合は失敗とし、推測した URL には絶対に移動しないでください。
-
存在ではなく、内容を検証してください。 「メールが届いた」というアサーションは不十分です。
subject、fromアドレス、リンクが正しいドメインを指していることを確認してください。誤ったテンプレートやリンクの不具合は、内容を検証しなければ発見できません。 -
配信性の検証は、機能テストを妨げない独立したテストスイートにしてください。 SPF/DKIM/DMARC は、実際の受信サーバーによる認証結果から判定するもので、本文のテキストから判定するものではありません。また、機能フローテストを妨げてはなりません。
受信箱の選択フローチャート
実際にメールを受信できるツールの中から、最も低コストなものを選びます。ローカル環境と CI の機能テストでは、まず Mailpit を使います。実在するメールアドレスや、高い並列性を確保した分離が必要な場合に限り、ホステッド型の受信箱を選びます。
| ツール | ホスティング/コスト | アドレスの種類 | 使用する場面 |
|---|---|---|---|
| Mailpit | セルフホスト、単一バイナリまたは docker、無料/オープンソース | 任意のローカル SMTP 受信者 | デフォルト。ローカル環境と GitHub Actions の機能テスト、予算が限られている場合。REST API は :8025(/api/v1/messages)にあります。 |
| Mailosaur | ホステッド型(API キー)、有料 | 実在する *.mailosaur.net アドレス | 実際に配信可能なアドレスが必要なステージング/本番に近いフロー向け。messages.get(serverId, { sentTo }) による自動待機、構造化されたリンク/コード、実際の SPF/DKIM/DMARC に対応します。 |
| MailSlurp | ホステッド型(API キー)、有料 | 実在する、受信箱ごとのアドレス | createInbox() と waitForLatestEmail によるテストごとの使い捨て受信箱。並列実行時の分離に優れています。 |
| ShazaMail | ホステッド型(Bearer API キー)、受信専用 | 実在する、テストごとの受信箱 | POST /api/v1/test-inboxes と、上限時間付きの POST /api/v1/test-inboxes/:id/wait による登録/パスワードリセット/OTP フロー向け。送信 SMTP には対応していません。 |
| Ethereal | ホステッド型の使い捨てサービス、無料 | メールを捕捉しますが、配信はしません | ローカル開発でのテンプレートのプレビューのみ(createTestAccount と getTestMessageUrl)。CI のアサーションには使用しないでください。 |
予算を抑えた登録フロー(ローカル環境と GitHub Actions、セルフホスト可能)の場合は、Mailpit を使用します。無料のオープンソース REST API(:8025)を備えた単一バイナリ/docker イメージです。後から実際に配信可能なアドレスが必要になった場合は、そのテストスイートを Mailosaur、MailSlurp、または ShazaMail(実在するアドレスを使えるホステッド型受信箱)に移行します。「メールの代わりにデータベースだけを確認する」のは避けてください。確認できるのは行が書き込まれたことだけで、メールが送信され、宛先が設定され、リンクを利用できることは確認できません。
MailHog は避けてください。2020 年以降、アーカイブされ、メンテナンスされていません。Mailpit はそのドロップイン置換であり(同じポート、互換性のある API)、2026 年半ばに確認済みです。また、新しいテストスイートでは smtp4dev と Papercut も避けてください。自動アサーションには Mailpit の API と全文検索のほうが適しています。
docker-compose、ポーリングヘルパー、抽出ユーティリティについては references/mailpit-playwright.md を、Mailosaur/MailSlurp/Ethereal については references/hosted-inboxes.md を参照してください。
受信箱のポーリング(Mailpit)
Playwright の request フィクスチャで検索エンドポイントを読み込み、このテストの宛先に送られたメッセージを探してから、ID を使って本文全体を取得します。timeout と intervals を指定した expect.poll を使います。一致するメッセージが現れるまで再試行するため、受信が速い場合はすぐに解決し、遅い場合でもテストを通過できます。
// `request` is the Playwright APIRequestContext fixture; plain fetch() works too.
await expect.poll(async () => {
const query = new URLSearchParams({ query: `to:${to}` });
const res = await request.get(`http://localhost:8025/api/v1/search?${query}`);
expect(res.ok(), `Mailpit search HTTP ${res.status()}`).toBe(true);
const { messages } = await res.json();
return messages.find((m) => m.To.some((t) => t.Address === to))?.ID ?? null;
}, { timeout: 30_000, intervals: [500, 1_000, 2_000] }).not.toBeNull();
// then: request.get(`http://localhost:8025/api/v1/message/${id}`) → { Text, HTML, Subject, From, To }
並列テストの結果を確定的にするのは、検索エンドポイントの query=to: フィルターと、受信者に対する .find です。受信者でフィルタリングせずに messages[0]/messages.at(-1)(全体で最も新しいメッセージ)を取得するのは避けてください。同じアカウントに2通目のメールを要求する場合は、すでに確認済みの ID を除外します。古い一致メッセージが新しい要求を満たしてはなりません。HTTP/スキーマエラーと詳細チェックも含むヘルパーの全体版は、references/mailpit-playwright.md を参照してください。
OTP とリンクの抽出
references/mailpit-playwright.md のガード付きヘルパーを、メール本文に対して使用してください。
const otp = extractOtp(message.Text, /\bcode:\s*(\d{6})\b/gi);
const resetUrl = await extractLink(page, message, new URL(baseURL).origin,
url => url.pathname === '/reset' && Boolean(url.searchParams.get('token')));
await page.goto(resetUrl);
インデックスによる切り出し(body.split(' ')[3]、substring(0, 6)、indexOf('code'))は行わないでください。テンプレートの文言が変わると、すぐに壊れてしまいます。メール本文を取得するつもりなのに、表示中のページの innerText を読み取るのも避けてください。6桁のコードがほかの数字と重複する可能性がある場合は、ラベルを基準にして、一致する異なる値が複数あれば拒否します。広範な \S* URL 正規表現は、閉じ HTML タグまで取り込んだり、クエリパラメーター内の & をそのまま残したりする場合があります。ホステッド型サービスでは message.html.links/message.html.codes が構造化データとして提供されるため、利用可能な場合はそちらを優先してください。references/mailpit-playwright.md を参照してください。
決定的なアドレス(並列実行の分離)
並列実行による不具合:2 つのテストが同時にサインアップし、どちらも「最新のサインアップメール」をポーリングするため、メッセージを取り違えます。優先度順の対策は次のとおりです。
- テストごとに一意のアドレスを使う — プラスアドレス/サブアドレスを使用します。
user+${randomUUID()}@example.testのように、リトライのたびに新しく作成します。タイムスタンプだけでは、ワーカー間で重複する可能性があります。ほとんどのプロバイダーはuser+anything@をuser@に配送するため、1 つの実メールボックスで無数の一意な宛先を利用できます。 - 読み取りのたびに宛先でフィルタリングする —
sentTo(Mailosaur)、またはquery=to:と.findの一致(Mailpit)を使います。常に最も新しいメッセージを取得するのは避けてください。 - テストごとに専用の受信トレイを用意する — MailSlurp の
createInbox()、または ShazaMail のPOST /api/v1/test-inboxesを使うと、各テスト専用の受信トレイが得られます。Mailosaur では、各テストにサーバードメイン上の一意のアドレスが割り当てられます。
テスト間で受信トレイをクリアしても、並列実行では不十分です。同時に実行される 2 つのテストは依然として衝突します。一意のアドレスと宛先フィルターを組み合わせるのが根本的な解決策です。references/hosted-inboxes.md を参照してください。
件名/差出人/ヘッダー/リンクの検証
メールを取得したら、内容を検証します。
expect(message.subject).toBe('Welcome to Example')— テンプレートの誤りを検出します。expect(message.from?.[0].email).toBe('[email protected]')— 差出人または返信先の設定ミスを検出します。- 製品側で設定している場合は、ヘッダー(
List-Unsubscribe、カスタムのX-ヘッダー)を検証します。 - リンクが正しいドメインを指していることを検証します。
遷移する前に、
expect(new URL(link.href).origin).toBe('https://staging.example.com')を実行します。 ホスト名のサフィックスを確認するだけではevil-staging.example.comも許可されます。完全一致するオリジン、または意図的に承認したサブドメインの境界を必須にしてください。
subject、from、リンクのドメインを検証する Mailosaur の例は references/hosted-inboxes.md にあります。
配信到達性:SPF/DKIM/DMARC
機能フローのテストとは分けて、ブロックしない専用スイートにしてください。SPF、
DKIM、DMARC の pass/fail とアライメントは、実際の受信サーバーが送信ドメインを認証した結果です。本文中の文字列ではありません。そのため、body.includes('spf') や body.match(/dkim/) は絶対に使わないでください。また、Mailpit は SPF/DKIM/DMARC のアライメントを検証しません。実際の DNS を介した送信がないため、基本的な SpamAssassin のコンテンツスコアを判定するだけです。実際のアライメントを確認するには、ホスト型の送受信環境が必要です(配信到達性レポートを利用できる Mailosaur、または単発の確認には mail-tester.com)。スイートに
@deliverability タグを付け、必須ではない CI ジョブ(continue-on-error)として実行してください。また、OTP/リセットフローのテスト内では配信到達性を決して検証しないでください。references/deliverability.md を参照してください。
CI でメールが不安定になる場合
ローカルではテストが通るのに、CI で検証するとメールが「まだ届いていない」状態になります。根本原因は次の 4 つです。単にスリープ時間を延ばすのではなく、すべてを診断してください。
- ポーリングせず、固定時間スリープしている。
waitForTimeoutや任意の遅延では、メールとの競合が起きます。expect.poll/.toPass/組み込みのwaitForに置き換えてください。 - タイムアウトが短すぎる。 CI でのメール配信はローカルより遅くなります。ポーリングを使うようにしたうえで、やみくもなスリープを長くするのではなく、ポーリングの
timeoutを延長します(例:30~60 秒)。 - 宛先でフィルタリングしていない。 並列実行中に最も新しいメッセージを全体から取得すると、別のテストのメールを拾います。
sentTo/to:でフィルタリングし、テストごとに一意のアドレスを使ってください。 - 前回の実行時の古いメッセージが残っている。 新しいメールが届く前に、古い一致メールがアサーションを満たしてしまいます。グローバルセットアップで受信トレイをクリアするか、前回の実行時のメールが一致しないよう、実行ごとに一意のアドレスを使ってください。
ジョブ全体の再試行、テストの隔離、またはグローバルなスリープを 30 秒に延ばす方法では、症状に対処するだけで、競合は解消されません。
アンチパターン
1. 受信トレイを読む前の固定スリープ
page.waitForTimeout(5000)/setTimeout/sleep() の後に読み取る方法です。短すぎると不安定になり、長すぎると数分を無駄にします。/api/v1/messages に対して expect.poll でポーリングするか、組み込みの待機機能を使ってください。
2. 使われなくなったメール取得ツールを推奨する
MailHog はアーカイブ済みです(2020 年)。Papercut/smtp4dev は自動化には力不足です。Mailpit を使ってください。
3. メールではなくデータベースを確認する
DB の行から分かるのは書き込みが行われたことだけで、メールが送信され、正しい宛先に届き、リンクを取得できることまでは確認できません。 実際にキャプチャしたメッセージを読み取ってください。
4. メールを経由せずにショートカットする
リセットエンドポイントを直接呼び出したり、トークンをハードコードしたりすると、テンプレート、リンクの生成、トークンの署名を検証できません。メールをキャプチャしてリンクを抽出し、page.goto で開いてください。
5. インデックスに依存した脆弱な抽出
body.split(' ')[3)、substring(0, 6)、indexOf('code') は使わないでください。アンカー付きの \d{6} 正規表現と null ガードを使用してください。また、ライブページの innerText ではなく、メール本文から抽出してください。
6. 宛先を絞り込まず、全体で最新のメッセージを取得する
messages[0] / messages.at(-1) は並列実行時に競合します。宛先で絞り込み、テストごとに一意のアドレスを使用してください。「テスト間ですべてのメッセージを削除する」だけでは解決しません。
7. メールが存在することだけをアサートする
subject / from / リンクを確認しないと、テンプレートやリンクの誤りを見逃します。コンテンツをアサートしてください。
8. 本文から SPF/DKIM/DMARC を正規表現で抽出する、または Mailpit を信頼する
認証結果は実際の受信サーバーから得られるもので、本文のテキストからは得られません。Mailpit が行うのはスパムスコアの判定だけです。Mailosaur の配信レポートを、別の非ブロッキングスイートで使用してください。
9. 実際のメールボックスに IMAP ライブラリを使う
imap-simple / node-imap / imapflow はポーリングを独自に実装することになり、分離性も損なわれます。テストごとに 1 つの受信トレイを使用し、MailSlurp の createInbox + waitForLatestEmail を使ってください。
10. CI で Ethereal を使う、またはローカルプレビューに有料サービスを使う
Ethereal はメールを配信しません。プレビュー専用です(createTestAccount + getTestMessageUrl)。CI で Ethereal をアサート対象にしないでください。同様に、ローカルでテンプレートを目視確認するだけのために、有料のホステッドサービスを起動する必要もありません。
検証
スイートが実際にメールをキャプチャしてアサートすることを、最小の確認から順に証明してください。
- キャプチャが接続されていること:
curl -s localhost:8025/api/v1/messages | jq '.total'が数値を返す(Mailpit が起動していて、API に到達可能)。ホステッド受信トレイの場合は、1 行のmessages.get/createInboxスモークスクリプトが認証エラーなしで終了すること。 - 盲目的なスリープがないこと: メールのテスト仕様を対象に
grep -rE 'waitForTimeout|sleep\(|setTimeout' tests/を実行し、何も出力されないこと。 - フローが成功し、実際のメールを使っていること: サインアップ/リセットの仕様を実行し、テンプレートの件名を一時的に壊すと失敗することを確認してください。それでも成功するなら、コンテンツをアサートしていません。
- 決定性が保たれること: メールのテスト仕様を
--workers=4 --repeat-each=3で実行してください。成功すれば、宛先ごとのフィルターが並列実行でも機能していることを証明できます。 - 配信可能性のテストが分離されていること:
--grep @deliverabilityで認証スイートだけが選択され、その CI ジョブがcontinue-on-errorに設定されている、または必須ではないこと。
完了条件
- キャプチャツールが判断ツリーに基づいて選ばれ、
.agents/qa-project-context.mdに記録されていること(ローカル/CI では Mailpit、実際のアドレスが必要な場合に限りホステッド受信トレイを使用)。 - 受信トレイを読み取る前に
waitForTimeout/sleep/setTimeoutを使うメールのテストがないこと。メールのテスト仕様を対象にしたgrep -rE 'waitForTimeout|sleep\(|setTimeout' tests/が何も出力せず、到着をexpect.poll/.toPass/ 組み込みの待機機能で待つこと。 - メールの各テストで一意の宛先(プラスアドレスまたはテストごとの受信トレイ)を使用し、その宛先で読み取りを絞り込んでいること。フィルターなしで
messages[0]/messages.at(-1)を使わないこと。 - OTP の抽出でテンプレートのラベルを使用し、異なる候補コードが複数ある場合は拒否すること。リンクの抽出では HTML アンカーを解析し、承認済みの正確なオリジン、パス、トークンに一致するものが 1 つだけであることを必須とすること。結果がない場合や曖昧な場合は、ナビゲーション前に失敗すること。
- サインアップ確認(またはリセット/マジックリンク)フローの少なくとも 1 つに、実際のメールをキャプチャし、
page.goto(link)でフローを完了する成功した E2E テストがあること。 subject、from、リンクのドメインを確認するコンテンツアサーションがあること。存在確認だけにしないこと。- 対象範囲に含める場合、配信可能性(SPF/DKIM/DMARC)のテストは、CI でブロッキングにならない独立した
@deliverabilityスイートに置くこと。
関連スキル
- playwright-automation — あらゆるメールフローにおけるブラウザー操作の部分です。フォーム、ナビゲーション、フィクスチャ、ポーリング用ヘルパーを扱います。このスキルでは受信トレイ側を扱います。
- api-testing — メールプロバイダーの API を直接テストしたり、アプリがメールを「送信する」ことをテストしたりする場合は、こちらを参照してください。このスキルでは、E2E フローでメールを「受信して検証する」方法を扱います。
- test-data-management — ここでの宛先戦略に使用する、テストごとに一意のアドレス、ファクトリー、シード済みユーザーの生成を扱います。
- qa-project-context — 使用するキャプチャツール、SMTP の送信先、認証情報を記録し、すべてのメールテストで同じ設定を共有できるようにします。
参照ファイル(references/ 内)
- mailpit-playwright.md — docker-compose、
expect.pollを使った Mailpit ヘルパー、OTP/リンク抽出ユーティリティ、パスワードリセット/サインアップ/OTP/マジックリンクの完全な E2E テストを扱います。 - hosted-inboxes.md — Mailosaur(
messages.getによる自動待機、実在するアドレス、構造化されたリンク/コード)、MailSlurp(createInboxとwaitForLatestEmail、テストごとの受信トレイ)、ShazaMail(受信トレイをテストごとに作成し、作成/待機にクライアント側の期限を設定)、Ethereal(createTestAccountとgetTestMessageUrlを使ったローカルプレビュー)を扱います。 - deliverability.md — ブロックされない独立したスイートとしての SPF/DKIM/DMARC、本文の正規表現や Mailpit では認証を検証できない理由、Mailosaur による到達性の検証を扱います。