テスト・CI

Cypressのメールテスト:登録・OTP・パスワードリセット

Cypressはテストをブラウザの中で実行しますが、ブラウザはメールボックスを読み取れません。よくある解決策は、Node側でメールボックスをポーリングするタスクを用意することです — ここではそれを、APIキー不要の本物の使い捨て受信箱に対して実装し、さらに、サインアップのスペックをテスト対象の機能そのもののように読ませる2つのカスタムコマンドも用意します。

  • 中級
  • 読了19分
青いチェックの付いた3つの灰色の立方体が並ぶ台に、青い封筒を下ろす灰色のロボットアーム

なぜCypressにはこれ専用のタスクが必要なのか

Cypressのスペックは、テスト対象のページと同じウィンドウの中、つまりブラウザの内部で実行されます。それによってcy.getcy.containsがあれほど直接的になる一方で、スペックが単純にHTTP APIを1分間ループできない理由でもあります。コマンドキューは、sleepを挟んだwhileループを置く場所ではなく、リトライを重ねたcy.request呼び出しの連鎖は読みにくく、止めるのはさらに困難です。

フローのメール側をテストするためのよくある3つの方法は、それぞれ違うことを証明し、実際に出荷したものを証明できるのはそのうち1つだけです。

メーラーをスタブする
send()が正しい引数で呼び出されたことは証明できます。テンプレートやリンク、あるいはメッセージを拒否したプロバイダについては何も語りません。
ローカルのSMTPシンク(Mailpit、MailHog、smtp4dev)
整形の取れたメッセージがアプリケーションから送出されたことは証明できます。CIにコンテナが1つ増える一方で、公開インターネット上でしか起きないこと — 実際のMXルックアップ、実際のプロバイダ、実際の受信者 — は、ここでは何も起きません。
本物の使い捨てメールボックス
メッセージがアプリケーションを離れ、インターネットを渡り、本物のメールサーバーに受理され、機能するコードを運んでいることまで証明できます。コストは、テストが正しく待つ必要があるという点で、Cypressにおいて待機を行うべき正しい場所はタスクです。

タスクが呼び出すAPIは、キー不要のエンドポイント3つで、リファレンスも短いものです。テストランナーを問わないこの作法の一般的な内容は確認フローをエンドツーエンドでテストするに、タスクの代わりにフィクスチャを使うPlaywright版はPlaywrightガイドにあります。

タスク: Node側のポーリングループ

待機が必要な処理はすべて、ここ、setupNodeEventsの中にあります。中身はごく普通のNodeのコードです。fetch、期限、1秒に1回の読み取り、そして失敗ではなく待機で処理する429の分岐。スペック側は、その中身をいっさい目にしません。

cypress.config.ts
import { defineConfig } from 'cypress';

const API = 'https://grabmail.io/api/v1';
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

type Args = { address: string; subjectContains?: string; fromContains?: string; timeoutMs?: number };

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    taskTimeout: 90_000,                  // above the mail deadline below, always
    setupNodeEvents(on) {
      on('task', {
        /** Poll a mailbox until a matching message arrives, or the deadline passes. */
        async waitForMail({ address, subjectContains, fromContains, timeoutMs = 60_000 }: Args) {
          const deadline = Date.now() + timeoutMs;

          while (Date.now() < deadline) {
            const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(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 ${address}`);

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

            if (hit) {
              const full = await fetch(`${API}/message/${hit.id}?mailbox=${encodeURIComponent(address)}`);
              if (!full.ok) throw new Error(`GET /message answered ${full.status}`);
              return full.json();                      // the whole message, both parts
            }
            await sleep(1000);                         // one read a second, never throttled
          }
          return null;                                 // "not yet" is an answer, not an error
        },
      });
    },
  },
});

そのファイルの中には、述べておく価値のある決定が2つあります。タスクは要約ではなくメッセージ全体を返します。どのスペックでも次に欲しくなるのは本文であり、そのために2回目のタスク呼び出しを行うのはノイズだからです。そして、期限が来ると例外を投げるのではなくnullを返します。「まだメッセージがない」は、タスクが返してよい正当な答えであり、それを役に立つメッセージ付きの失敗に変えるのは、この下にあるコマンドの役目です。

2つのカスタムコマンドと2つの抽出関数

コマンドは意図的に薄く作ってあります。freshAddressはメールボックスを作り、waitForMailは期限より余裕を持って長いタイムアウトでタスクを呼び出し、その答えをアサーションします。抽出関数はただの関数です。ただの文字列処理であり、Cypressのコマンドにしてしまうと単体テストがかえって難しくなるだけだからです。

cypress/support/commands.ts
export type Message = {
  id: string; from: string; to: string; subject: string; date: string;
  text: string | null; html: string | null;
};
type WaitOpts = { subjectContains?: string; fromContains?: string; timeoutMs?: number };

declare global {
  namespace Cypress {
    interface Chainable {
      /** A mailbox nothing else in this run, or any previous run, is using. */
      freshAddress(prefix?: string): Chainable<string>;
      /** Block until a matching message arrives. Fails the test at the deadline. */
      waitForMail(address: string, opts?: WaitOpts): Chainable<Message>;
    }
  }
}

Cypress.Commands.add('freshAddress', (prefix = 'cy') =>
  cy.wrap(`${prefix}-${Math.random().toString(36).slice(2, 10)}@grabmail.io`, { log: false }));

Cypress.Commands.add('waitForMail', (address, opts = {}) =>
  cy.task<Message | null>('waitForMail', { address, ...opts }, { timeout: (opts.timeoutMs ?? 60_000) + 10_000 })
    .then(m => {
      expect(m, `a message for ${address}`).not.to.be.null;
      return cy.wrap(m as Message, { log: false });
    }));

/** The whole body, both parts, with the 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 hit = bodyOf(m).match(new RegExp(`https?://[^\\s"'<>]*${pathContains}[^\\s"'<>]*`));
  if (!hit) throw new Error(`no link containing "${pathContains}" in "${m.subject}"`);
  return hit[0];
}

コマンド自身のtimeoutに注目してください。これはタスクの期限に10秒を足したもので、タスクが必ず答えを返せるようにしています。これがないと、Cypressの既定のタスクタイムアウトである60秒が、60秒のメール期限と競争して数ミリ秒差で勝ってしまい、失敗の原因がタスクのせいにされてしまいます。

3つのスペック、エンドツーエンドで

タスクとコマンドさえ用意しておけば、各スペックは、それが検証している機能そのもののように読めます。待機処理とパース処理はどこか別の場所にあり、それこそがそこに置いてある理由のすべてです。

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

cypress/e2e/signup.cy.ts
import { codeFrom } from '../support/commands';

describe('sign-up', () => {
  it('confirms the address with the emailed code', () => {
    cy.freshAddress().then(address => {
      cy.visit('/signup');
      cy.get('input[name="email"]').type(address);
      cy.get('input[name="password"]').type('correct-horse-battery-staple');
      cy.contains('button', 'Create account').click();
      cy.contains('Check your inbox').should('be.visible');

      cy.waitForMail(address, { subjectContains: 'confirm' }).then(message => {
        cy.get('input[name="code"]').type(codeFrom(message));
        cy.contains('button', 'Confirm').click();
        cy.contains('h1', 'Welcome').should('be.visible');
      });
    });
  });
});

メールで送られる使い捨てコードを求めるログイン

まずユーザーが存在している必要があり、これはアプリケーション自体のテスト用の接点 — 内部エンドポイント、データベースフィクスチャ、CLIなど — の仕事です。ブラウザ経由ではなく、cy.requestでそこに到達します。

cypress/e2e/otp-login.cy.ts
import { codeFrom } from '../support/commands';

describe('login with an emailed one-time code', () => {
  it('asks for the code and accepts it', () => {
    cy.freshAddress().then(address => {
      // Your application's own test seam: an internal endpoint, a DB fixture, a CLI.
      cy.request('POST', '/internal/test/users', { email: address, password: 'hunter2hunter2', otpByEmail: true });

      cy.visit('/login');
      cy.get('input[name="email"]').type(address);
      cy.get('input[name="password"]').type('hunter2hunter2');
      cy.contains('button', 'Log in').click();
      cy.contains('Enter the code we emailed you').should('be.visible');

      cy.waitForMail(address, { subjectContains: 'code' }).then(message => {
        cy.get('input[name="otp"]').type(codeFrom(message, /code is\s*([0-9]{6})/i));
        cy.contains('button', 'Continue').click();
        cy.url().should('include', '/dashboard');
      });
    });
  });
});

パスワードの再設定、そして新しいパスワードでのログイン

再設定リンクがbaseUrlと同じオリジンを指している場合は、普通のcy.visitでそのままアクセスします。アプリケーションが再設定ページとして別のオリジン — 例えば認証用のサブドメイン — にユーザーを送る場合は、そのページ上のステップをcy.origin()で包んでください。リンクの抽出方法自体は変わりません。

cypress/e2e/password-reset.cy.ts
import { linkFrom } from '../support/commands';

describe('password reset', () => {
  it('changes the password through the emailed link', () => {
    cy.freshAddress().then(address => {
      cy.request('POST', '/internal/test/users', { email: address, password: 'old-password-1' });

      cy.visit('/forgot-password');
      cy.get('input[name="email"]').type(address);
      cy.contains('button', 'Send reset link').click();

      cy.waitForMail(address, { subjectContains: 'reset' }).then(message => {
        cy.visit(linkFrom(message, '/reset/'));      // same origin as baseUrl: a plain visit
        cy.get('input[name="password"]').type('new-password-2');
        cy.contains('button', 'Change password').click();
      });

      cy.visit('/login');
      cy.get('input[name="email"]').type(address);
      cy.get('input[name="password"]').type('new-password-2');
      cy.contains('button', 'Log in').click();
      cy.url().should('include', '/dashboard');
    });
  });
});

CIでも通るようにする

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

症状原因対処法
ローカルでは通るのに、CIでは失敗するランナーが公開インターネットに到達できないか、アウトバウンド通信がフィルタされています。Node側からHTTPS経由でgrabmail.ioを許可してください。それ以外は不要です — SMTPポートも、インバウンド通信も要りません。
メールについて何も語らずに「cy.task timed out」となるtaskTimeout(既定で60秒)が、メールの期限より短いのです。設定ファイルでtaskTimeoutを期限より長く設定し、コマンドがすでに計算している呼び出しごとのタイムアウトを渡してください。
1回目は失敗し、再試行すると通る送信側がメールをキューイングしており、その待ち時間より期限のほうが短いのです。他の何かをいじる前に、まず期限を延ばしてください。トランザクションメールなら60秒が妥当な上限です。
バースト状に429が出る複数のスペックが1つのアドレスをポーリングしているか、ランナーが1分に1200リクエストを超えています。スペックごとに1つのアドレスを使ってください。クライアント側の上限は、1秒に1回のポーリングで20個のメールボックス分です。
ビルドはグリーンなのに、機能は壊れている使い回されたアドレスが、古いメッセージを返しました。すべてのテスト本体でfreshAddressを使ってください。これが最も重要な対処法です。
1週間は動くが、その後まったく動かなくなるフィクスチャがメッセージIDをキャッシュしています。ここではすべてが5日後に削除されます。スペックは、実行のたびに自分自身でメールを発生させる必要があります。5日を超えて残るものは何もありません。

保存すべきシークレットはありません。公開ドメインはキーもアカウントもヘッダーも不要です。パイプラインがこれらのスペックを実行するのに認証情報を必要とするなら、どこかで誤解が生じています。アウトバウンド通信の問題を解決した上で、このようなスイートを実行するGitHub ActionsのワークフローはCIガイドにあります。

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

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

ドメインをキャッチオール受信箱にするがそのセットアップ方法で、1つのドメインで無制限のテストアカウントを持つが、それをテストスイートの中で実践した姿です。

完了とみなす前に

  • テスト本体の中で作る、スペックごとに異なるアドレスを。定数は使わない。
  • 待機は、実時間の期限を持つタスクの中で。期限が来たらnullを返し、undefinedは返さない。
  • taskTimeoutとコマンドのタイムアウトを、どちらもメールの期限より長く。
  • 429は、失敗させるのではなくRetry-Afterの分だけ待って処理する。
  • コードやリンクは、単なるパターンではなく自分自身の文言と照合する。
  • 件名や送信元でフィルタし、2通届いたときに正しいメッセージが選ばれるようにする。
  • メールがどれだけ速く届いたかはアサーションしない。届いたことだけを検証する。

作法としては、これがすべてです。テストランナーを問わない抽出ルールそのものは自動テストにおけるOTPコードにまとめています。

質問

タスクの代わりに、ループの中でcy.requestを使うこともできますか?

できます。cy.requestもNode側から実行されるためCORSの制約を受けず、一致するかどうか、あるいは期限に達するまで再リクエストを繰り返す再帰関数も動作します。ただ、whileループを持つタスクに比べると読みにくく、止めるのも難しくなります。そしてタスクを使えば、スペックからリトライのロジックを排除できます。

APIキーやCypressの環境変数は必要ですか?

いいえ。公開ドメインはキーもアカウントもヘッダーも不要なので、cypress.env.jsonやCIのシークレットに書き込むものは何もありません。使い捨てメールのブロックリストに載らない有料のドメインプールだけがBearerトークンを使い、それは別の製品です。

Cypressのテストリトライや並列実行でも動作しますか?

動作します。まさに、アドレスがテスト本体の中で作られるからです。すべてのリトライ、すべての並列マシンが、それぞれ専用のメールボックスを持ちます。クライアントあたりの上限である1分に1200リクエストは、1秒に1回のポーリングで20個のメールボックス分に相当し、Cypressの実行がそこに近づくことはまずありません。

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

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

スペックが使っている間、そのメールボックスは非公開ですか?

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

後片付けはどうすればいいですか?

任意で、タスクからメッセージに対してDELETEを行えます。これは冪等です。どのみちすべてが5日後に期限切れになるため、後片付けを省略した実行にもコストはかかりません — 削除しておくと、次の失敗を読みやすくなるだけです。

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

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

おかえりなさい

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