0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

dry-runの無いメール送信バッチを、実送信のままE2Eにする - 届き先・対象・頻度を絞って安全を確保した

0
Posted at

TL;DR

  • 利用企業ごとに条件を判定し、担当者へメールを送る通知バッチを、E2E で自動テストにしました。送信を止める仕組み(dry-run)は、バッチにありません
  • 送信を止めるのは諦め、実際に送ったままテストします。安全は「どこに届くか」「どの企業を対象にするか」「どの実行で送るか」の 3 つを絞って確保しました
  • 安全弁は三重です。対象企業を E2E 用に限定しないと起動しない。宛先のアドレスを起動前に DB で検査する。IAM で縛れない部分は、タスクで動くコードを限ることで抑える
  • 検証環境で毎日決まった時刻に動く定期の起動と、E2E の起動がぶつからないよう、テストの対象を 30 日後の未来の日付に置き、判定時刻を固定して起動します
  • 結果、手動テストの 15 件のうち 10 件を「E2E で確認済み」にでき、毎回手で確かめるのは 1 件だけになりました
  • メールに限らず、外部 API への書き込みや決済など、止められない副作用を持つバッチにも使える考え方です

この記事の対象読者

  • メールや通知を送るバッチを、E2E で自動テストしたい方
  • テストのために本番のコードへ dry-run を足せず、実際の副作用とどう付き合うか悩んでいる方
  • 手動のテストケースと自動テストの分担を、どう決めるか考えている方

環境

  • Playwright(TypeScript)。E2E は、VPC 内で起動する使い捨ての ECS タスクの中で動く(前作の構成)
  • 通知バッチは Go で書かれた ECS の単体タスク。検証環境(stg)では、平日 10:00 に EventBridge Scheduler が起動する
  • GitHub Actions(E2E の起動と、実行の種別の受け渡し)

前作の「これから」に答える

前作では、DB を直接引く Playwright の E2E を、GitHub Actions から VPC 内の ECS タスク 1 つで回す構成を書きました。最後の節では、バッチまで E2E を広げるには 2 つ要る、と書いています。

  • 対象日・時刻の注入:テストから「いつの分として動かすか」を渡せること
  • 単体で起動する手段:テストからバッチだけを起動できる入口

通知バッチは、この 2 つを最初から持っていました。ECS の単体タスクとして起動でき、環境変数の上書き(overrides)で次の値を渡せます。

環境変数(一般化した名前) 役割
NOTIFY_COMPANY_IDS 処理する企業を、指定した企業だけに限る
NOTIFY_AS_OF 配信期限の判定時刻を上書きする(RFC 3339)
MAIL_API_ENDPOINT メールを送る API の接続先を上書きする

ただ、1 つだけ無いものがありました。送信を止める仕組みです。バッチのコードを調べ、送らずに動かす手段は無いと確かめました。

送信を止めるのは諦め、絞ることにした

そこで、送信を止めるのは諦め、送っても困らない状態を作ることにしました。送ったまま流せば、E2E は本番と同じ経路を通ります。絞ったのは 3 つです。

絞ったもの やったこと
届き先 検証環境の E2E 用アカウントの宛先を、すべてチームのグループアドレスに +suffix を付けたものにした
対象 起動するときは、必ず E2E 用の企業だけに限る
頻度 実際に送るケースは、週次の実行とリリース前の実行でだけ流す

宛先がチームのグループアドレスなら、届いても困る人はいません。チームのメンバーが受け取るだけです。チームの E2E の規約には「メールは実送信しない」とありましたが、この spec だけは例外として規約に書き足しました。

安全弁を三重にした

宛先をグループアドレスに寄せただけでは、まだ足りません。起動を間違えれば、検証環境のほかの企業にもメールが届くからです。そこで、起動までに 3 つの安全弁を置きました。

(a) 対象企業を限定しないと、起動しない

通知バッチは、対象企業を渡さないと、通知が有効なすべての企業を処理します。E2E がこれを付け忘れると、検証環境のすべての企業に通知が飛びます。

そこで、起動の関数で対象企業を必須にし、E2E が書き換えてよい企業以外が入っていたら止めます。

// 説明用に簡略化したコード
export async function runNotificationTask(options: { companyIds: number[]; asOf: string }) {
  const allowed = [e2eCompanyId(), e2eSecondCompanyId()];
  if (options.companyIds.length === 0) {
    throw new Error('対象企業が空です。対象企業を限定せずに起動することはできません');
  }
  const unexpected = options.companyIds.filter((id) => !allowed.includes(id));
  if (unexpected.length > 0) {
    throw new Error(`E2E が書き換えてよい企業以外が含まれます: ${unexpected.join(', ')}`);
  }
  return runBatchTask({
    environment: [
      { name: 'NOTIFY_COMPANY_IDS', value: options.companyIds.join(',') },
      { name: 'NOTIFY_AS_OF', value: options.asOf },
    ],
  });
}

(b) 起動の前に、配信先のアドレスを DB で検査する

対象企業を限っても、その企業の配信先に、誰かが実在のアドレスの従業員を足しているかもしれません。そこで起動の直前に、配信先(個別に指定した従業員と、ロールの所属者)のアドレスを DB から引き、正規表現で検査します。

// 説明用に簡略化したコード(アドレスは例の値)
const SAFE_EMAIL = /^team-group(\+[a-z0-9.-]+)?@example\.com$/;

for (const row of recipients) {
  if (row.email !== '' && !SAFE_EMAIL.test(row.email)) {
    // アドレスそのものはログに出さない(個人のアドレスの可能性がある)
    unsafe.push(`社員番号 ${row.code}`);
  }
}
if (unsafe.length > 0) throw new Error(`グループアドレスでない配信先があるため起動しません: ${unsafe.join(', ')}`);

1 件でも合わないものがあれば、起動しません。エラーにはアドレスではなく社員番号だけを出し、ログに個人のアドレスを残さないようにしています。

(c) IAM で縛れない部分は、動くコードを限って抑える

E2E のタスクロールには、通知タスクの ecs:RunTask を許可しました。許可は、通知タスクのタスク定義とクラスターに絞っています。

ただし、IAM では「対象企業を限定して起動すること」までは強制できません。環境変数の上書きの中身は、IAM の条件で縛れないためです。タスクロールを使えるコードが対象企業を付けずに起動すれば、すべての企業が処理されます。

この残るリスクは、タスクで動くコードを限ることで抑えました。E2E のタスクで動くのは、main のコードか、レビューで承認された PR のコードだけです。(a) の検査を外すコードは、レビューを通らない限り動きません。

毎日の定期の起動と、ぶつからないようにする

検証環境の通知バッチは、平日 10:00 に Scheduler から起動されます。こちらは対象企業を限定せず、その時点で配信期限を迎えた分を配ります。E2E の起動とぶつかると、2 つの問題が起きます。

  1. E2E が用意したデータを、毎日の起動が先に配ってしまう
  2. 企業単位のロックを取り合い、片方の起動がその企業を飛ばす

テストの対象を 30 日後に置く

1 つ目は、日付をずらして避けました。E2E が用意するデータの実施日を「実行日 + 30 日」の未来の日付にし、判定時刻をその日の 10:00 に固定して起動します。

// 説明用に簡略化したコード
const scheduledDate = addDaysJst(new Date(), 30); // 例: 2026-11-25
const asOf = `${scheduledDate}T10:00:00+09:00`;   // 配信期限ちょうどで判定させる

毎日の起動は、現在の時刻で配信期限を判定します。実施日が 30 日後のデータは、まだ期限を迎えていないので拾いません。一方 E2E の起動は、判定時刻を 30 日後の 10:00 に上書きしているので、このデータを配ります。

もう 1 つ効果がありました。対象企業や判定時刻を上書きした起動では、バッチは監視用のメトリクス(ハートビートや監査の数値)を送りません。E2E の起動が、毎日の起動の監視に混ざることもありません。

ロックの取り合いは、時間帯で避ける

2 つ目のロックの取り合いは、日付をずらしても残ります。毎日の起動は、E2E 用の企業も含めて、企業ごとにロックを取るためです。

これは、時間帯で避けました。

  • E2E は、09:55〜10:15 の間は通知タスクを起動せず、抜けるまで待つ
  • 週次の E2E の定期実行を、09:45 から 10:30 に移した。週次の実行の中の通知の spec が、毎日の起動とロックを取り合わないようにするため

実行の種別で、流すケースを出し分ける

実際に送るケースを、すべての実行で流すわけにはいきません。1 回の実行で、10 通前後のメールが届くためです。そこで、GitHub Actions から E2E のタスクへ「実行の種別」を渡し、spec がケースを出し分けます。

実行の種別 いつ動くか 送らないケース 送るケース(7 本) 送信失敗のケース
pr PR を流す実行 ✓ skip skip
main main の手動起動(入力なし) ✓ skip skip
weekly 週次の定期実行 ✓ ✓ skip
gate リリース前の手動起動(入力 gate=true) ✓ ✓ ✓
// 説明用に簡略化したコード
export function e2eRunKind(): 'pr' | 'main' | 'weekly' | 'gate' {
  const value = process.env.E2E_RUN_KIND ?? 'pr';
  // 未設定や想定外の値は、送らない側(pr)に倒す
  return value === 'main' || value === 'weekly' || value === 'gate' ? value : 'pr';
}

const SENDS_MAIL = e2eRunKind() === 'weekly' || e2eRunKind() === 'gate';

test('送るケース', async () => {
  test.skip(!SENDS_MAIL, '実メールを送るケースは、週次とリリース前の実行でだけ流す');
  // ...
});

ポイントは 2 つです。

  • 未設定や想定外の値は、送らない側に倒す。 種別を渡し忘れても、メールが増える事故にはなりません
  • 送信失敗のケースは、リリース前の実行だけ。 失敗を起こすと、チームの Slack に @channel 付きの通知が鳴ります。毎週鳴らさないよう、ここまで絞りました

もう 1 つ工夫したのが、実行名を分けたことです。リリース前の実行も、入力なしの手動起動も、どちらも main を流します。入力を付け忘れると、送るケースを流さないまま、リリース前の確認が「成功」で終わってしまいます。そこで、実行名を E2E (main) と E2E (main, gate) に分け、リリースの判断には後者の結果を見る、と決めました。

判定は配送記録と終了コードで行い、ログと受信箱は見ない

E2E が「通知が正しく動いた」と判定する材料は、2 つだけです。

  • 配送記録の状態:企業 × データごとに 1 行あり、sent(送った)/ no_target(対象者がいなかった)/ failed(送信に失敗した)のどれかになる
  • タスクの終了コード:送信に失敗した企業があれば 1 になる
const result = await launch([primary]);
expect(result.exitCode, '通知タスクの終了コード').toBe(0);
expect(await fixture.delivery(primary), '配送記録').toMatchObject({ status: 'sent' });

バッチのログは見ません。ログは共有のログ基盤(S3)へ非同期に書き出されるため、テストからログの到着を待ち合わせると、不安定の元になります。短いバッチのログ転送では、過去に不具合があったこともあります。配送記録なら、タスクが終わった時点で確定しています。

送信失敗のケースは、メールを送る API の接続先を、到達できないアドレスに上書きして起こします。終了コードが 1、配送記録が failed になることを確かめた後、上書きなしで起動し直し、sent に変わることまで確かめます。

「一部」を「済」に上げた考え方

チームには、手動テストのケース一覧があります。各ケースには「E2E」の列があり、次の値を付けます。

値 意味
済 E2E が期待結果をすべて確かめている。手動の実施を省ける
一部 E2E が一部だけ確かめている。手動の実施は残る

手動を省けるのは「済」だけです。E2E を書いただけでは、多くのケースが「一部」に留まりました。受信箱を見ないので、「メールが届く」「本文が正しい」を E2E で確かめられないためです。

そこで、期待結果そのものを見直しました。

  • 単体テストで担保済みの確認は、期待結果から外した。 「対象者がいなければメールが送信されない」「対象外の企業には送信されない」は、バッチの単体テストで確かめています。チームには「単体テストで担保済みの確認は、手動のケースに書かない」という方針があり、それに沿って外しました
  • 本文の確認は、代表のケースに任せた。 条件だけが違うケースでも、メールの本文は条件によらず同じです。本文の確認は代表の 1 ケースで毎回行い、ほかのケースは配送記録で判定する形に絞りました

結果、15 件のうち 10 件が「済」になりました。手動に残ったのは、次の 5 件です。

頻度 件数 手で確かめること
毎回 1 本文と、実際に届くこと
関係する実装を変えたとき 4 誰に何通届いたか(3 件)、エラーログ(1 件)

「誰に何通届いたか」は、受信者ごとの記録が DB に残らないため、E2E からは見えません。宛先の決め方が変わりうる実装の箇所を書き出し、そこを変えたリリースでだけ手で確かめることにしました。

見送ったこと

受信箱を E2E から読む

受信箱を読めれば、「一部」のケースの多くを「済」にできます。ただ、チームのアドレスはグループのアドレスなので、E2E からは直接読めません。E2E から読めるメールボックスを、別に申請する必要があります。

権限と認証情報の管理も含めて費用が大きいわりに、毎回手で確かめるのは 1 件だけです。今回は見送り、手動の負担が問題になったときに見直すことにしました。

ログの自動確認

ログを E2E から確かめるには、ログ基盤を読む権限を足す必要があります。前述のとおり、非同期の書き出しを待つことになり、不安定さの元になります。これも見送りました。

踏んだこと

送信失敗のケースは、Slack の通知が 2 種類鳴る

送信失敗のケースを流すと、ERROR のログからログ基盤が Slack へ通知します。これは想定していました。初回のリリース前の実行で分かったのは、もう 1 つ、ECS タスクの失敗を検知する監視アラーム(Critical)も必ず鳴ることです。終了コード 1 で終わるので、当然ではあります。手順書に、両方が鳴ることと、意図した失敗だとスレッドに書き残すことを足しました。

E2E が作ったデータは、実施日が空だった

E2E の準備では、取り込みバッチを動かしてデータを作ります。ところが、作ったデータの実施日は空(NULL)でした。取り込みの起動で、実施日を渡す環境変数を渡していなかったためです。実施日が空のデータは、通知の候補になりません。spec の開始時に実施日(30 日後)を書き込み、終了時に戻すようにしました。

2 社目の宛先は、手で作った

複数の企業へ通知するケースには、2 社目にも宛先の従業員が要ります。既存のテスト用の従業員は、ほかのテストで役割が決まっていて使い回せませんでした。2 社目には、宛先専用の従業員を手で 1 人作りました。

結果

初回のリリース前の実行(2026-10-26)の結果です。

項目 値
成功 / 失敗 / skip(E2E 全体) 171 / 0 / 91
実行全体の所要時間 約 28 分
PR の実行で skip される通知のケース 8 本(送るケース 7 本と、送信失敗のケース)
手動の 15 件のうち「済」 10 件
毎回手で確かめるケース 1 件

skip の 91 件には、通知以外の spec の skip も含みます。

まとめ

決めたこと 理由
送信を止めるのは諦め、実送信のままテストする バッチに送信を止める仕組みが無い。送ったままなら本番と同じ経路を通る
宛先をチームのグループアドレスに寄せた 届いても困る人がいない
対象企業の限定を必須にし、宛先も起動前に検査した 付け忘れと、配信先の想定外の変更を止める
IAM で縛れない部分は、動くコードを限って抑えた 環境変数の中身は IAM で強制できない
テストの対象を 30 日後に置き、判定時刻を固定した 毎日の起動に先に配られない
09:55〜10:15 は起動を待ち、週次を 10:30 へ移した 企業単位のロックを取り合わない
実行の種別で、送るケースを出し分けた メールの通数と @channel を必要な実行に限る
判定は配送記録と終了コードだけ ログの遅れに引きずられない
単体テストで担保済みの確認を外し、本文は代表に任せた 「済」を増やし、手動を減らす

学びは 2 つです。

  1. 副作用を止められないなら、副作用の届く範囲を絞る。 送信を止める仕組みが無くても、届き先・対象・頻度を絞れば、実送信のまま安全に回せます。絞り方は 1 つに頼らず、起動の前に重ねて検査するのが大事でした。外部 API への書き込みのような、メール以外の副作用でも同じです
  2. 自動化の効果は、手動のケースの書き方で決まる。 E2E を書いただけでは「一部」が残ります。期待結果のうち、単体テストや代表のケースに任せられるものを外して初めて、手動の実施を減らせました

参考

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?