iOS (XCUITest)
本页由英文自动翻译。
阅读英文版添加 helper
Section titled “添加 helper”把这个文件加到你的 UI 测试 target(不是应用 target)。它只需要 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 下的测试计划或 scheme 中设为$(PROXYMAIL_QA_API_KEY),这样值来自你的 shell 或 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";对每人限用一次的优惠,添加 oneTime: true。recordConsent(address, marketing: "declined", prechecked: false) 记录账号同意了什么,这样 checkEmail 会按该虚拟用户所在地的规则判断其营销邮件。
createAddress(environment: "staging", age: "under18", country: "GB") 记录账号用于哪个环境,并给它的 persona 设定出生日期和国家,供需要填写这些信息的表单使用。
以已保存的账号登录,或借用一个
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)。按账号在应用中的状态给它们打标签,就能组成账号池:参见账号池。
pull request 上的报告
Section titled “pull request 上的报告”在作业中使用 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 团队,他们会用英文回复。