コンテンツにスキップ
  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 キー をインストルメンテーション引数として渡します:

    ./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 レベル 26 が必要です。それ未満ではコアライブラリの 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" のいずれかを返します。1 人 1 回のオファーには oneTime = true を追加します。recordConsent(address, "declined", prechecked = false) はアカウントが同意した内容を記録するため、checkEmail はペルソナが住む地域のルールに沿ってマーケティングを判定します。

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

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

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 を使う場合は、アクションが開始した実行を別のインストルメンテーション引数として渡し、各テストに名前を付けます。これで、各メールがどのテストから来たかがレポートに示されます:

./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 チームに質問を送ることもできます。チームからの返信は英語です。