先々月のブログで「Playwrightはプログラマーでなくても比較的使いやすいよ」という記事をかきました。その後、Gmailに届くメールを取得できないかのという話題が社内であったので、APIを使ってざっくり実装してみました。
TL;DR
- Google APIを使えばGmailを読み取れる
- Playwrightの範疇ではないので、単純なTypeScriptのコードに
- 他の方法も場合によってはありかも……?
環境
- Node.js: v22
- Playwright: v1.17.0
ライブラリを導入する
プロジェクトルートで、次のパッケージをnpm installします。PlaywrightのプロジェクトをVSCodeで開いていれば、画面下のターミナルにコピペしてEnterを押すだけです
npm i -D googleapis @google-cloud/local-auth google-auth-library
Gmailを取得するサードパーティーのライブラリもあったので、それらを使う方が便利かもしれません。ただ、それらのパッケージの安全性を確認していないので、今回はGoogleが提供しているライブラリのみを使いました。
Google APIを準備する
Gmailを取得する上で、一番ややこしい・めんどくさいのがGoogle APIの設定です。ここではざっくり、次の手順でOAuth 2.0 クライアントIDを作成します。
- Google Cloud Console にアクセスして、プロジェクトを選択か作成する
- 検索欄に “Gmail API” と入力して、Gmail APIを有効化する
- Google Auth Platformから、OAuth 2.0 クライアントIDを作成する
- アプリケーションの種類は「デスクトップ アプリ」を選択
- クライアントシークレットのJSONをダウンロードする
ダウンロードしたJSONファイルは、プロジェクトのルートに credentials.json
という名前で保存しておきます。このファイルはGitのコミットに追加しないように.gitignoreに指定するなど気をつけてください。
https://developers.google.com/identity/protocols/oauth2?hl=ja#installed
PlaywrightでGmailを取得する(原理)
上2つの準備が終わったら、Gmailを取得できます。大雑把なコードは、このようになります。
import { google } from 'googleapis';
import { authenticate } from '@google-cloud/local-auth';
const auth = await authenticate({
keyfilePath: path.join(process.cwd(), 'credentials.json'),
scopes: ['https://www.googleapis.com/auth/gmail.readonly'],
});
const gmail = google.gmail({ version: 'v1', auth });
const mailList = await gmail.users.messages.list({
userId: 'me',
});
const message = await gmail.users.messages.get({
userId: 'me',
id: mailList.data?.messages?.[0]?.id,
});
最初の auth
を取得している箇所がOAuth 2.0の認証処理です。自分で実装すると面倒ですが @google-cloud/local-auth
のauthenticate関数を使って認証を行います。
この部分では、ブラウザが起動してGoogleの認証画面が表示されるので、ログインして許可します。
次の mailList
の部分は、見てのとおり /users.messages/list APIでメール一覧を取得しています。
最後の message
も、見てのとおり /users.messages/get APIでメールの詳細を取得しています。ここでは、mailList
の最初のメールのIDを指定しています。
この message
変数にはいろいろな情報が入っている上、メール本文がBase64でエンコードされています。そのため、テストできるような文字列にするにはもうちょっと処理が必要です。
PlaywrightでGmailを取得する(実装)
実際にメールを取得する処理を関数にして、実際にPlaywrightから取得できるようにしてみます。
コードはこんな感じで、Playwrightでのテストというより、ただのTypeScriptという見た目になりますね
import { test } from '@playwright/test';
import { google } from 'googleapis';
import { authenticate } from '@google-cloud/local-auth';
import { OAuth2Client } from 'google-auth-library';
async function fetchGamil(auth: OAuth2Client, query: string = '') {
const gmail = google.gmail({ version: 'v1', auth });
// メールを検索する
const list = await gmail.users.messages.list({
userId: 'me',
q: query,
});
const messageId = list.data?.messages?.[0]?.id;
if (!messageId) {
return;
}
// メールを取得する
const message = await gmail.users.messages.get({
userId: 'me',
id: messageId,
});
const subject = message.data.payload?.headers?.find((header) => header.name === 'Subject')?.value;
const rawBody = message.data.payload?.body?.data;
const firstPart = message.data.payload?.parts?.[0]?.body?.data;
const body = Buffer.from(rawBody ?? firstPart ?? '', 'base64').toString();
return { subject, body };
}
test('Gmailのメールを1件取得する', async ({ page }) => {
const auth = await authenticate({
keyfilePath: path.join(process.cwd(), 'credentials.json'),
scopes: ['https://www.googleapis.com/auth/gmail.readonly'],
});
const mail = await fetchGamil(auth, '検索ワード');
console.log(`件名: ${mail?.subject}`);
console.log(`本文: ${mail?.body}`);
});
メール取得APIの処理を fetchGamil 関数に切り出し、メールの件名や本文の取得までを関数内で行うようにしました。
subject
やbody
の取得処理は、見て分かるようにかなりラフな実装になっています。
実際に送信されるメールの構造にあわせて、もっとちゃんと処理する必要があります。
リフレッシュトークンを使う
上のコードでは、テストを実行すると常にOAuthの認証画面が表示され、許可ボタンを押す必要があります。
リフレッシュトークンを使うと、以前に認証した情報を使って認証を行うことができます。ただし、リフレッシュトークンの保存や取得は @google-cloud/local-auth に機能が無いようなので、自分でその処理を実装するとこんなコードになりました
async function apiAuthenticate() {
const existingToken = await readTokenFile();
if (existingToken) {
// token.jsonの内容でOAuth2Clientを作成
const auth = new google.auth.OAuth2(existingToken);
auth.setCredentials({ refresh_token: existingToken.refreshToken });
return auth;
}
// @google-cloud/local-authを使って認証
const auth = await authenticate({
keyfilePath: path.join(process.cwd(), 'credentials.json'),
scopes: ['https://www.googleapis.com/auth/gmail.readonly'],
});
writeTokenFile({
clientId: auth._clientId ?? (() => {throw new Error()})(),
clientSecret: auth._clientSecret ?? (() => {throw new Error()})(),
refreshToken: auth.credentials.refresh_token ?? (() => {throw new Error()})(),
});
return auth;
}
async function writeTokenFile(credentials: {clientId: string, clientSecret: string, refreshToken: string}) {
const filePath = path.join(process.cwd(), 'token.json');
const jsonText = JSON.stringify(credentials);
await fs.writeFile(filePath, jsonText, 'utf-8');
}
async function readTokenFile() {
try {
const filePath = path.join(process.cwd(), 'token.json');
const jsonText = await fs.readFile(filePath, 'utf-8');
const parsed = JSON.parse(jsonText);
return {
clientId: parsed.clientId ?? (() => {throw new Error('token.jsonにclientIdがありません')})(),
clientSecret: parsed.clientSecret ?? (() => {throw new Error('token.jsonにclientSecretがありません')})(),
refreshToken: parsed.refreshToken ?? (() => {throw new Error('token.jsonにrefreshTokenがありません')})(),
};
} catch (error) {
if (error.code === 'ENOENT') {
return; // ファイルが無い場合は何もしない
}
throw error;
}
}
/* ... 中略 ... */
test('Gmailのメールを1件取得する', async ({ page }) => {
const auth = await apiAuthenticate();
const mail = await fetchGamil(auth!, '検索ワード');
console.log(`件名: ${mail?.subject}`);
console.log(`本文: ${mail?.body}`);
});
認証トークンの取得などの処理も、apiAuthenticate 関数に切り出しました。リフレッシュトークンがあればそれを使い、なければ認証画面を表示して取得するという処理になりました。
credentials.jsonと同様に、token.jsonもコミットに追加しないよう注意してください。リフレッシュトークンはログインに使ったアカウントのアクセス権そのものなので、社内やプロジェクト内であっても他の人とは共有しないでください。
実際のメール送信でテストしてみる
最後に、実際にメールを送るテストを実施してみます。題材としては前回と同じくEC-CUBEで、仮会員登録時の挙動について、次のテストを実施してみます
- テストの実施時にGoogle APIの認証をし、認証できなければテストをスキップする
- EC-CUBEの会員登録ページにアクセスして、会員登録を行う
- EC-CUBEから、本会員登録の案内メールが届く
- メールに記載のURLにアクセスすると、本会員登録が完了する
上でかいた関数なども含め、コード全文がこちらです。
import { test, expect } from '@playwright/test';
import path from 'path';
import fs from 'fs/promises';
import { google } from 'googleapis';
import { authenticate } from '@google-cloud/local-auth';
import { OAuth2Client } from 'google-auth-library';
async function writeTokenFile(credentials: {clientId: string, clientSecret: string, refreshToken: string}) {
const filePath = path.join(process.cwd(), 'token.json');
const jsonText = JSON.stringify(credentials);
await fs.writeFile(filePath, jsonText, 'utf-8');
}
async function readTokenFile() {
try {
const filePath = path.join(process.cwd(), 'token.json');
const jsonText = await fs.readFile(filePath, 'utf-8');
const parsed = JSON.parse(jsonText);
return {
clientId: parsed.clientId ?? (() => {throw new Error('token.jsonにclientIdがありません')})(),
clientSecret: parsed.clientSecret ?? (() => {throw new Error('token.jsonにclientSecretがありません')})(),
refreshToken: parsed.refreshToken ?? (() => {throw new Error('token.jsonにrefreshTokenがありません')})(),
};
} catch (error) {
if (error.code === 'ENOENT') {
return; // ファイルが無い場合は何もしない
}
throw error;
}
}
async function apiAuthenticate() {
const existingToken = await readTokenFile();
if (existingToken) {
// token.jsonの内容でOAuth2Clientを作成
const auth = new google.auth.OAuth2(existingToken);
auth.setCredentials({ refresh_token: existingToken.refreshToken });
return auth;
}
// @google-cloud/local-authを使って認証
const auth = await authenticate({
keyfilePath: path.join(process.cwd(), 'credentials.json'),
scopes: ['https://www.googleapis.com/auth/gmail.readonly'],
});
writeTokenFile({
clientId: auth._clientId ?? (() => {throw new Error()})(),
clientSecret: auth._clientSecret ?? (() => {throw new Error()})(),
refreshToken: auth.credentials.refresh_token ?? (() => {throw new Error()})(),
});
return auth;
}
async function fetchGamil(auth: OAuth2Client, query: string = '') {
const gmail = google.gmail({ version: 'v1', auth });
// メールを検索する
const list = await gmail.users.messages.list({
userId: 'me',
q: query,
});
const messageId = list.data?.messages?.[0]?.id;
if (!messageId) {
return;
}
// メールを取得する
const message = await gmail.users.messages.get({
userId: 'me',
id: messageId,
});
const subject = message.data.payload?.headers?.find((header) => header.name === 'Subject')?.value;
const rawBody = message.data.payload?.body?.data;
const firstPart = message.data.payload?.parts?.[0]?.body?.data;
const body = Buffer.from(rawBody ?? firstPart ?? '', 'base64').toString();
return { subject, body };
}
const isFileExists = async (filePath: string) => fs.access(filePath).then(() => true).catch(() => false);
test('正常系:新規会員登録時に仮登録メールが届く', async ({ page }) => {
const gmailAuth = await apiAuthenticate();
test.skip(!gmailAuth, "Google APIの認証に失敗しました");
const BASE_EMAIL = 'user+20250101010101@example.test';
const FROM_EMAIL = 'admin@example.test';
// ユニークなメールアドレスを生成
const yyyyMMddHHmmss = (new Date()).toLocaleString('sv-SE').replace(/[-: ]/g, '');
const userEmail = BASE_EMAIL.replace('20250101010101', yyyyMMddHHmmss);
// 会員登録ページ
await page.goto('https://ec-cube.example.test/entry');
await page.getByRole('textbox', { name: '姓' }).fill('テスト氏名');
await page.getByRole('textbox', { name: '名', exact: true }).fill(yyyyMMddHHmmss);
await page.getByRole('textbox', { name: 'セイ' }).fill('テスト');
await page.getByRole('textbox', { name: 'メイ' }).fill('テスト');
await page.getByRole('textbox', { name: '例:5300001' }).fill('1000000');
await page.locator('#entry_address_pref').selectOption('13');
await page.getByRole('textbox', { name: '市区町村名(例:大阪市北区)' }).fill('千代田区1-1');
await page.getByRole('textbox', { name: '番地・ビル名(例:西梅田1丁目6-8)' }).fill('テストビル');
await page.getByRole('textbox', { name: '電話番号' }).fill('09000000000');
await page.getByRole('textbox', { name: '例:ec-cube@example.com' }).fill(userEmail);
await page.locator('#entry_email_second').fill(userEmail);
await page.getByRole('textbox', { name: '半角英数記号1〜50文字' }).fill('password');
await page.locator('#entry_plain_password_second').fill('password');
await page.getByRole('checkbox', { name: '利用規約に同意してお進みください' }).check();
await page.getByRole('button', { name: '同意する' }).click();
// 確認画面
await page.getByRole('button', { name: '会員登録をする' }).click();
// ⚠ メールが届くまで待つしかない
await page.waitForTimeout(5000);
// メールを取得して、内容を検証
const receivedMail = await fetchGamil(gmailAuth!, `Subject:会員登録のご確認 From:${FROM_EMAIL}`);
expect(receivedMail).not.toBeUndefined();
console.debug(receivedMail);
expect(receivedMail!.subject).toBe('[EC-CUBE SHOP] 会員登録のご確認');
expect(receivedMail!.body).toContain(`テスト氏名 ${yyyyMMddHHmmss} 様`);
expect(receivedMail!.body).toContain('本会員登録を完了するには下記URLにアクセスしてください。');
// 本登録URLを取得
const activateUrlMatch = receivedMail!.body.match(/https:\/\/.*\/entry\/activate\/\w+/);
expect(activateUrlMatch).not.toBeNull();
// 本登録URLにアクセス
await page.goto(activateUrlMatch![0]);
await expect(page.getByRole('paragraph')).toContainText('会員登録が完了しました。');
});
Gmailに関する関数が長いので、実際には gmail.ts などに分離した方が良いと思います。
ちょっとしたTipsですが、yyyyMMddHHmmss
変数はJavaScriptのDateオブジェクトから取得した年月日時分秒の文字列です。.toLocaleString('sv-SE')
は、スウェーデン語形式で日時を文字列にすると 2025-03-14 01:23:45
と良い感じになることを利用しています。
注意点
これでテストコード中にGmailを取得できるようになりましたが、ただいいくつか注意点があります。
OAuthが必要なのでCI/CDに不向き
初回時には、認証画面が表示され、自分で許可ボタンを押す必要があります。そのため、GitHub ActionsのようなCIでの自動実行には向いていません。
テストの実行時間が長くなる
Playwrightでは「○秒待つ」のような固定秒数の待機は非推奨で、代わりに「特定の要素が表示されるまで」とか「JavaScriptの読み込みが終わるまで」のような待機をします。これのおかげで、テスト中の無駄な待機時間がなく、テストの実行時間が短く済みます。
しかし、Gmailを受信についてはそのような柔軟な待機ができず、適当な秒数を待ってからメール一覧を取得しています。サンプルコードでは5秒待つ処理を入れていますが、実際には5秒では足りないかもしれないですし、もしかしたら1秒で取得できてしまって4秒が無駄な待ち時間になってしまうかもしれません。
特にGitHub Actionsなどでは実行時間に応じて料金が発生するので、テストの実行時間を短くすることは重要です。
外部APIに依存するので、テストが不安定に
Gmailに限りませんが、テスト中にAPIを利用すると言うことは、何らかの原因でAPIが利用できないとテストに失敗します。例えばAPIの利用回数が多いなど、何らかの理由で制限される可能性があります。
メール取得ができなくても問題ないテストにするべきです
できれば、メール取得ができなくても大部分のテストが実施できるにするのが望ましいです。
上に書いたように、テストコード中でメール取得をすると実行時間が延びたり、テストが不安定になったりします。それを踏まえると、メール取得はあくまでおまけとしての立ち位置がベストです。例えば、メール取得ができない場合(例えばcredentials.jsonがないときなど)は、メールの内容を検証するテストをスキップできるようにしておくと佳いと思います。
そのため、少なくともあらゆる箇所でメールを取得しなければならないテストコードは避けるべきです。
最後に
Gmailを取得する方法は他にもいろいろありそうです。例えば、
- Gmailの取得に特化した外部のライブラリを使う
- Gmailにログイン済みのブラウザをPlaywrightで操作してスクレイピングする方法
- IMAPを使ってメールを受信する方法
などのアイデアがありますが、実装の簡単さや環境依存性を考慮して上の実装にしました。実用するのであれば、メールをちゃんとパースするとか、エラーハンドリングするとか、連続でAPIを発行しないようスロットリングするとかいろいろ考慮した方が良さそうです。
先々月のPlaywrightの紹介記事と一転して、この記事はGmailのAPIを呼び出す単なるTypeScriptのコードになってしまいました。極論ですが、Playwrightはプログラミングで可能なことなら何でもできます。Gmailに限らずいろいろな応用できるのが魅力的ですね。