---
title: "Android (Espresso) | Proxy Mail QA"
description: "Test email verification codes and magic links in Android Espresso or UI Automator tests with disposable inboxes and a small Kotlin helper."
url: https://proxymail.qa/docs/espresso
---

# 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:

ProxyMailQA.kt

```kotlin
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.
     */
    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](https://proxymail.qa/docs/api-keys) as an instrumentation argument when the tests run:

Terminal window

```bash
./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.proxymailQaApiKey=$PROXYMAIL_QA_API_KEY
```

A sign-up test:

SignUpTest.kt

```kotlin
@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:

```kotlin
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))
```

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

```kotlin
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](https://proxymail.qa/docs/rest-api#checking-an-emails-sending-and-build)). `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

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:

```kotlin
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](https://proxymail.qa/docs/rest-api#an-account-pool-for-parallel-tests).

## A report on the pull request

With the [GitHub Action](https://proxymail.qa/docs/ci#a-report-on-the-pull-request) 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:

Terminal window

```bash
./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.proxymailQaApiKey=$PROXYMAIL_QA_API_KEY -Pandroid.testInstrumentationRunnerArguments.proxymailQaRun=$PROXYMAIL_QA_RUN
```

```kotlin
@get:Rule val name = TestName()

@Test fun signUpSendsAWorkingCode() {
    val qa = ProxyMailQA(test = name.methodName)
    // …
}
```

[Previous iOS (XCUITest)](https://proxymail.qa/docs/xcuitest.html) [Next Flutter](https://proxymail.qa/docs/flutter.html)
