Android (Espresso)
英語から自動翻訳されています。
英語で読むヘルパーを追加する
Section titled “ヘルパーを追加する”このファイルを
androidTestのソースに追加します。使うのは Android に含まれるHttpURLConnectionとorg.jsonだけなので、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}}API キー を渡す
Section titled “API キー を渡す”テストの実行時に、API キー をインストルメンテーション引数として渡します:
./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.proxymailQaApiKey=$PROXYMAIL_QA_API_KEYサインアップのテストを書く
Section titled “サインアップのテストを書く”サインアップのテスト:
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))}}またはリンクを開く
Section titled “またはリンクを開く”リンクの場合は、メールアプリでタップしたときと同じようにアプリで開きます:
メールにウェブ用のリンクとアプリを開くリンク(
myapp://…)の両方がある場合、qa.waitForLink(account.address, submitted, app = true)はアプリ用のリンクを取得します。java.time.Instantには API レベル 26 が必要です。それ未満ではコアライブラリの desugaring が必要です。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))
コードだけでなくメールもチェックする
Section titled “コードだけでなくメールもチェックする”assertEquals(1, qa.countEmails(account.address, submitted))assertEquals(emptyList<String>(), qa.checkEmail(account.address, submitted, expectHost = "links.example.com"))checkEmail は、メールが不合格になったチェックを返します。SPF、DKIM、DMARC、localhost や平文の http へのリンク、リンク先(expectHost は App Links が使うドメイン)、記載された有効期限、サイズなどです(各チェックの意味)。expectNoEmail(address, since) は何も届かない場合にのみ成功し、「配信停止したユーザーには何も届かない」ことを確認できます。category = "marketing" を追加するとマーケティングメールだけを監視するため、領収書は届いてもかまいません。再送後は、waitForCode(address, submitted, unused = true) が使用済みのコードをスキップします。オファーに登録した後は、waitForCode(address, submitted, type = "coupon") が代わりにクーポンやプロモーションコードを待ちます。チェックアウトで適用した後(Google Play のテストカードやアプリのテストモードを使い、実際のカードは使わないでください)、recordCouponResult(address, "accepted", message = shownText) がアプリの動作を記録し、coupon_result の判定 "pass"、"warn"、"fail" のいずれかを返します。1 人 1 回のオファーには oneTime = true を追加します。recordConsent(address, "declined", prechecked = false) はアカウントが同意した内容を記録するため、checkEmail はペルソナが住む地域のルールに沿ってマーケティングを判定します。
createAddress(environment = "staging", age = "under18", country = "GB") は、アカウントがどの環境用かを記録し、入力を求めるフォーム向けに、ペルソナに生年月日と国を設定します。
保存したアカウントでサインインする、または借りる
Section titled “保存したアカウントでサインインする、または借りる”サインインのテストでは、signInAccount(tags = listOf("verified")) が保存済みのテスト用アカウントとそのパスワードを返し、合うものがなければ null を返します。テストを並列で実行する場合、checkOutAccount(listOf("verified")) は他のテストが使っていないアカウントをパスワード付きで、返却されるまで貸し出します:
val account = qa.checkOutAccount(listOf("verified"))try { // Sign in with account.address and account.password.} finally { qa.returnAccount(account)}どちらも保存済みのパスワードを読むため、キーには Can read saved passwords 権限が必要です(Project settings → API keys)。アプリでの状態をアカウントにタグ付けするとプールを作れます。アカウントプール を参照してください。
プルリクエストへのレポート
Section titled “プルリクエストへのレポート”ジョブで GitHub Action を使う場合は、アクションが開始した実行を別のインストルメンテーション引数として渡し、各テストに名前を付けます。これで、各メールがどのテストから来たかがレポートに示されます:
./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) // …}このページについて質問がありますか?
質問はどの言語でもかまいません。ドキュメントからすぐに回答を得ることも、Proxy Mail QA チームに質問を送ることもできます。チームからの返信は英語です。