Android (Espresso)
Add this file to your androidTest sources. It uses only HttpURLConnection and org.json, which Android ships, so there’s nothing to add to Gradle:
package com.example.app.test // Your androidTest package.
import androidx.test.platform.app.InstrumentationRegistryimport org.json.JSONArrayimport org.json.JSONObjectimport java.net.HttpURLConnectionimport java.net.URLimport java.net.URLEncoderimport 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. */ fun waitForCode(address: String, since: Instant, unused: Boolean = false): String = waitFor("code", address, since, 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. */ fun expectNoEmail(address: String, since: Instant, timeout: Int = 30) { send("GET", "/addresses/${encode(address)}/none?since=${encode(since.toString())}&timeout=$timeout") }
/** * 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()) }
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 }}Pass the API key as an instrumentation argument when the tests run:
./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.proxymailQaApiKey=$PROXYMAIL_QA_API_KEYA sign-up test:
@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)) }}For a link, open it in the app, the way tapping it in an email app would:
val link = qa.waitForLink(account.address, submitted)val context = InstrumentationRegistry.getInstrumentation().targetContextcontext.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))If the email has both a web link and one that opens the app (myapp://…), qa.waitForLink(account.address, submitted, app = true) gets the app one. java.time.Instant needs API level 26, or core library desugaring below that.
Check the email, not just the code
Section titled “Check the email, not just the code”assertEquals(1, qa.countEmails(account.address, submitted))assertEquals(emptyList<String>(), qa.checkEmail(account.address, submitted, expectHost = "links.example.com"))checkEmail returns the checks the email fails: SPF, DKIM and DMARC, links to localhost or plain http, where its link goes (expectHost, the domain your App Links use), a stated expiry, its size and more (what each check means). expectNoEmail(address, since) passes only if nothing arrives, for “an unsubscribed user gets nothing”. After a resend, waitForCode(address, submitted, unused = true) skips the code already used.
createAddress(environment = "staging", age = "under18", country = "GB") records which environment the account is for and gives its persona a date of birth and a country, for forms that ask.
Sign in as a saved account, or borrow one
Section titled “Sign in as a saved account, or borrow one”For a sign-in test, signInAccount(tags = listOf("verified")) returns a saved test account and its password, or null when none fits. When tests run in parallel, checkOutAccount(listOf("verified")) lends one no other test has, with its password, until you give it back:
val account = qa.checkOutAccount(listOf("verified"))try { // Sign in with account.address and account.password.} finally { qa.returnAccount(account)}Both read saved passwords, so the key needs Can read saved passwords (Project settings → API keys). Tag accounts with their state in the app to make pools: see an account pool.
A report on the pull request
Section titled “A report on the pull request”With the GitHub Action in the job, pass the run it starts as another instrumentation argument, and name each test, so the report says which test each email came from:
./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) // …}