跳转到内容
  1. 把这个文件添加到你的 androidTest 源码中。它只用到 Android 自带的 HttpURLConnection 和 org.json,所以无需在 Gradle 中添加任何东西:

    ProxyMailQA.kt
    package com.example.app.test // Your androidTest package.
    import androidx.test.platform.app.InstrumentationRegistry
    import org.json.JSONArray
    import org.json.JSONObject
    import java.net.HttpURLConnection
    import java.net.URL
    import java.net.URLEncoder
    import java.time.Instant
    /**
    * Disposable inboxes for Android UI tests (Espresso or UI Automator): 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/espresso
    *
    * The API key comes from the test run as an instrumentation argument, never
    * from the app: ./gradlew connectedAndroidTest
    * -Pandroid.testInstrumentationRunnerArguments.proxymailQaApiKey=$PROXYMAIL_QA_API_KEY
    * Add proxymailQaRun=$PROXYMAIL_QA_RUN the same way to add the calls to a run
    * (the GitHub Action sets it), and pass `test` to name the test they belong to.
    * Uses only what Android ships (HttpURLConnection, org.json). Call it from the
    * test thread, not the main thread.
    */
    class ProxyMailQA(
    private val apiKey: String = InstrumentationRegistry.getArguments().getString("proxymailQaApiKey")
    ?: error("Pass -Pandroid.testInstrumentationRunnerArguments.proxymailQaApiKey=... to Gradle."),
    private val baseUrl: String = "https://api.proxymail.qa/v1",
    private val run: String? = InstrumentationRegistry.getArguments().getString("proxymailQaRun"),
    private val test: String? = null,
    ) {
    data class Address(val address: String, val password: String?)
    /** An account checked out of the pool: nobody else gets it until it's returned or [until] passes. */
    data class Account(val address: String, val password: String?, val tags: List<String>, val token: String, val until: String)
    class Failure(message: String, val status: Int) : IllegalStateException(message)
    /**
    * A new test address, with a password for the sign-up form. [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.
    */
    fun createAddress(environment: String? = null, age: String? = null, country: String? = null): Address {
    val body = JSONObject().put("password", true).put("persona", true)
    .put("environment", environment).put("age", age).put("country", country)
    val json = send("POST", "/addresses", body.toString())
    return Address(json.getString("address"), json.optString("password").ifEmpty { null })
    }
    /**
    * The code from the newest email received since [since], waiting up to 30 seconds.
    * [unused] = true skips a code already used, for a second code. [type] = "coupon" waits for
    * a coupon or promo code instead (after signing up for an offer), "any" for either.
    */
    fun waitForCode(address: String, since: Instant, unused: Boolean = false, type: String? = null): String =
    waitFor("code", address, since, type, unused = unused)
    /**
    * 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.
    */
    fun waitForLink(address: String, since: Instant, type: String = "verify", app: Boolean? = null): String =
    waitFor("link", address, since, type, app)
    /** How many emails arrived since [since], to check the app sends exactly one. */
    fun countEmails(address: String, since: Instant): Int =
    send("GET", "/addresses/${encode(address)}/count?since=${encode(since.toString())}").getInt("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 App Link domain.
    */
    fun checkEmail(address: String, since: Instant, expectHost: String? = null): List<String> {
    val query = "since=" + encode(since.toString()) + (expectHost?.let { "&expect_host=" + encode(it) } ?: "")
    return strings(send("GET", "/addresses/${encode(address)}/checks?$query").getJSONArray("failed"))
    }
    /**
    * Returns if no email arrives within [timeout] seconds (at most 30), such as for an unsubscribed
    * user; throws, saying what came, as soon as one does. [category] = "marketing" watches only for
    * offers and newsletters, so receipts can still arrive.
    */
    fun expectNoEmail(address: String, since: Instant, timeout: Int = 30, category: String? = null) {
    send("GET", "/addresses/${encode(address)}/none?since=${encode(since.toString())}&timeout=$timeout" + (category?.let { "&category=$it" } ?: ""))
    }
    /**
    * 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.
    */
    fun signInAccount(tags: List<String> = emptyList(), environment: String? = null): Address? {
    val body = JSONObject().put("tags", JSONArray(tags)).put("environment", environment)
    val json = try {
    send("POST", "/addresses/sign-in", body.toString())
    } catch (e: Failure) {
    if (e.status == 404) return null else throw e
    }
    return Address(json.getString("address"), json.optString("password").ifEmpty { null })
    }
    /**
    * 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. Give it back with [returnAccount].
    */
    fun checkOutAccount(tags: List<String>, minutes: Int = 10): Account {
    val json = send("POST", "/addresses/checkout", JSONObject().put("tags", JSONArray(tags)).put("minutes", minutes).toString())
    val address = json.getString("address")
    val password = try {
    send("GET", "/addresses/${encode(address)}/password").getString("secret")
    } catch (e: Failure) {
    if (e.status == 403 || e.status == 404) null else throw e
    }
    val hold = json.getJSONObject("checkout")
    return Account(address, password, strings(json.getJSONArray("tags")), hold.getString("token"), hold.getString("until"))
    }
    /** Gives a borrowed account back, recording what the test changed (tags to add and remove). */
    fun returnAccount(account: Account, addTags: List<String> = emptyList(), removeTags: List<String> = emptyList()) {
    val body = JSONObject().put("token", account.token).put("add_tags", JSONArray(addTags)).put("remove_tags", JSONArray(removeTags))
    send("POST", "/addresses/${encode(account.address)}/return", body.toString())
    }
    /**
    * 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.
    */
    fun recordConsent(address: String, marketing: String, prechecked: Boolean? = null, doubleOptIn: String? = null) {
    val body = JSONObject().put("marketing", marketing)
    prechecked?.let { body.put("prechecked", it) }
    doubleOptIn?.let { body.put("double_opt_in", it) }
    send("PUT", "/addresses/${encode(address)}/consent", body.toString())
    }
    /**
    * After applying a coupon at checkout in the app (Google Play's test cards 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. Returns the coupon_result verdict: "pass", "warn" or "fail".
    */
    fun recordCouponResult(address: String, result: String, code: String? = null, message: String? = null, oneTime: Boolean = false): String {
    val body = JSONObject().put("result", result).put("one_time", oneTime)
    code?.let { body.put("code", it) }
    message?.let { body.put("message", it) }
    val recorded = send("POST", "/addresses/${encode(address)}/coupon-result", body.toString())
    return recorded.optJSONObject("check")?.optString("status") ?: "skip"
    }
    private fun waitFor(kind: String, address: String, since: Instant, type: String? = null, app: Boolean? = null, unused: Boolean = false): String {
    val query = "since=" + encode(since.toString()) + (type?.let { "&type=$it" } ?: "") + (app?.let { "&app=$it" } ?: "") +
    (if (unused) "&unused=true" else "")
    return send("GET", "/addresses/${encode(address)}/$kind?$query").getString("value")
    }
    private fun strings(array: JSONArray) = List(array.length()) { array.getString(it) }
    // Addresses can hold characters such as ' + / ?, so encode them all.
    private fun encode(value: String) = URLEncoder.encode(value, "UTF-8").replace("+", "%20")
    private fun send(method: String, path: String, body: String? = null): JSONObject {
    val connection = URL(baseUrl + path).openConnection() as HttpURLConnection
    connection.requestMethod = method
    connection.connectTimeout = 10_000
    connection.readTimeout = 40_000
    connection.setRequestProperty("Authorization", "Bearer $apiKey")
    connection.setRequestProperty("Content-Type", "application/json")
    if (!run.isNullOrEmpty()) {
    connection.setRequestProperty("Proxy-Mail-QA-Run", run)
    // Percent-encoded: a test's name can hold characters headers can't.
    if (test != null) connection.setRequestProperty("Proxy-Mail-QA-Test", encode(test))
    }
    if (body != null) {
    connection.doOutput = true
    connection.outputStream.use { it.write(body.toByteArray()) }
    }
    val ok = connection.responseCode in 200..299
    val text = (if (ok) connection.inputStream else connection.errorStream).bufferedReader().use { it.readText() }
    val json = JSONObject(text)
    // A wait that finds nothing says why: read the hint before retrying.
    if (!ok) throw Failure(listOf(json.optString("message"), json.optString("hint")).filter { it.isNotEmpty() }.joinToString(" "), connection.responseCode)
    return json
    }
    }
  2. 运行测试时,把 API 密钥 作为 instrumentation 参数传入:

    ./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.proxymailQaApiKey=$PROXYMAIL_QA_API_KEY
  3. 一个注册测试:

    SignUpTest.kt
    @RunWith(AndroidJUnit4::class)
    class SignUpTest {
    @get:Rule val activity = ActivityScenarioRule(MainActivity::class.java)
    @Test fun signUpSendsAWorkingCode() {
    val qa = ProxyMailQA()
    val account = qa.createAddress()
    val submitted = Instant.now()
    onView(withHint("Email")).perform(typeText(account.address))
    onView(withHint("Password")).perform(typeText(account.password!!), closeSoftKeyboard())
    onView(withText("Sign up")).perform(click())
    val code = qa.waitForCode(account.address, submitted)
    onView(withHint("Code")).perform(typeText(code))
    }
    }
  4. 对于链接,在应用中打开它,就像在邮件应用中点击它一样:

    如果邮件同时包含网页链接和打开应用的链接(myapp://…),qa.waitForLink(account.address, submitted, app = true) 会获取打开应用的链接。java.time.Instant 需要 API level 26,低于这个版本时需要 core library desugaring。

    val link = qa.waitForLink(account.address, submitted)
    val context = InstrumentationRegistry.getInstrumentation().targetContext
    context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))

检查邮件本身,而不只是验证码

Section titled “检查邮件本身,而不只是验证码”
assertEquals(1, qa.countEmails(account.address, submitted))
assertEquals(emptyList<String>(), qa.checkEmail(account.address, submitted, expectHost = "links.example.com"))

checkEmail 返回邮件未通过的检查:SPF、DKIM 和 DMARC,指向 localhost 或普通 http 的链接,链接指向哪里(expectHost,即你的 App Links 使用的域名),注明的有效期、邮件大小等(每项检查的含义)。expectNoEmail(address, since) 只在什么都没收到时才通过,用于“已退订的用户什么都收不到”;加上 category = "marketing" 可以只关注营销邮件,这样收据仍然可以到达。重新发送后,waitForCode(address, submitted, unused = true) 会跳过已经用过的验证码。注册某个优惠后,waitForCode(address, submitted, type = "coupon") 会改为等待它的优惠券或促销码。在结账时使用后(使用 Google Play 的测试卡或应用的测试模式,绝不要用真实银行卡),recordCouponResult(address, "accepted", message = shownText) 会记录应用的处理结果,并返回 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 = listOf("verified")) 返回一个已保存的测试账号及其密码,没有合适的账号时返回 null。当测试并行运行时,checkOutAccount(listOf("verified")) 会借出一个其他测试都没在用的账号及其密码,直到你归还:

val account = qa.checkOutAccount(listOf("verified"))
try {
// Sign in with account.address and account.password.
} finally {
qa.returnAccount(account)
}

两者都会读取已保存的密码,所以密钥需要 Can read saved passwords 权限(Project settings → API keys)。按账号在应用中的状态给它们打标签,就能组成账号池:参见账号池。

如果任务中用了 GitHub Action,把它启动的运行作为另一个 instrumentation 参数传入,并给每个测试命名,这样报告就能说明每封邮件来自哪个测试:

./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.proxymailQaApiKey=$PROXYMAIL_QA_API_KEY -Pandroid.testInstrumentationRunnerArguments.proxymailQaRun=$PROXYMAIL_QA_RUN
@get:Rule val name = TestName()
@Test fun signUpSendsAWorkingCode() {
val qa = ProxyMailQA(test = name.methodName)
// …
}

对本页有疑问?

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