コンテンツにスキップ
  1. このファイルをテストに追加します(Node 18 以降)。使うのは fetch だけです:

    proxymail-qa.js
    // Disposable inboxes for Detox and Appium tests (Node 18 or later): create a
    // test address, sign up with it in your app, then wait for the code or link
    // from the email it gets, and check the email itself.
    // Guide: https://proxymail.qa/docs/detox-appium
    //
    // The API key comes from the test run's environment (PROXYMAIL_QA_API_KEY),
    // never from the app. With PROXYMAIL_QA_RUN set (the GitHub Action sets it),
    // the calls add to that run, named by test: Jest's (and so Detox's) current
    // test, or the name given to setTest.
    const BASE_URL = process.env.PROXYMAIL_QA_API_URL ?? 'https://api.proxymail.qa/v1';
    let testName;
    async function send(method, path, body) {
    const key = process.env.PROXYMAIL_QA_API_KEY;
    if (!key) throw new Error('Set PROXYMAIL_QA_API_KEY for the test run.');
    const run = process.env.PROXYMAIL_QA_RUN;
    const test = testName ?? globalThis.expect?.getState?.().currentTestName;
    const response = await fetch(BASE_URL + path, {
    method,
    headers: {
    Authorization: `Bearer ${key}`,
    'Content-Type': 'application/json',
    // Percent-encoded: a test's name can hold characters headers can't.
    ...(run ? { 'Proxy-Mail-QA-Run': run, ...(test ? { 'Proxy-Mail-QA-Test': encodeURIComponent(test) } : {}) } : {}),
    },
    body: body && JSON.stringify(body),
    signal: AbortSignal.timeout(40_000),
    });
    const json = await response.json();
    // A wait that finds nothing says why: read the hint before retrying.
    if (!response.ok) throw Object.assign(new Error([json.message, json.hint].filter(Boolean).join(' ')), { status: response.status });
    return json;
    }
    const at = (address, path, query = {}) => `/addresses/${encodeURIComponent(address)}/${path}?${new URLSearchParams(query)}`;
    /** The test the next calls belong to, in the run's report. Under Jest, the current test is used without it. */
    export const setTest = (name) => {
    testName = name;
    };
    /**
    * A new test address, with a password for the sign-up form: { address, password }. Options:
    * environment (such as 'staging') records where it's used; age ('adult', 'under18', 'under13'…) and
    * country ('GB') give the persona a date of birth and a country.
    */
    export const createAddress = ({ environment, age, country } = {}) => send('POST', '/addresses', { password: true, persona: true, environment, age, country });
    const waitFor = async (kind, address, since, query) => (await send('GET', at(address, kind, { since: since.toISOString(), ...query }))).value;
    /**
    * The code from the newest email received since `since`, waiting up to 30 seconds. `unused: true` skips a code
    * already used. `type: 'coupon'` waits for a coupon or promo code instead (after signing up for an offer), 'any' for either.
    */
    export const waitForCode = (address, since, unused = false, type) =>
    waitFor('code', address, since, { ...(unused ? { unused: 'true' } : {}), ...(type ? { type } : {}) });
    /**
    * The link from the newest email received since `since`: verify, magic_signin, invite, reset or any.
    * `app: true` asks for the link that opens the app (myapp://…) when the email also has a web one.
    */
    export const waitForLink = (address, since, type = 'verify', app) => waitFor('link', address, since, { type, ...(app === undefined ? {} : { app: String(app) }) });
    /** How many emails arrived since `since`, to check the app sends exactly one. */
    export const countEmails = async (address, since) => (await send('GET', at(address, 'count', { since: since.toISOString() }))).count;
    /**
    * The checks the newest email since `since` fails: SPF, DKIM, DMARC, links to localhost or plain http,
    * a stated expiry, its size and more. Empty when it passes. `expectHost` is the domain its link should
    * open, such as your app's Universal Link or App Link domain.
    */
    export const checkEmail = async (address, since, expectHost) =>
    (await send('GET', at(address, 'checks', { since: since.toISOString(), ...(expectHost ? { expect_host: expectHost } : {}) }))).failed;
    /**
    * Resolves if no email arrives within `timeout` seconds (at most 30); throws, saying what came, as soon as one does.
    * `category: 'marketing'` watches only for offers and newsletters, so receipts can still arrive.
    */
    export const expectNoEmail = async (address, since, timeout = 30, category) => {
    await send('GET', at(address, 'none', { since: since.toISOString(), timeout: String(timeout), ...(category ? { category } : {}) }));
    };
    /**
    * A saved test account to sign in with, and its password: one with every tag given that no test has
    * checked out, the one that got mail most recently first. null when none fits. Needs an API key
    * allowed to read saved passwords.
    */
    export const signInAccount = async ({ tags = [], environment } = {}) => {
    try {
    return await send('POST', '/addresses/sign-in', { tags, environment });
    } catch (err) {
    if (err.status === 404) return null;
    throw err;
    }
    };
    /**
    * Borrows an account with every tag given (verified, premium…) that no other test has, for `minutes`,
    * with its saved password when the key may read it: { address, password, tags, checkout }. Give it
    * back with returnAccount.
    */
    export const checkOutAccount = async (tags, minutes = 10) => {
    const account = await send('POST', '/addresses/checkout', { tags, minutes });
    try {
    account.password = (await send('GET', at(account.address, 'password'))).secret;
    } catch (err) {
    if (err.status !== 403 && err.status !== 404) throw err;
    }
    return account;
    };
    /** Gives a borrowed account back, recording what the test changed (tags to add and remove). */
    export const returnAccount = (account, { addTags = [], removeTags = [] } = {}) =>
    send('POST', `/addresses/${encodeURIComponent(account.address)}/return`, { token: account.checkout.token, add_tags: addTags, remove_tags: removeTags });
    /**
    * After applying a coupon at checkout in the app (the store's sandbox or the app's test mode, never a real card),
    * records what happened: 'accepted', 'rejected' or 'error', with the app's message. `code` is the coupon applied
    * (default: the newest at the address); `oneTime` marks a one-per-person offer. Resolves to the coupon_result
    * verdict: 'pass', 'warn' or 'fail'.
    */
    export const recordCouponResult = async (address, result, { code, message, oneTime = false } = {}) =>
    (await send('POST', `/addresses/${encodeURIComponent(address)}/coupon-result`, { result, code, message, one_time: oneTime })).check?.status ?? 'skip';
    /**
    * Records what the account agreed to at sign-up: `marketing` 'opted_in' or 'declined', `prechecked` when the box was
    * ticked as the screen loaded, and `doubleOptIn` 'confirmed' or 'pending'. The rules where the persona lives then
    * judge the marketing it gets.
    */
    export const recordConsent = (address, marketing, { prechecked, doubleOptIn } = {}) =>
    send('PUT', `/addresses/${encodeURIComponent(address)}/consent`, { marketing, prechecked, double_opt_in: doubleOptIn });
  2. テストを実行する環境で、CI のシークレットから PROXYMAIL_QA_API_KEY を設定します。

e2e/signup.test.js
import { createAddress, waitForCode, waitForLink } from './proxymail-qa.js';
it('sends a working code', async () => {
const { address, password } = await createAddress();
await device.launchApp();
const submitted = new Date();
await element(by.id('email')).typeText(address);
await element(by.id('password')).typeText(password);
await element(by.text('Sign up')).tap();
const code = await waitForCode(address, submitted);
await element(by.id('code')).typeText(code);
});

確認リンクやマジックリンクの場合は、サインアップ後にアプリで開きます。メールにウェブ用のリンクとアプリを開くリンク(myapp://…)の両方がある場合、アプリ用のリンクを使うには 4 番目の引数に true を渡します:

const link = await waitForLink(address, submitted, 'verify', true);
await device.openURL({ url: link });
test/signup.e2e.js
import { createAddress, waitForCode, waitForLink } from './proxymail-qa.js';
it('sends a working code', async () => {
const { address, password } = await createAddress();
const submitted = new Date();
await $('~email').setValue(address);
await $('~password').setValue(password);
await $('~Sign up').click();
const code = await waitForCode(address, submitted);
await $('~code').setValue(code);
});

確認リンクやマジックリンクの場合は、サインアップ後にアプリで開きます:

const link = await waitForLink(address, submitted);
await driver.execute('mobile: deepLink', { url: link, package: 'com.example.app' }); // Android
await driver.execute('mobile: deepLink', { url: link, bundleId: 'com.example.app' }); // iOS

コードだけでなくメールもチェックする

Section titled “コードだけでなくメールもチェックする”
import { checkEmail, countEmails } from './proxymail-qa.js';
expect(await countEmails(address, submitted)).toBe(1);
expect(await checkEmail(address, submitted, 'links.example.com')).toEqual([]);

checkEmail は、メールが不合格になったチェックを返します。SPF、DKIM、DMARC、localhost や平文の http へのリンク、リンク先(3 番目の引数は、アプリのリンクが使うドメイン)、記載された有効期限、サイズなどです(各チェックの意味)。expectNoEmail(address, since) は何も届かない場合にのみ解決し、「配信停止したユーザーには何も届かない」ことを確認できます。4 番目の引数に 'marketing' を渡すとマーケティングメールだけを監視するため、領収書は届いてもかまいません。再送後は、waitForCode(address, submitted, true) が使用済みのコードをスキップします。オファーに登録した後は、waitForCode(address, submitted, false, 'coupon') が代わりにクーポンやプロモーションコードを待ちます。チェックアウトで適用した後(ストアのサンドボックスやアプリのテストモードを使い、実際のカードは使わないでください)、recordCouponResult(address, 'accepted', { message }) がアプリの動作を記録し、coupon_result の判定 'pass'、'warn'、'fail' のいずれかで解決します。1 人 1 回のオファーには oneTime: true を追加します。recordConsent(address, 'declined', { prechecked: false }) はアカウントが同意した内容を記録するため、checkEmail はペルソナが住む地域のルールに沿ってマーケティングを判定します。

createAddress({ environment: 'staging', age: 'under18', country: 'GB' }) は、アカウントがどの環境用かを記録し、入力を求めるフォーム向けに、ペルソナに生年月日と国を設定します。

保存したアカウントでサインインする、または借りる

Section titled “保存したアカウントでサインインする、または借りる”

サインインのテストでは、signInAccount({ tags: ['verified'] }) が保存済みのテスト用アカウントとそのパスワードを返し、合うものがなければ null を返します。テストを並列で実行する場合、checkOutAccount(['verified']) は他のテストが使っていないアカウントをパスワード付きで、返却されるまで貸し出します:

const account = await checkOutAccount(['verified']);
try {
// Sign in with account.address and account.password.
} finally {
await returnAccount(account);
}

どちらも保存済みのパスワードを読むため、キーには Can read saved passwords 権限が必要です(Project settings → API keys)。アプリでの状態をアカウントにタグ付けするとプールを作れます。アカウントプール を参照してください。

ジョブで GitHub Action を使う場合は、テストにも PROXYMAIL_QA_RUN を設定します(アクションはその後のステップにだけ設定します)。Detox が使う Jest では、各呼び出しに現在のテスト名が付きます。WebdriverIO では、フックで自分で名前を付けます:

import { setTest } from './proxymail-qa.js';
beforeEach(function () {
setTest(this.currentTest.fullTitle());
});

このページについて質問がありますか?

質問はどの言語でもかまいません。ドキュメントからすぐに回答を得ることも、Proxy Mail QA チームに質問を送ることもできます。チームからの返信は英語です。