Android (Espresso)
Traducido automáticamente del inglés.
Leer en inglésAñade el helper
Sección titulada «Añade el helper»Añade este archivo a tus fuentes de
androidTest. Solo usaHttpURLConnectionyorg.json, que incluye Android, así que no hay nada que añadir a Gradle:ProxyMailQA.kt 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. [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 HttpURLConnectionconnection.requestMethod = methodconnection.connectTimeout = 10_000connection.readTimeout = 40_000connection.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 = trueconnection.outputStream.use { it.write(body.toByteArray()) }}val ok = connection.responseCode in 200..299val 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}}Pasa la clave de API
Sección titulada «Pasa la clave de API»Pasa la clave de API como argumento de instrumentación al ejecutar las pruebas:
./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.proxymailQaApiKey=$PROXYMAIL_QA_API_KEYEscribe una prueba de registro
Sección titulada «Escribe una prueba de registro»Una prueba de registro:
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))}}O abre un enlace
Sección titulada «O abre un enlace»Para un enlace, ábrelo en la app, como lo haría tocarlo en una app de correo:
Si el correo tiene tanto un enlace web como uno que abre la app (
myapp://…),qa.waitForLink(account.address, submitted, app = true)obtiene el de la app.java.time.Instantnecesita API nivel 26, o core library desugaring por debajo de ese nivel.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))
Comprueba el correo, no solo el código
Sección titulada «Comprueba el correo, no solo el código»assertEquals(1, qa.countEmails(account.address, submitted))assertEquals(emptyList<String>(), qa.checkEmail(account.address, submitted, expectHost = "links.example.com"))checkEmail devuelve las comprobaciones que el correo no supera: SPF, DKIM y DMARC, enlaces a localhost o a http simple, adónde lleva su enlace (expectHost, el dominio que usan tus App Links), una caducidad indicada, su tamaño y más (qué significa cada comprobación). expectNoEmail(address, since) pasa solo si no llega nada, para “un usuario que canceló la suscripción no recibe nada”; añade category = "marketing" para vigilar solo el marketing, de modo que los recibos puedan seguir llegando. Después de un reenvío, waitForCode(address, submitted, unused = true) omite el código ya usado. Después de registrarte en una oferta, waitForCode(address, submitted, type = "coupon") espera su cupón o código promocional. Después de aplicarlo en el pago (las tarjetas de prueba de Google Play o el modo de prueba de la app, nunca una tarjeta real), recordCouponResult(address, "accepted", message = shownText) registra lo que hizo la app y devuelve el resultado coupon_result, "pass", "warn" o "fail"; añade oneTime = true para una oferta de una por persona. recordConsent(address, "declined", prechecked = false) registra lo que aceptó la cuenta, para que checkEmail juzgue su marketing según las normas del lugar donde vive la persona.
createAddress(environment = "staging", age = "under18", country = "GB") registra para qué entorno es la cuenta y da a su persona una fecha de nacimiento y un país, para los formularios que lo piden.
Inicia sesión con una cuenta guardada o toma una prestada
Sección titulada «Inicia sesión con una cuenta guardada o toma una prestada»Para una prueba de inicio de sesión, signInAccount(tags = listOf("verified")) devuelve una cuenta de prueba guardada y su contraseña, o null si ninguna encaja. Cuando las pruebas se ejecutan en paralelo, checkOutAccount(listOf("verified")) presta una que ninguna otra prueba tiene, con su contraseña, hasta que la devuelvas:
val account = qa.checkOutAccount(listOf("verified"))try { // Sign in with account.address and account.password.} finally { qa.returnAccount(account)}Ambas leen contraseñas guardadas, así que la clave necesita el permiso Can read saved passwords (Project settings → API keys). Etiqueta las cuentas con su estado en la app para crear grupos: consulta un grupo de cuentas.
Un informe en el pull request
Sección titulada «Un informe en el pull request»Con la Action de GitHub en el job, pasa la ejecución que inicia como otro argumento de instrumentación y ponle nombre a cada prueba, para que el informe indique de qué prueba vino cada correo:
./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) // …}¿Tienes una pregunta sobre esta página?
Pregunta en cualquier idioma. La documentación puede responderte de inmediato, o puedes enviar tu pregunta al equipo de Proxy Mail QA, que responde en inglés.