Playwrightでメール配信のテスト自動化にチャレンジ!

こんにちは、JX通信社でシニアエンジニアをしているSirosuzumeです。

JX通信社の「FASTALERT」には、ユーザーが事前に設定した地域で発生した災害情報を、メールで受信する機能があります。

しかしテストする手順も複雑で、配信条件も多様化していったこともあって、手動でのテストを行うことに限界を感じていました。

設定画面の挙動確認など、ブラウザ上で完結するテストであればPlaywrightを使って自動化することもできていたのですが、実際にメールを受信するところのテストを自動化する方法についてのノウハウ不足が課題でした。

そこで、Amazon SESの機能を改めて確認していたところ、特定のメールアドレスで受信したメールをS3に保存する機能があることを知り、E2Eテスト内からS3にアクセスすることでメールの受信テストまで自動化でカバーできるのではないか、と考えたことが、今回のチャレンジのきっかけです。

この記事ではAmazonSES + S3を経由して、Playwright上からメール受信テストを行なう方法を解説します。

概要

今回作成したPlaywrightのテストは、以下のシーケンス図のような流れで動作させました。

E2Eテストシーケンス図

Amazon SESでを使用して、メールを受信するための設定方法は公式ドキュメントを参照してください。

以降のドキュメントはルール設定時にアクションの追加で、Amazon S3バケットにメールを保存するようにしていることが前提になります。

S3経由でメールの受信を検知する方法(Node環境の場合)

S3にアクセスする必要があるため、AWS SDKを使用します。

S3に保存されたメールを取得するだけならば、ListObjectsV2Commandを使ってファイル一覧を取得し、GetObjectCommandを使ってファイルの内容を取得し、解析することで可能です。

特定のアクションをトリガーとして新しいメールが来るのを検知したい場合は、ポーリングを行って新しいファイルが追加されるのを検知する必要があります。

また、メール配信のトリガーとなるアクション自体も時間がかかる場合があるため、以下のような順番で新しいメールが来るのを待つ処理を実装してみました。

  1. 受信前のリストを取得する
  2. メール配信のトリガーとなるアクションを実行する
  3. ポーリングを一定時間行ない、受信前のリストと比較して、新しいファイルが追加されたらそのファイルのオブジェクトキーを返却する
  4. オブジェクトキーを使ってファイル(メール)をダウンロードする

以下はポーリングを行なう関数、S3からメールを取得する関数の実装例です。

import { setInterval } from "node:timers/promises";
import {
  DeleteObjectsCommand,
  GetObjectCommand,
  ListObjectsV2Command,
  S3Client,
} from "@aws-sdk/client-s3";

const client = new S3Client({
  region: process.env.E2E_MAIL_S3_BUCKET_REGION,
});

export async function readNewEmailFromS3(): Promise<string[]> {
  // WARN: S3に1000件以上のメールが溜まっている場合、1000件を超えるメールが来た場合に対応できない
  const command = new ListObjectsV2Command({
    Bucket: process.env.E2E_MAIL_S3_BUCKET_NAME,
  });
  const response = await client.send(command);
  const fileNames =
    response.Contents?.map((content) => content.Key ?? "") ?? [];
  return fileNames;
}

/**
 * 一定時間メールが来なかった場合に発生するエラー。逆にメールが来ないことを検知する際は、このエラーが発生することを期待する
 */
export class WatchTimeoutError extends Error {
  constructor() {
    super("Timeout");
  }
}

export async function watchNewEmailsUntilTimeout(
  actionPromise: Promise<void>,
  timeout: number,
  interval = 500,
): Promise<string[]> {
  const lastEmails = await readNewEmailFromS3();
  await actionPromise;
  // Promise完了からtimeoutまでの間、新しいメールが来るのを待つ
  for await (const startTime of setInterval(interval, Date.now())) {
    if (Date.now() - startTime > timeout) {
      break;
    }
    const emails = await readNewEmailFromS3();
    if (emails.length > lastEmails.length) {
      return emails.filter((email) => !lastEmails?.includes(email));
    }
  }
  throw new WatchTimeoutError();
}
/**
 * オブジェクトキーを指定してメールを文字列の形式で取得する
 */
export async function fetchEmailByKey(key: string): Promise<string> {
  const command = new GetObjectCommand({
    Bucket: process.env.E2E_MAIL_S3_BUCKET_NAME ?? "",
    Key: key,
  });
  const response = await client.send(command);
  if (!response.Body) {
    throw new Error("No body");
  }
  return response.Body.transformToString();
}

メールの内容を取得し、解析できる形式に変換する

S3に保存されたEmailはBase64でエンコードされたRawデータが保存されているため、一度デコードしてメールの内容を取得する必要がありました。

Node環境でメールをデコードする場合、mailparserを使うと、メールの内容を解析してオブジェクトとして取得できます。

以下はmailparserを使って、1件のメール受信を待つ関数の実装例です。

import { ParsedMail, simpleParser } from "mailparser";

export async function waitNewMail(
  actionPromise: Promise<any>,
  timeout: number,
): Promise<ParsedMail> {
  const newEmailKeys = await watchNewEmailsUntilTimeout(actionPromise, timeout);
  if (!newEmailKeys.length) {
    throw new Error("No new email");
  }
  if (newEmailKeys.length > 1) {
    throw new Error("Too many new emails");
  }
  const newEmail = await fetchEmailByKey(newEmailKeys[0] as string);
  const parsedEmail = await simpleParser(newEmail);
  return parsedEmail;
}

テスト例

上記の関数を使って、メールが来ることを検知するテストと、メールが来ないことを検知するテストを実装してみました。

メールが来ることをテストする場合は、一定時間内にメールが来ることを検知する関数を使って、メールの内容を検証します。 メールが来ないことをテストする場合は、逆に一定時間内にメールが送信されず、WatchTimeoutErrorが発生することを期待します。

import { expect, test } from "@playwright/test";

test.beforeEach(async ({ page }) => {
  // メールの受信設定を行なう
  await page.goto("https://example.com/mail-setting");
  // SESで設定したメールアドレスを入力する
  await page.getByRole("textbox", { name: "メールアドレス" }).type(process.env.E2E_MAIL_ADDRESS);
  await page.click("button", { text: "設定" });
});
test("メールが10秒以内に来ることを検知する", async ({ page }) => {
  // メールが来ることを検知する
  const mail = await waitNewMail(actionToTriggerMail(), 10000);
  await expect(mail.subject).toBe("メールの件名");
  // page.setContentを使うと、HTMLメールをブラウザ上で表示しているかのようにテストできる。ただしメーラーによる差分までは検知できない
  await page.setContent(mail.html);
  await expect(page.getByText("メールの内容")).toBeTruthy();
});
test("メールが来ないことを検知する", async () => {
  // メールが来ないことを検知する
  await expect(
    waitNewMail(actionToTriggerMailButNoMail(), 10000),
  ).rejects.toThrowError(WatchTimeoutError);
});

HTMLメールのメーラーごとの差分を検知するのはPlaywrightでは困難ですが、コーディング時にReact Emailを使用するとHTMLメールの開発を楽にすることができます。

詳しくは、実践 React Emailを使ったHTMLメールの開発・運用という記事を書いたので、もしよろしければご参考にしてください。

まとめ

メールの受信テストを自動化したことで、新機能開発、リファクタリング時のリグレッションテストが大幅に効率化することができました。 手動では1時間以上かかってしまうテストが数分で完了し、リグレッションテストも毎回網羅的に行えるようになりました。

テスト用のコードそのものが複雑になりがちではありますが、それを補ってあまりある効果があったと感じています。

今後、FASTALERTではメールによる各種情報の配信を増やしていく予定のため、より多くのテストを自動化し、品質を担保していきたいと考えています。