Detox 和 Appium
本页由英文自动翻译。
阅读英文版添加 helper
Section titled “添加 helper”把这个文件添加到你的测试中(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 });设置 API 密钥
Section titled “设置 API 密钥”在运行测试的环境中,用你的 CI 的 secrets 设置
PROXYMAIL_QA_API_KEY。
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 });Appium (WebdriverIO)
Section titled “Appium (WebdriverIO)”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' }); // Androidawait 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)。按账号在应用中的状态给它们打标签,就能组成账号池:参见账号池。
pull request 上的报告
Section titled “pull request 上的报告”如果任务中用了 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 团队,他们会用英文回复。