跳转到内容
  1. 把这个文件加到你的 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: String
    let 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: String
    let tags: [String]
    let checkout: Hold
    var password: String?
    }
    struct Failure: Error, CustomStringConvertible {
    let description: String
    var status = 0
    }
    var apiKey: String
    var 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 = apiKey
    self.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) + "/" + path
    url.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 = 40
    return 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 = method
    request.httpBody = try JSONSerialization.data(withJSONObject: body)
    request.timeoutInterval = 40
    return try await send(request)
    }
    private func send(_ request: URLRequest) async throws -> Data {
    var request = request
    request.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 ?? 0
    guard (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
    }
    }
  2. 为测试运行设置 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'
  3. 一个注册测试:

    SignUpTests.swift
    import XCTest
    final class SignUpTests: XCTestCase {
    @MainActor
    func 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)
    }
    }
  4. 对于链接,在应用中打开它,就像在 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)。按账号在应用中的状态给它们打标签,就能组成账号池:参见账号池。

在作业中使用 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 团队,他们会用英文回复。