開発者のためのメールテスト完全ガイド
実際のメールフローをエンドツーエンドでテスト — 確認コード、パスワードリセット、OTP、マジックリンク。使い捨てメールボックスと API だけで。
メールは、多くのテストスイートが結局あきらめてしまうステップです。そこに至るまではすべて自分の手の内にあります——フォームを埋め、ボタンを押し、リダイレクトをアサートする。ところがその先で、フローはアプリケーションを完全に離れ、自分の所有していないインフラを通り、テストからは見えない場所に着地します。そこでスイートは送信呼び出しをスタブ化し、アプリが「送ろうとした」ことをアサートして、カバー済みとみなします。
そうして抜け落ちるのは、実際に壊れる部分そのものです。メールは本当に出て行ったのか、テンプレートは正しくレンダリングされたのか、そして中に書かれたコードはバックエンドが受け付けるコードと一致しているのか。
本ガイドは、それをきちんとテストするための地図です。
まず、ツールの半分をふるい落とす区別
何かを選ぶ前に、自分が抱えている問題が二つのうちどちらなのかを見極めてください。この分野のツールはきれいに二分されますが、その違いは各社のマーケティングページからはまず読み取れません。
本物の受信用トレイは、インターネット上のどこからでもメールを受け取れるルーティング可能なアドレスを提供し、API で読むか webhook で受け取れます。自分のコードが送ったのではないメール——登録確認、パスワードリセット、サードパーティ発行の 2FA コード——をテストできるのは、この種類だけです。
SMTP キャッチャーは、アプリの向き先として設定する偽の SMTP サーバーです。捕まえるのは自分のアプリが送信したメールで、それを UI で確認できます。ローカルの開発ループや、自社の送信テンプレートに対するアサーションには非常に有用です。しかし外部からのメールは受け取れません。インターネット側から到達する経路が存在しないからです。
Mailpit、MailHog、MailCatcher、MailDev、そして Mailtrap の Email Testing サンドボックスは、すべて後者です。これらは「メールテストツール」の一覧で前者と並べて紹介されますが、答えている問いが違うという注記はまず添えられていません。片方をもう片方の用途に選んでしまうのはこの領域で最もよくある失敗で、たいていは統合作業の三日目に発覚します。
多くのチームは結局、両方を使うことになります。開発ループにはキャッチャーを、エンドツーエンドの CI には本物の受信トレイを。
パターン:テストごとにひとつの受信トレイ
受信側については、この営み全体がひとつのルールに集約されます。スイートごとではなく、テストごとに新しい受信トレイを払い出すこと。
スイート全体でひとつのメールボックスを共用するのは、取り除こうとしていた不安定さをそのまま呼び戻す行為です。二つのテストが並行実行された瞬間、片方がもう片方のメールを読み、並行時にしか再現しない失敗が生まれます。単体では通るのにスイートに入れると落ちるテストは、それがカバーしている機能よりデバッグコストが高くつきます。
構成要素は順に四つです。
- そのテスト実行に固有のアドレス
- 送信をトリガーするブラウザまたは API の操作
- 届いたメッセージの読み取り
- コードやリンクの抽出と、アサーション
ツール選定が効いてくるのは 1 と 3 だけで、残りはごく普通のテストコードです。
動く最小構成
エンドポイント二つで用は足ります。アドレスを作り、何か届くまでポーリングするだけです。
const API = 'https://moemail.app/api'
const H = { 'X-API-Key': process.env.MAIL_KEY, 'Content-Type': 'application/json' }
// expiryTime の単位はミリ秒。name を省略するとローカル部が
// ランダム生成されるので、並行テストが衝突しません。
export async function createInbox() {
const res = await fetch(`${API}/emails/generate`, {
method: 'POST',
headers: H,
body: JSON.stringify({ expiryTime: 3_600_000, domain: 'moemail.app' }),
})
if (!res.ok) throw new Error(`create inbox failed: ${res.status}`)
return res.json() // { id, email }
}
export async function waitForMessage(inboxId, timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const res = await fetch(`${API}/emails/${inboxId}`, { headers: H })
const { messages } = await res.json()
if (messages?.length) return messages[0]
await new Promise(r => setTimeout(r, 1500))
}
throw new Error(`no email arrived within ${timeoutMs}ms`)
}
返ってくるメッセージは { id, from_address, to_address, subject, content, html, received_at } という形で、content がプレーンテキスト本文、html が HTML パートです。
待機はリトライ回数ではなくデッドラインで区切ってください。固定回数にすると、誰かが間隔を調整した瞬間に、実質的なタイムアウト値が黙って変わってしまいます。
そして抽出そのものにもアサーションを置きます。
const code = msg.content.match(/\b\d{6}\b/)?.[0]
expect(code, `no 6-digit code in: ${msg.subject}`).toBeDefined()
正規表現が外れると match(...)[0] は Cannot read properties of null を投げ、スタックトレースは正規表現を指します。しかし本当の問題は、メールの中身が想定と違っていたことです。失敗メッセージに件名を含めておけば、十分の混乱がレポートの一瞥に変わります。
ポーリングか webhook か
ポーリングは単純で、テストが数本なら十分です。webhook が効いてくるのはレイテンシと無駄なリクエストを削れる点で、スイートが「1.5 秒間隔 × 100 テスト」が実時間として無視できない規模になったときに意味を持ちます。
どちらにせよ、タイムアウトは短く、しかし正直に設定してください。テスト環境のトランザクションメールは数秒で届くはずで、30 秒あれば余裕があります。90 秒必要なスイートがあるなら、それは配信の問題を修正せずに隠しているだけです。
HTML パートを読む
先回りして避けておきたい失敗がひとつあります。テンプレートが認証リンクを HTML 本文にしか置いていない場合、content を読んでも何も見つかりません。プレーンテキスト版が HTML と食い違っている例は想像以上に多く、しかもユーザーが実際にクリックするのは HTML の方です。content にリンクがなければ msg.html を読み、可能な限りユーザーが受け取る側をテストしてください。
ツールの選び方
有料サービスを検討しているなら、比べるべきは機能の数ではありません。本物の受信ができるか、無料枠で実際に何ができるか、そして受信トレイ単位の課金から逃れるために自分でホストできるか——この三点です。
これを詳しく扱った記事が二本あります。商用製品を買った方が妥当なケースもはっきり書いてあります。Mailosaur の代替は、上に述べた「受信 / キャッチャー」の境界でこの分野全体を整理したもの。MailSlurp の代替は、テストごとに受信トレイを作るのが正しい作法であるときに、受信トレイ課金が何を意味するのかを計算したものです。
MoeMail の位置づけ
MoeMail は本物の受信用トレイです。ルーティング可能なアドレス、キー認証付きの OpenAPI、そして webhook を備えています。オープンソースで、Cloudflare 上に自分でホストすればカスタムドメインが使え、外部クォータからも解放されます。
できないことも率直に書きます。SMS・電話 OTP のテストはできません。メールクライアント横断のレンダリングプレビューもありません。すぐ使えるフレームワーク統合も有料製品よりずっと少なく、HTTP 呼び出しは自分で書くことになります——それが上の二十行です。同じツールで SMS までカバーする必要があるなら、Mailosaur を買ってください。これは細かい粗ではなく、本当の欠落です。
プロフィールから API キーを取得し、OpenAPI ドキュメントを読むか、メールボックスを作成してデータの形を確かめてみてください。
以下の各ガイドは、このパターンを具体的なフレームワーク・フロー・言語・CI 環境に落とし込んだものです。