Zum Inhalt springen
  1. Füge diese Datei zu deinen androidTest-Quellen hinzu. Sie nutzt nur HttpURLConnection und org.json, die Android mitbringt, also musst du in Gradle nichts hinzufügen:

    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. Übergib den API-Schlüssel als Instrumentierungsargument, wenn die Tests laufen:

    ./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.proxymailQaApiKey=$PROXYMAIL_QA_API_KEY
  3. Ein Registrierungstest:

    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. Einen Link öffnest du in der App, so wie ein Tippen darauf in einer E-Mail-App es tun würde:

    Wenn die E-Mail sowohl einen Weblink als auch einen Link zum Öffnen der App hat (myapp://…), bekommt qa.waitForLink(account.address, submitted, app = true) den App-Link. java.time.Instant braucht API-Level 26 oder darunter 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))
assertEquals(1, qa.countEmails(account.address, submitted))
assertEquals(emptyList<String>(), qa.checkEmail(account.address, submitted, expectHost = "links.example.com"))

checkEmail gibt die Prüfungen zurück, die die E-Mail nicht besteht: SPF, DKIM und DMARC, Links auf localhost oder einfaches http, wohin ihr Link führt (expectHost, die Domain, die deine App Links nutzen), eine angegebene Ablaufzeit, ihre Größe und mehr (was jede Prüfung bedeutet). expectNoEmail(address, since) besteht nur, wenn nichts ankommt, für „ein abgemeldeter Nutzer bekommt nichts“; füge category = "marketing" hinzu, um nur auf Marketing zu achten, damit Belege weiterhin ankommen können. Nach einem erneuten Senden überspringt waitForCode(address, submitted, unused = true) den bereits genutzten Code. Nach der Anmeldung für ein Angebot wartet waitForCode(address, submitted, type = "coupon") stattdessen auf dessen Gutschein- oder Promo-Code. Nachdem du ihn im Checkout angewendet hast (mit den Testkarten von Google Play oder im Testmodus der App, nie mit einer echten Karte), erfasst recordCouponResult(address, "accepted", message = shownText), was die App getan hat, und gibt das Ergebnis coupon_result, "pass", "warn" oder "fail" zurück; füge oneTime = true für ein Angebot hinzu, das nur einmal pro Person gilt. recordConsent(address, "declined", prechecked = false) erfasst, wozu das Konto zugestimmt hat, sodass checkEmail sein Marketing nach den Regeln am Wohnort der Persona bewertet.

createAddress(environment = "staging", age = "under18", country = "GB") erfasst, für welche Umgebung das Konto ist, und gibt seiner Persona ein Geburtsdatum und ein Land, für Formulare, die danach fragen.

Mit einem gespeicherten Konto anmelden oder eines ausleihen

Abschnitt betitelt „Mit einem gespeicherten Konto anmelden oder eines ausleihen“

Für einen Anmeldetest gibt signInAccount(tags = listOf("verified")) ein gespeichertes Testkonto und sein Passwort zurück oder null, wenn keines passt. Wenn Tests parallel laufen, leiht checkOutAccount(listOf("verified")) eines aus, das kein anderer Test hat, mit seinem Passwort, bis du es zurückgibst:

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

Beide lesen gespeicherte Passwörter, also braucht der Schlüssel die Berechtigung Can read saved passwords (Project settings → API keys). Markiere Konten in der App mit ihrem Zustand, um Pools zu bilden: siehe Konto-Pools.

Mit der GitHub Action im Job übergibst du den Lauf, den sie startet, als weiteres Instrumentierungsargument und benennst jeden Test, damit der Bericht sagt, von welchem Test jede E-Mail kam:

./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)
// …
}

Eine Frage zu dieser Seite?

Frag in jeder Sprache. Die Doku kann dir sofort antworten oder deine Frage an das Team von Proxy Mail QA schicken, das auf Englisch antwortet.

Doku fragen