✉️

結果のフィードバックがメールのテストを非同期で検証する仕組みを作ってみた

に公開

こんにちは。ダイの大冒険エンジョイ勢のbun913と申します。

みなさんは、「この処理の結果がメールで届くんだよなぁ。このメールを起点に非同期であるべき結果を確認できるテストを書きたいなぁ」と思ったことはありませんか?私はあります。

私はSDET(Software Development Engineer in Test)という職種で働いており、テスト自動化コードの実行だけでなく「こういうテストをできる仕組みが欲しい〜!」を叶えるのも私の仕事だと思っています。

今回はシステムからフィードバックとして届くメールをSlackに転送して、Slack AppをAWS Lambdaでイベント駆動で結果を検証するピタゴラスイッチを作って遊んでみました。とても楽しかったです。

design

インフラの構築はTerraformで行い、Lambdaの処理はTypeScriptで実装しています。詳しくコードを見たい方は、以下をご参照ください。

https://github.com/bun913/slack-mail-event-handler-lambda-app?tab=readme-ov-file

先にデモ

まず私のメールアドレスに以下のようなテスト対象のシステムからの知らせを模したメールが届きます。

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ■ お支払い完了詳細
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  支払ID: 12345
  サービス名: bun913 プレミアムプラン
  金額: 2,980円(税込)
  決済方法: クレジットカード
  処理日時: 2025年9月28日 14:47:08
  ステータス: 支払いが完了しました

  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  ご利用期間: 2025年9月28日 〜 2025年10月28日

  bun913株式会社

それらのメールがある程度フィルタリングされた状態で以下のようにSlackに転送されます。

こちらに対して、メールで通知された結果が期待する結果と同じであれば単にそのメールに :+1: スタンプをつけるのみで終了します。また、もしテスト管理用のシステムを利用していれば、そこにOKの結果を送信すると良いと思います。

slack1

一方、メールで通知された結果が期待する結果と異なれば、メッセージの中で特定のユーザーやグループをメンションして異変を知らせます。

slack2

結局やっていることは、以下のような単純なものですが、保存するデータやLambdaの処理を変更すれば同じようなメールやSlack起点のイベントを扱って非同期のイベントを処理できます。

LambdaやDynamoDBを利用して極力運用のコストや日々のランニングコストを抑えた構成にしています。

  • PlaywrightやPostmanを使って対象のシステムに対してなんらかの操作をする
  • その際、同時に「このイベントはこのようにフィードバックされることを期待する」というデータをDynamoDBに格納しておく
  • システムからのフィードバックとして届くメールをSlackに転送する
  • Slack Appを構成して、API Gateway + Lambdaでそのメールの内容を確認
  • DynamoDBに登録してある期待値と違えば、Slackで管理者宛てに通知する
    • 成功すればリアクションのスタンプだけつけておく
    • ※ このあたりの処理は後でいくらでも変更可能です。TestRailなどのテストマネジメントシステムを利用していれば、そこに結果を格納するのも良いでしょう

個々の実装ステップ

メールをSlackチャンネルに転送する

今回はGmailに届くメールをシンプルにSlackに転送しています。

こちらの機能はSlackの有料プランでのみ利用できます。あらかじめご了承ください。

以下のSlackの「チャンネルまたはDB用メールアドレスを作成する」のセクションを参考に特定のSlackチャンネルに転送できるメールアドレスを取得します。

https://slack.com/intl/ja-jp/help/articles/206819278-Slack-にメールを送信する

slackMail

次に以下のGmailサポートサイトにある記事に記載の通り、自分のメールアドレスに届くメールを先ほどのSlackチャンネルに送信できるアドレスに対して設定します。

https://support.google.com/mail/answer/10957?hl=ja

この時、「特定の件名を含む」や「このメールアドレスから来た」などのフィルターを設定できるため、意図しない個人情報流出とならないように留意しましょう。

API GatewayやLambdaを作成する

以下のリポジトリに全体のTerraformコードを配置していますので、詳細は以下をご覧ください。

https://github.com/bun913/slack-mail-event-handler-lambda-app?tab=readme-ov-file

工夫した点や留意が必要なポイントのみ説明します。

Systems Manager Parameter Store

後のLambdaでも紹介しますが、今回Slack Appとして動作させるにあたって、Slack App固有の機密情報を扱う必要があります。

以下のようにParameter Storeのリソースを2つTerraformで作成します。

https://github.com/bun913/slack-mail-event-handler-lambda-app/blob/c2235f2cdd4bb795b79ba8742d18c8857aa15599/parameter_store.tf#L1-L23

ここで実際の値は "Dummy" として機密情報をTerraformで管理しないで良い構成にしています。

他のリソースと一緒に terraform apply を実行した後に、手動で実際のSlackのTokenやSignin Secretの値を格納するようにしましょう。

Lambda

Slackに転送されたメールのメッセージを受け取って、DynamoDBに保存された期待値との比較などを行うためのLambdaを作成するコードです。

https://github.com/bun913/slack-mail-event-handler-lambda-app/blob/c2235f2cdd4bb795b79ba8742d18c8857aa15599/lambda.tf#L1-L39

今回はTypeScriptをesbuildであらかじめバンドルしてビルドしたコードをTerraformでzipとして固めて、Lambdaにアップロードする構成をとっています。

よって、手動で cd lambda/mailTriggered して npm run build を実行しておく必要があります。

また、以下のように環境変数としてDynamoDBのテーブル名などの情報を格納しています。

https://github.com/bun913/slack-mail-event-handler-lambda-app/blob/c2235f2cdd4bb795b79ba8742d18c8857aa15599/lambda.tf#L23-L30

SLACK_BOT_TOKEN_PARAM_PATHSLACK_SIGNIN_SECRET_PATH は実際に機密情報を格納しているのではなく、先ほど作成したSystems Manager Parameter Storeのリソースのパス(名前)が格納されています。

以下のlayersという箇所で、Lambda Extensionsという機能を利用してParameter Storeのデータを取得するようにしています。

https://github.com/bun913/slack-mail-event-handler-lambda-app/blob/c2235f2cdd4bb795b79ba8742d18c8857aa15599/lambda.tf#L17-L21

これにより、Lambdaにアクセスできるユーザーにも教えたくない機密情報をセキュアに扱いつつ、Parameter Storeへのアクセスもキャッシュしてくれるためコストの削減ができます。

https://dev.classmethod.jp/articles/lambda-get-paramater/

Lambdaの方では以下のようなExtensionsを活用して、いい感じにParameter Storeから秘密情報を取得する関数を準備しています。

import axios from "axios"

const AWS_SESSION_TOKEN = process.env.AWS_SESSION_TOKEN || ""

export const getParameter = async (path: string): Promise<string> => {
  const res =  await axios.get(
    "http://localhost:2773/systemsmanager/parameters/get",
    {
      params: {
        name: encodeURIComponent(path),
        withDecryption: true
      },
      headers: {
        "X-Aws-Parameters-Secrets-Token": AWS_SESSION_TOKEN,
      },
    }
  )
  return res.data.Parameter.Value
}

Lambdaに対して、DynamoDBやCloudWatch Logsなどへのアクセス権限を忘れないようにIAM Roleにセットしてあげます。

https://github.com/bun913/slack-mail-event-handler-lambda-app/blob/c2235f2cdd4bb795b79ba8742d18c8857aa15599/iam.tf#L1-L89

API Gateway

こちらは特に工夫している点はないのですが、API GatewayからLambdaを実行できるようにリソースベースのポリシーを設定しています。

https://github.com/bun913/slack-mail-event-handler-lambda-app/blob/c2235f2cdd4bb795b79ba8742d18c8857aa15599/apigateway.tf#L1-L34

DynamoDB

DynamoDBではオンデマンドモードという読み込み・書き込みのキャパシティのサイズをあまり気にしなくて良いものがあるのですが、今回はテスト用のアプリケーションなので最小限のキャパシティを設定しています。

https://github.com/bun913/slack-mail-event-handler-lambda-app/blob/c2235f2cdd4bb795b79ba8742d18c8857aa15599/dynamodb.tf#L1-L19

また地味ですが、レコードの消し忘れにより料金がかさむのは嫌なのでTTL設定をしています。ただし、これらは期限が切れたら即座にデータが削除されるものではないので留意しましょう。

https://dev.classmethod.jp/articles/try-dynamodb-ttl/

なお、DynamoDBには実際には以下のようなデータが格納されることを想定しています。

{
    "Item": {
        // TTL用のデータ。これが過ぎたレコードはどんどん消してもらいます
        "expirationDate": {
            "N": "1761629631"
        },
        // 一意のIDをuuidなどを利用してセットします
        "eventId": {
            "S": "12345"
        },
        // eventごとに異なるメールが届く場合などに利用する想定です
        "eventType": {
            "S": "payment"
        },
        // その他イベントに応じて必要なデータがあればここにJSON形式で格納できるようにします
        "params": {
            "S": "{}"
        },
        // 期待する結果ですね
        "expectedResult": {
            "S": "success"
        }
    }
}

Slack Appの設定

今回Slack Appを利用するにあたって、Slack側で設定をする必要があります。

が、以下の記事の手順が非常にわかりやすいので詳細は割愛させていただきます。

https://dev.classmethod.jp/articles/amazon-bedrock-slack-chat-bot-part2/

詳しくは、「手順(1) Slack側の設定」と「手順(5) Slack Appの再インストール」および「手順(6) ワークスペースへアプリを追加」をご参照ください。

ただし、今回の場合 Bot Token Scopes には chat:write だけでなく以下のような項目も設定して、メッセージに対するリアクションなどができるようにしています。

slackScope

また Event Subscriptions には以下のようなURLを設定してください。 https://<ID>.execute-api.ap-northeast-1.amazonaws.com/test/slack/mailArrived

Lambdaのソースコード

現時点で以下のようなコードを組んでSlack Boltを活用してSlackからのアクセスを受け付けています。

import { Context, Callback, APIGatewayProxyEvent, APIGatewayProxyResultV2 } from "aws-lambda"
import { App, AwsLambdaReceiver } from "@slack/bolt"
import { getParameter } from "./getSecret";
import { isEmailForwardingMessage, extractEmailContent } from "./eventHandler";
import { isPaymentEmail, getPaymentResult, extractPaymentId } from "./event/payment";
import { getPaymentEvent, deletePaymentEvent } from "./database/dynamodb";

let awsLambdaHandler: AwsLambdaReceiver
let app: App;

const initializeApp = async () => {
  const slackToken = await getParameter(process.env.SLACK_BOT_TOKEN_PARAM_PATH || "")
  const slackSigningSecret = await getParameter(process.env.SLACK_SIGNIN_SECRET_PATH || "")

  awsLambdaHandler = new AwsLambdaReceiver({
    signingSecret: slackSigningSecret
  })

  app = new App({
    token: slackToken,
    receiver: awsLambdaHandler
  });

  // Handle all message events to detect email forwarding
  app.event('message', async ({ event, logger, say, client }) => {
    // Check if this is an email forwarding message
    if (isEmailForwardingMessage(event)) {
      logger.info('Email forwarding detected')

      const emailContent = extractEmailContent(event)
      if (!emailContent) {
        return
      }

      // Check if this is a payment email
      if (!isPaymentEmail(emailContent)) {
        return
      }

      const paymentId = extractPaymentId(emailContent)
      if (!paymentId) {
        return
      }

      const paymentResult = getPaymentResult(emailContent)
      if (!paymentResult) {
        return
      }

      logger.info(`Payment email detected - ID: ${paymentId}, Result: ${paymentResult}`)

      // Look up the payment event in DynamoDB
      const paymentEvent = await getPaymentEvent(paymentId)

      if (!paymentEvent) {
        // No expected result found in DB
        await say({
          text: 'この支払いに対する期待値がDBから見つかりませんでした',
          channel: event.channel,
          thread_ts: event.ts
        })
        return
      }

      // Check if the result matches expectations
      if (paymentEvent.expected_result === paymentResult) {
        // Success - add thumbs up reaction and delete record
        await client.reactions.add({
          channel: event.channel,
          timestamp: event.ts,
          name: '+1'
        })

        // Delete the payment event from DynamoDB since test passed
        const deleted = await deletePaymentEvent(paymentId)
        if (deleted) {
          logger.info(`Successfully deleted payment event: ${paymentId}`)
        } else {
          logger.error(`Failed to delete payment event: ${paymentId}`)
        }
      } else {
        // Mismatch - send thread message with mention
        await say({
          text: `期待する結果と決済結果が異なります`,
          channel: event.channel,
          thread_ts: event.ts,
        })
      }
    }
  })
}

export const handler = async (
  event: APIGatewayProxyEvent,
  context: Context,
  callback: Callback
): Promise<APIGatewayProxyResultV2> => {
  try {
    if (!app) {
      await initializeApp()
    }

    const result = await awsLambdaHandler.start()
    return await result(event, context, callback)
  } catch (error) {
    console.error('Error in Lambda handler:', error)
    return {
      statusCode: 500,
      body: JSON.stringify({ error: 'Internal server error' })
    }
  }
}

上には可読性のためメインの処理部分のみを記載していますが、その他DynamoDBの操作や各種イベントの処理(メールの内容に応じた処理の分岐)は以下に配置しています。

https://github.com/bun913/slack-mail-event-handler-lambda-app/tree/main/lambda/mailTriggered

まとめ

  • ある操作に対するフィードバックがメールやSlackであるシステムに対して、非同期でテストを行える基盤を作ってみました

design

  • 実際はメールなどを起点とせずシステムのDBの値を見ることも多いと思います
  • 実際にメールやSlackなどの通知に対してテストを行えることで、よりE2Eに近い挙動を再現できるのはメリットです

以上、最後までご覧いただきありがとうございました。

GitHubで編集を提案
Money Forward Developers

Discussion