跳转到内容
  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 的 secrets 设置 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://…),把 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 的链接,链接指向哪里(第三个参数,即你的应用链接使用的域名),注明的有效期、邮件大小等(每项检查的含义)。expectNoEmail(address, since) 只在什么都没收到时才 resolve,用于“已退订的用户什么都收不到”;把 'marketing' 作为第四个参数传入,可以只关注营销邮件,这样收据仍然可以到达。重新发送后,waitForCode(address, submitted, true) 会跳过已经用过的验证码。注册某个优惠后,waitForCode(address, submitted, false, 'coupon') 会改为等待它的优惠券或促销码。在结账时使用后(使用商店的 sandbox 或应用的测试模式,绝不要用真实银行卡),recordCouponResult(address, 'accepted', { message }) 会记录应用的处理结果,并 resolve 为 coupon_result 结论:'pass'、'warn' 或 'fail';对于每人限用一次的优惠,加上 oneTime: true。recordConsent(address, 'declined', { prechecked: false }) 记录账号同意了哪些内容,这样 checkEmail 就会按 persona 所在地的规则判断它的营销邮件。

createAddress({ environment: 'staging', age: 'under18', country: 'GB' }) 记录账号用于哪个环境,并给它的 persona 设定出生日期和国家,供需要填写这些信息的表单使用。

以已保存的账号登录,或借用一个

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(action 只为它之后的步骤设置)。在 Detox 使用的 Jest 下,每次调用都会以当前测试命名。使用 WebdriverIO 时,在 hook 中自己命名:

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

对本页有疑问?

可以用任何语言提问。文档可以立即回答,你也可以把问题发给 Proxy Mail QA 团队,他们会用英文回复。