受信メールの検証、MailTrap

# 概要
以前、投稿した記事[「CodeceptJSで受信メールの検証、MailSlurp vs gmail　お薦めはMailSlurp」](https://qiita.com/KazuhiroYoshino/items/d55e570f3b9fb0fd24bc)に利用できるメールサーバを1つ追加という話。
まず簡単に、前回紹介したものも含めて比較してみます。

|メール受信|必要なインストール|受信手段|利用するメールサーバ|特記事項|
|:---|:---|:---|:---|:---|
|MailTrap|特になし|APIを参考に自作したLastMail受信メソッド|MailTrap|1つのinboxに50通まで。その後は古いメールから削除される。無料だと500通/月まで|
|MailSlurp|MailSlurp-client|メールがこちらに送信された後のLastMailメソッド|MailSlurp|利用頻度によっては有料プランが必要。無料だと100通/月まで|
|gmail|inboxおよびmailParserおよびiconv|メール待受け中の着信に反応|gmail|IMAPが必須。gmailの外からのアプリケーションアクセスの許可が必須|
参考にした資料：

* [メールのテストに便利なMailTrap](https://sendgrid.kke.co.jp/blog/?p=10535)
* [Mailtrap API Documentation](https://mailtrap.docs.apiary.io/#)
* [e2eテスト自動化する](https://qiita.com/teyosh/items/b69370cbc0bd404938c9)

専用のメール・クライアントが用意されている「MailSlurp」
APIを参考に自前でゴリゴリ受信メソッドを書く「MailTrap」
一言で言うと、そんな感じです。
MailSlurpとMailTrapの有料プランを比較してみました。参考まで。
MailSlurp
![mailslurp011.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/939617/8d00a0e2-4b82-679b-63b3-50466d4dfaaa.png)
MailTrap
![mailTrap007.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/939617/2f3d2175-484e-e5e0-7361-5fe50a5a7739.png)
MailTrapは９ドルからあるみたいです。あと、inboxの数に違いがありますが、メールアドレスにエイリアスを使わなければ、inboxの数 = テストに使えるメールアドレスの数、と考えると、同じくらいの料金ではMailSlurpの方が多いように見えます。
また、MailSlurp-clientでは、開発言語についてjavaScript のみのようですが、MailTrapでは、cURLといったCUI、Ruby / Python / PHP / Java / Perl / C# についてもスニペットがありますので、自作に当たっての参考になればと思います。
# メール受信して何を自動テストするか
メールを受信して、本文に含まれるキーワードを使ってWebUIの検証をするという点は前回の投稿と同等ですが、メールのSubjectについて検証対象に追加しました。
[Selenium学習サイト](https://hotel.testplanisphere.dev/ja/index.html?)を使って、以下のメール本文

> [会員ランク]
> [会員氏名]様。
> この度は当ホテルに会員登録いただき、誠にありがとうございました。
> 登録手続きが完了いたしましたので、ご連絡申し上げます。
> ユーザーID：[メールアドレス]
> パスワードは登録時に設定頂いたものをお使い頂けます。
> ご登録頂いた内容につきましては、以下のアドレスにてご確認頂けます。
> [URL]
> それでは、ご利用を心からお待ち申し上げております。
> [トップページのURL]
からメールアドレスと控えておいたパスワードを使って、会員情報へのログインした後、IDと会員ランクと氏名とを評価します。

今回も、テストデータとして以下を準備して臨みました。

* 登録済みの4名を使ったテスト（会員登録完了のご連絡001 / 会員登録完了のご連絡006 / 会員登録完了のご連絡007/ 会員登録完了のご連絡008）
* リンク先URLが間違っているケース（会員登録完了のご連絡002）
* 会員種別が間違っているケース（会員登録完了のご連絡003）
* メール文言の一部が間違っているケース（会員登録完了のご連絡004）
* 会員氏名が間違っているケース（会員登録完了のご連絡005）

メール本文さえ取得できれば、後はURLっぽいのを抽出したり、メールアドレスっぽいのを抽出したりは、JavaScriptのmatch関数で正規表現を用いて行ないます。
.waitForLatestEmail だけでメール本文を取得できる点でMailSlurpは、とても簡単です。
今回紹介するMailTrapの場合、API仕様に従ったhttpsリクエストを投げて返信をもらうといった実装は、自前で行なう必要があります。
メールに記載のURLに移動したり、ログインをしたりは、CodeceptJSを利用します。
# MailTrapを利用した場合の自動テスト環境設定について
事前に[MailTrapのサイト](https://mailtrap.io/)にアカウントを作成しログイン後、Api-Tokenを取得しておきます。
![mailTrap003.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/939617/3b3614ac-33cc-33ea-af2e-92ceaa5280d4.png)
また、IDおよびパスワードも画面に表示されたものを使いますので控えておきます。
![mailTrap004.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/939617/de8654d9-9714-1f62-2f0c-f56d6cd4391d.png)

inboxid（受信箱のIDみたいなもの）は、APIのドキュメントページにてApi-Tokenを使って取得します。
![mailTrap005.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/939617/8f7c428a-4cf5-7d3b-be58-eb4c61c0a66e.png)

このAPIドキュメントですが、実際にhttpsでリクエストを出して、返信を確認することが出来ますので、テストコードを実装する際、挙動を確認しながらの作業ができます。
受信するinboxのメールアドレスは、
![mailTrap010.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/939617/ed186408-43fe-6e27-e545-3f5008a44281.png)
InboxのEmail Addressタブに表示されたものを利用します。
codecept.conf.jsですが、helperにcodeceptjs-chai、plugINにAllureレポートを追加しただけで、MailTrapに関するものは、何も追加しません。
メール送受信に関するコードは、

```eMailMailTrapCommon.js
var nodemailer = require('nodemailer');

module.exports.SendMail = async function(rank, subject, simei, id, url, callback_func){
    var address = 'ここにMailTrapの受信ボックスアドレス';
    var nodemailer = require('nodemailer');
    var smtpConfig = {
        port: 2525,
        host: 'smtp.mailtrap.io',
        auth: {
            user: 'ここにMailTrapのID',
            pass: 'ここにMailTrapのパスワード'
        }
    };
    const transporter = nodemailer.createTransport(smtpConfig);
    var body01 = '様。\r\n';
    var body02 = 'この度は当ホテルに会員登録いただき、誠にありがとうございました。\r\n';
    var body03 = '登録手続きが完了いたしましたので、ご連絡申し上げます。\r\n';
    var body04 = 'ユーザーID：';
    var body05 = 'パスワードは登録時に設定頂いたものをお使い頂けます。\r\n';
    var body06 = 'ご登録頂いた内容につきましては、以下のアドレスにてご確認頂けます。\r\n';
    var body07 = 'それでは、ご利用を心からお待ち申し上げております。\r\nhttps://hotel.testplanisphere.dev/ja/index.html';

    var body = rank + '\r\n' + simei + body01 + body02 + body03 + body04 + id + '\r\n' + body05 + body06 + url + '\r\n' + body07;

    let info = await transporter.sendMail({
        from: 'ここは送信元のアドレス',
        to: address,
        subject: subject,
        text: body
    }, function(e, res){
       console.log(e ? e.message : res.message);
       smtp.close();
    });
    return await info;
};

module.exports.lastMailId = async function(){
    var result;
    var https = require('https');
    var url = 'https://mailtrap.io/api/v1/inboxes/"ここにinboxid"/messages';
    let options = {
        headers: {
            'Api-Token': 'ここにApi-Token'
        }
    }
    var data = [];

    let promise = await new Promise(async(resolve, reject) => {
        await https.get(url, options, async function (res) {
            await res.on('data', function(chunk) {
                data.push(chunk);
            }).on('end', function() {
                var events   = Buffer.concat(data);
                var r = JSON.parse(events);
                var matchData = r.filter(function(item, index){
                    if(item.id != '0') return true;
                });
                resolve(r[0].id);
//                console.log(r[0].id);
            });
        });
    }).then((lastId) => {
        result = lastId;
    });
    return await result;
}

module.exports.receiveSubject = async function(){
    var result;
    var https = require('https');
    var url = 'https://mailtrap.io/api/v1/inboxes/"ここにinboxid"/messages';
    let options = {
        headers: {
            'Api-Token': 'ここにApi-Token'
        }
    }
    var data = [];

    let promise = await new Promise(async(resolve, reject) => {
        await https.get(url, options, async function (res) {
            await res.on('data', function(chunk) {
                data.push(chunk);
            }).on('end', function() {
                var events   = Buffer.concat(data);
                var r = JSON.parse(events);
                var matchData = r.filter(function(item, index){
                    if(item.id != '0') return true;
                });
                resolve(r[0].subject);
//                console.log(r[0].id);
            });
        });
    }).then((lastId) => {
        result = lastId;
    });
    return await result;
}

module.exports.receiveMail = async function(mailid){
    var result;
    var https = require('https');
    var url = 'https://mailtrap.io/api/v1/inboxes/"ここにinboxid"/messages/' + mailid + '/body.txt';
    let options = {
        headers: {
            'Api-Token': 'ここにApi-Token'
        }
    }
    var data = [];

    let promise = await new Promise((resolve, reject) => {
        let client = https.get(url, options, function (res) {
            res.setEncoding('utf-8');
            res.on('data', function(chunk) {
                resolve(chunk);
            });
            res.on('close', function(){
//                console.log('Connection closed');
            });
            res.on('end', function() {
//                console.log('Response data end');
            });
            res.on('aborted', function() {
                console.log('Connection aborted');
            });
        });
    }).then((body) => {
        result = body;
    });
    return await result;
};
```
Node.jsのhttpsモジュールでApi-Token含めてGETし、返信がJSON形式で返ってくるので、最後に着信したメールのIDを取得し、取得したメールIDの本文をUTF-8でエンコードして取り出しています。
MailSlurp-clientの .waitLastEmailは未読の最終着信を見ているようなので、ここは、もう少し（例えばメールが到着しない場合とか想定して）改良の余地があるかもしれません。
CodeceptJSで使うテストコードは、以下のようになります。

```eMailTest_test.js
let emailTestTable = new DataTable(['Subject', 'url', 'verifyTitle', 'verifyWord', '会員ランク', '会員氏名', '会員ID', '会員Pass']);
emailTestTable.add(['会員登録完了のご連絡001','https://hotel.testplanisphere.dev/ja/login.html','ログイン | HOTEL PLANISPHERE - テスト自動化練習サイト','ご利用を心からお待ち申し上げております。', 'プレミアム会員', '山田一郎', 'ichiro@example.com', 'password']);
emailTestTable.add(['会員登録完了のご連絡002','https://hotel.testplanisphere.dev/ja/index.html','ログイン | HOTEL PLANISPHERE - テスト自動化練習サイト','ご利用を心からお待ち申し上げております。', 'プレミアム会員', '山田一郎', 'ichiro@example.com', 'password']);
emailTestTable.add(['会員登録完了のご連絡003','https://hotel.testplanisphere.dev/ja/login.html','ログイン | HOTEL PLANISPHERE - テスト自動化練習サイト','ご利用を心からお待ち申し上げております。', '一般会員', '山田一郎', 'ichiro@example.com', 'password']);
emailTestTable.add(['会員登録完了のご連絡004','https://hotel.testplanisphere.dev/ja/login.html','ログイン | HOTEL PLANISPHERE - テスト自動化練習サイト','ご利用を心からお待ち申し上げます。', 'プレミアム会員', '山田一郎', 'ichiro@example.com', 'password']);
emailTestTable.add(['会員登録完了のご連絡005','https://hotel.testplanisphere.dev/ja/login.html','ログイン | HOTEL PLANISPHERE - テスト自動化練習サイト','ご利用を心からお待ち申し上げております。', 'プレミアム会員', '山田一朗', 'ichiro@example.com', 'password']);
emailTestTable.add(['会員登録完了のご連絡006','https://hotel.testplanisphere.dev/ja/login.html','ログイン | HOTEL PLANISPHERE - テスト自動化練習サイト','ご利用を心からお待ち申し上げております。', '一般会員', '松本さくら', 'sakura@example.com', 'pass1234']);
emailTestTable.add(['会員登録完了のご連絡007','https://hotel.testplanisphere.dev/ja/login.html','ログイン | HOTEL PLANISPHERE - テスト自動化練習サイト','ご利用を心からお待ち申し上げております。', 'プレミアム会員', '林潤', 'jun@example.com', 'pa55w0rd!']);
emailTestTable.add(['会員登録完了のご連絡008','https://hotel.testplanisphere.dev/ja/login.html','ログイン | HOTEL PLANISPHERE - テスト自動化練習サイト','ご利用を心からお待ち申し上げております。', '一般会員', '木村良樹', 'yoshiki@example.com', 'pass-pass']);

Feature('emailTest');

Data(emailTestTable).Scenario('eMail_Test', async ({I , current}) => {
    var mailBox = require('../emailMailTrapCommon');

//テスト用メール送信
    var sendMail = await mailBox.SendMail(current.会員ランク,current.Subject,current.会員氏名,current.会員ID,current.url,null);
    await sleep(10000);
//    I.wait(30);
//ここまで

    var lastid = await mailBox.lastMailId();
    var sentSubject = await mailBox.receiveSubject(lastid);
    var sentBody = await mailBox.receiveMail(lastid);
    console.log(sentSubject);
    console.log(sentBody);
//Subjectの検証
    I.assertContain(sentSubject, current.Subject);
//文面の文言検証
    I.assertContain(sentBody, current.verifyWord);
    const memberid = await sentBody.match(/[A-Za-z0-9_.-]*@example.com/)[0];
    const url = await sentBody.match(/(https?|ftp)(:\/\/[\w\/:%#\$&\?\(\)~\.=\+\-]+)/g)[0];
//文面から取得したURLに移動
    await I.amOnPage(url);
//移動先の検証、ここではタイトル
    I.seeTitleEquals(current.verifyTitle);
//文面から取得したアカウントでログイン
    I.fillField('email', memberid);
    I.fillField('password', current.会員Pass);
    I.waitForClickable('#login-button');
    I.click('#login-button');
//文面から移動した会員ページの検証
    I.see(current.会員ID);
    I.see(current.会員氏名);
    I.see(current.会員ランク);
    I.click('#logout-form > button');
});

function sleep(time) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve();
        }, time);
    });
}
```
前述した意図的にエラーを混入させたテストデータを含めて、8件のテストが実行されます。
Subjectの検証ですが、テストデータに含まれるSubjectを、そのままメール送信しているので、本件ではメール受信した後の検証でエラーにはならず、会員ページに記載の氏名と異なっていると検出されています。実際のメール送信を含むシステムテストでは、期待されるSubject（例えば予約日付が入ったり）を含むテストデータを準備してテストに臨むことになります。
メールテスト中は、I.wait()が効いていなさそうなので、別途 sleep を実装して使っています。
# テスト結果の確認
メールボックスには、テスト用のメールが着信していることが確認できます。
![mailTrap006.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/939617/41d3f860-c9f5-b377-2fd1-3967342b8eb2.png)

テストレポートはAllureで表示できます。
![mailTrap009.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/939617/ddce0c37-a18f-b9b7-cb34-4345436adb7d.png)

余談ですが、今回helperとしてplayWrightを用いて、テスト中の動画撮影機能を有効化してみました。
![mailTrap008.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/939617/4663130a-0ad0-f7d0-29dc-1496c00e52d7.png)
撮影された動画は、エラーを混入したテストデータを使った場合のみ、個別にクリップとして記録されているようです。Allureのレポートに表示されるスクリーンショットと併せて、動画も記録として残るようなので（platWrightの場合だけです。また、webm形式で記録されます。）報告資料として添付する等の用途があるかと思います。
# 総括
前回のMailSlurpに続いてMailTrapについてもメール受信のテストで使えますので、メール受信が関係するWebUIの自動テストの事例紹介とします。
紹介した事例にあるように、メールのSubjectと本文が、間違っていないかのテストが主旨となります。仕様との差異を間違い探しするようなことです。（ 一郎 と 一朗 みたいな）
単に間違い探しであれば、あまり予算や時間をかけずにできる点で、MailSlurpやMailTrapを利用して、CodeceptJSと併用するのは、良い選択だと考えます。
ぜひ、やってみてください。