Android (Espresso)
Traduit automatiquement de l’anglais.
Lire en anglaisAjouter le helper
Section intitulée « Ajouter le helper »Ajoutez ce fichier à vos sources
androidTest. Il n’utilise queHttpURLConnectionetorg.json, fournis avec Android, donc rien à ajouter à 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}}Passer la clé d’API
Section intitulée « Passer la clé d’API »Passez la clé d’API comme argument d’instrumentation lors de l’exécution des tests :
./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.proxymailQaApiKey=$PROXYMAIL_QA_API_KEYÉcrire un test d’inscription
Section intitulée « Écrire un test d’inscription »Un test d’inscription :
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))}}Ou ouvrir un lien
Section intitulée « Ou ouvrir un lien »Pour un lien, ouvrez-le dans l’app, comme le ferait un appui dessus dans une app de messagerie :
Si l’e-mail contient à la fois un lien web et un lien qui ouvre l’app (
myapp://…),qa.waitForLink(account.address, submitted, app = true)obtient celui de l’app.java.time.Instantnécessite API niveau 26, ou le desugaring de la bibliothèque core en dessous.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))
Vérifier l’e-mail, pas seulement le code
Section intitulée « Vérifier l’e-mail, pas seulement le code »assertEquals(1, qa.countEmails(account.address, submitted))assertEquals(emptyList<String>(), qa.checkEmail(account.address, submitted, expectHost = "links.example.com"))checkEmail renvoie les vérifications que l’e-mail ne passe pas : SPF, DKIM et DMARC, liens vers localhost ou en http simple, destination de son lien (expectHost, le domaine qu’utilisent vos App Links), une expiration indiquée, sa taille et plus encore (signification de chaque vérification). expectNoEmail(address, since) ne réussit que si rien n’arrive, pour « un utilisateur désabonné ne reçoit rien » ; ajoutez category = "marketing" pour ne surveiller que le marketing, afin que les reçus puissent encore arriver. Après un renvoi, waitForCode(address, submitted, unused = true) ignore le code déjà utilisé. Après l’inscription à une offre, waitForCode(address, submitted, type = "coupon") attend plutôt son coupon ou code promo. Après l’avoir appliqué au paiement (les cartes de test de Google Play ou le mode test de l’app, jamais une vraie carte), recordCouponResult(address, "accepted", message = shownText) enregistre ce qu’a fait l’app et renvoie le verdict coupon_result, "pass", "warn" ou "fail" ; ajoutez oneTime = true pour une offre limitée à une par personne. recordConsent(address, "declined", prechecked = false) enregistre ce que le compte a accepté, pour que checkEmail juge son marketing selon les règles du lieu où vit le persona.
createAddress(environment = "staging", age = "under18", country = "GB") enregistre l’environnement auquel le compte est destiné et donne à son persona une date de naissance et un pays, pour les formulaires qui les demandent.
Se connecter avec un compte enregistré, ou en emprunter un
Section intitulée « Se connecter avec un compte enregistré, ou en emprunter un »Pour un test de connexion, signInAccount(tags = listOf("verified")) renvoie un compte de test enregistré et son mot de passe, ou null si aucun ne convient. Quand les tests tournent en parallèle, checkOutAccount(listOf("verified")) en prête un qu’aucun autre test n’a, avec son mot de passe, jusqu’à ce que vous le rendiez :
val account = qa.checkOutAccount(listOf("verified"))try { // Sign in with account.address and account.password.} finally { qa.returnAccount(account)}Les deux lisent des mots de passe enregistrés, donc la clé a besoin de l’autorisation Can read saved passwords (Project settings → API keys). Étiquetez les comptes selon leur état dans l’app pour créer des pools : voir un pool de comptes.
Un rapport sur la pull request
Section intitulée « Un rapport sur la pull request »Avec l’Action GitHub dans le job, passez l’exécution qu’elle démarre comme autre argument d’instrumentation, et nommez chaque test, pour que le rapport indique de quel test vient chaque e-mail :
./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) // …}Une question sur cette page ?
Posez votre question dans la langue de votre choix. La documentation peut y répondre tout de suite, ou vous pouvez l'envoyer à l'équipe Proxy Mail QA, qui répond en anglais.