iOS (XCUITest)
英語から自動翻訳されています。
英語で読むヘルパーを追加する
Section titled “ヘルパーを追加する”このファイルを UI のテストターゲット(アプリのターゲットではありません)に追加します。必要なのは Foundation だけです。
ProxyMailQA.swift import Foundation/// Disposable inboxes for UI tests (XCUITest): 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/xcuitest////// The API key comes from the test run (an environment variable in the test/// plan or scheme, or Xcode Cloud secrets). Never build it into the app.struct ProxyMailQA {struct Address: Decodable {let address: Stringlet password: String?}/// An account checked out of the pool: nobody else gets it until it's returned or the hold runs out.struct Account: Decodable {struct Hold: Decodable { let token: String; let until: String }let address: Stringlet tags: [String]let checkout: Holdvar password: String?}struct Failure: Error, CustomStringConvertible {let description: Stringvar status = 0}var apiKey: Stringvar baseURL = URL(string: "https://api.proxymail.qa/v1")!/// The run these calls add to, for a report on the pull request: PROXYMAIL_QA_RUN,/// which the GitHub Action sets. `test` names the test they belong to.var run = ProcessInfo.processInfo.environment["PROXYMAIL_QA_RUN"]var test: String?init(apiKey: String? = ProcessInfo.processInfo.environment["PROXYMAIL_QA_API_KEY"], test: String? = nil) throws {guard let apiKey, !apiKey.isEmpty else {throw Failure(description: "Set PROXYMAIL_QA_API_KEY in the test plan or scheme (Arguments → Environment Variables).")}self.apiKey = apiKeyself.test = test}/// 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.func createAddress(environment: String? = nil, age: String? = nil, country: String? = nil) async throws -> Address {var body: [String: Any] = ["password": true, "persona": true]if let environment { body["environment"] = environment }if let age { body["age"] = age }if let country { body["country"] = country }return try JSONDecoder().decode(Address.self, from: try await post("addresses", body))}/// 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.func waitForCode(_ address: String, since: Date, unused: Bool = false, type: String? = nil) async throws -> String {try await waitFor("code", address: address, since: since, type: 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.func waitForLink(_ address: String, since: Date, type: String = "verify", app: Bool? = nil) async throws -> URL {let value = try await waitFor("link", address: address, since: since, type: type, app: app)guard let url = URL(string: value) else { throw Failure(description: "Not a link: \(value)") }return url}/// How many emails arrived since `since`, to check the app sends exactly one.func countEmails(_ address: String, since: Date) async throws -> Int {struct Count: Decodable { let count: Int }return try JSONDecoder().decode(Count.self, from: try await get(address, "count", ["since": iso(since)])).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 Universal Link domain.func checkEmail(_ address: String, since: Date, expectHost: String? = nil) async throws -> [String] {struct Checks: Decodable { let failed: [String] }var query = ["since": iso(since)]if let expectHost { query["expect_host"] = expectHost }return try JSONDecoder().decode(Checks.self, from: try await get(address, "checks", query)).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.func expectNoEmail(_ address: String, since: Date, timeout: Int = 30, category: String? = nil) async throws {var query = ["since": iso(since), "timeout": String(timeout)]if let category { query["category"] = category }_ = try await get(address, "none", query)}/// 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. nil when none fits. Needs an API/// key allowed to read saved passwords.func signInAccount(tags: [String] = [], environment: String? = nil) async throws -> Address? {var body: [String: Any] = ["tags": tags]if let environment { body["environment"] = environment }do {return try JSONDecoder().decode(Address.self, from: try await post("addresses/sign-in", body))} catch let failure as Failure where failure.status == 404 {return nil}}/// 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.func checkOutAccount(tags: [String], minutes: Int = 10) async throws -> Account {var account = try JSONDecoder().decode(Account.self, from: try await post("addresses/checkout", ["tags": tags, "minutes": minutes]))struct Secret: Decodable { let secret: String }if let data = try? await get(account.address, "password", [:]) {account.password = try? JSONDecoder().decode(Secret.self, from: data).secret}return account}/// Gives a borrowed account back, recording what the test changed (tags to add and remove).func returnAccount(_ account: Account, addTags: [String] = [], removeTags: [String] = []) async throws {let body: [String: Any] = ["token": account.checkout.token, "add_tags": addTags, "remove_tags": removeTags]_ = try await post("addresses/\(encode(account.address))/return", body)}/// 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.func recordConsent(_ address: String, marketing: String, prechecked: Bool? = nil, doubleOptIn: String? = nil) async throws {var body: [String: Any] = ["marketing": marketing]if let prechecked { body["prechecked"] = prechecked }if let doubleOptIn { body["double_opt_in"] = doubleOptIn }_ = try await post("addresses/\(encode(address))/consent", body, method: "PUT")}/// After applying a coupon at checkout in the app (StoreKit testing 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".func recordCouponResult(_ address: String, result: String, code: String? = nil, message: String? = nil, oneTime: Bool = false) async throws -> String {var body: [String: Any] = ["result": result, "one_time": oneTime]if let code { body["code"] = code }if let message { body["message"] = message }struct Recorded: Decodable { struct Check: Decodable { let status: String }; let check: Check? }let recorded = try JSONDecoder().decode(Recorded.self, from: try await post("addresses/\(encode(address))/coupon-result", body))return recorded.check?.status ?? "skip"}private func waitFor(_ kind: String, address: String, since: Date, type: String? = nil, app: Bool? = nil, unused: Bool = false) async throws -> String {var query = ["since": iso(since)]if let type { query["type"] = type }if let app { query["app"] = String(app) }if unused { query["unused"] = "true" }struct Found: Decodable { let value: String }return try JSONDecoder().decode(Found.self, from: try await get(address, kind, query)).value}// Addresses can hold characters such as ' + / ?, so encode all but the safest.private static let safe = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")private func encode(_ value: String) -> String { value.addingPercentEncoding(withAllowedCharacters: Self.safe)! }private func iso(_ date: Date) -> String { ISO8601DateFormatter().string(from: date) }private func get(_ address: String, _ path: String, _ query: [String: String]) async throws -> Data {var url = URLComponents(url: baseURL.appendingPathComponent("addresses"), resolvingAgainstBaseURL: false)!url.percentEncodedPath += "/" + encode(address) + "/" + pathurl.queryItems = query.isEmpty ? nil : query.sorted { $0.key < $1.key }.map { URLQueryItem(name: $0.key, value: $0.value) }var request = URLRequest(url: url.url!)request.timeoutInterval = 40return try await send(request)}private func post(_ path: String, _ body: [String: Any], method: String = "POST") async throws -> Data {var request = URLRequest(url: URL(string: baseURL.absoluteString + "/" + path)!)request.httpMethod = methodrequest.httpBody = try JSONSerialization.data(withJSONObject: body)request.timeoutInterval = 40return try await send(request)}private func send(_ request: URLRequest) async throws -> Data {var request = requestrequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")request.setValue("application/json", forHTTPHeaderField: "Content-Type")if let run, !run.isEmpty {request.setValue(run, forHTTPHeaderField: "Proxy-Mail-QA-Run")// Percent-encoded: a test's name can hold characters headers can't.if let test { request.setValue(encode(test), forHTTPHeaderField: "Proxy-Mail-QA-Test") }}let (data, response) = try await URLSession.shared.data(for: request)let status = (response as? HTTPURLResponse)?.statusCode ?? 0guard (200..<300).contains(status) else {// A wait that finds nothing says why: read `hint` before retrying.struct Problem: Decodable { let message: String?; let hint: String? }let problem = try? JSONDecoder().decode(Problem.self, from: data)throw Failure(description: [problem?.message, problem?.hint].compactMap { $0 }.joined(separator: " "), status: status)}return data}}API キー を設定する
Section titled “API キー を設定する”テスト実行用に
PROXYMAIL_QA_API_KEYを設定します。テストプランまたはスキームの Arguments → Environment Variables で$(PROXYMAIL_QA_API_KEY)として設定し、値がプロジェクトファイルではなくシェルや CI から渡されるようにします。Xcode Cloud では、シークレットの環境変数として追加します。コマンドラインからは、
xcodebuildはTEST_RUNNER_で始まる変数をプレフィックスを除いてテストに渡します。TEST_RUNNER_PROXYMAIL_QA_API_KEY="$PROXYMAIL_QA_API_KEY" xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16'サインアップのテストを書く
Section titled “サインアップのテストを書く”サインアップのテスト:
SignUpTests.swift import XCTestfinal class SignUpTests: XCTestCase {@MainActorfunc testSignUpSendsAWorkingCode() async throws {let qa = try ProxyMailQA()let account = try await qa.createAddress()let app = XCUIApplication()app.launch()let submitted = Date()app.textFields["Email"].tap()app.textFields["Email"].typeText(account.address)app.secureTextFields["Password"].tap()app.secureTextFields["Password"].typeText(account.password!)app.buttons["Sign up"].tap()let code = try await qa.waitForCode(account.address, since: submitted)app.textFields["Code"].tap()app.textFields["Code"].typeText(code)}}またはリンクを開く
Section titled “またはリンクを開く”リンクは、Mail でタップしたときと同じようにアプリで開きます。
メールにウェブ用のリンクとアプリを開くリンク(
myapp://…)の両方がある場合、waitForLink(account.address, since: submitted, app: true)はアプリ用のリンクを取得します。XCUIApplication.open(_:)には Xcode 14.3 と iOS 16.4 以降が必要です。let link = try await qa.waitForLink(account.address, since: submitted)app.open(link)
コードだけでなくメールもチェックする
Section titled “コードだけでなくメールもチェックする”XCTAssertEqual(try await qa.countEmails(account.address, since: submitted), 1)XCTAssertEqual(try await qa.checkEmail(account.address, since: submitted, expectHost: "links.example.com"), [])checkEmail は、メールが通らなかったチェックを返します。SPF、DKIM、DMARC、localhost やプレーンな http へのリンク、リンクの行き先(expectHost、Universal Links が使うドメイン)、記載された有効期限、サイズなどです(各チェックの意味)。expectNoEmail(address, since:) は何も届かなかった場合にのみ成功します。「配信停止したユーザーには何も届かない」の確認用です。category: "marketing" を追加するとマーケティングメールだけを監視するので、領収書は届いても構いません。再送後、waitForCode(address, since: submitted, unused: true) は使用済みのコードをスキップします。オファーに登録した後は、waitForCode(address, since: submitted, type: "coupon") が代わりにクーポンやプロモーションコードを待ちます。チェックアウトで適用した後(StoreKit テストまたはアプリのテストモードで行い、実際のカードは使いません)、recordCouponResult(address, result: "accepted", message: alertText) はアプリの動作を記録し、coupon_result の判定として "pass"、"warn"、"fail" のいずれかを返します。1 人 1 回のオファーには oneTime: true を追加します。recordConsent(address, marketing: "declined", prechecked: false) はアカウントが同意した内容を記録し、checkEmail がペルソナの居住地のルールに基づいてマーケティングメールを判定できるようにします。
createAddress(environment: "staging", age: "under18", country: "GB") は、アカウントがどの環境用かを記録し、入力を求めるフォーム向けに、ペルソナに生年月日と国を設定します。
保存したアカウントでサインインする、または借りる
Section titled “保存したアカウントでサインインする、または借りる”サインインのテストでは、signInAccount(tags: ["verified"]) が保存済みのテスト用アカウントとそのパスワードを返し、合うものがなければ nil を返します。テストを並列で実行する場合、checkOutAccount(tags: ["verified"]) は他のテストが使っていないアカウントをパスワード付きで、返却されるまで貸し出します:
let account = try await qa.checkOutAccount(tags: ["verified"])// Sign in with account.address and account.password!, then:try await qa.returnAccount(account)どちらも保存済みのパスワードを読むため、キーには Can read saved passwords 権限が必要です(Project settings → API keys)。アプリでの状態をアカウントにタグ付けするとプールを作れます。アカウントプール を参照してください。
プルリクエストへのレポート
Section titled “プルリクエストへのレポート”ジョブに GitHub Action がある場合は、それが開始する実行をキーと同じ方法でテストに渡し、各テストに名前を付けます。これで、各メールがどのテストから来たかがレポートに表示されます。
TEST_RUNNER_PROXYMAIL_QA_API_KEY="$PROXYMAIL_QA_API_KEY" TEST_RUNNER_PROXYMAIL_QA_RUN="$PROXYMAIL_QA_RUN" xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16'let qa = try ProxyMailQA(test: name) // XCTestCase's name for this test代わりにコーディングエージェントにシミュレーターでアプリを動かしてサインアップさせるには、コーディングエージェントで iOS アプリをテストする を参照してください。
このページについて質問がありますか?
質問はどの言語でもかまいません。ドキュメントからすぐに回答を得ることも、Proxy Mail QA チームに質問を送ることもできます。チームからの返信は英語です。