Zum Inhalt springen
  1. Füge diese Datei deinem UI-Testtarget hinzu (nicht dem App-Target). Sie braucht nur 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. Setze PROXYMAIL_QA_API_KEY für den Testlauf: im Testplan oder Scheme unter Arguments → Environment Variables, als $(PROXYMAIL_QA_API_KEY), damit der Wert aus deiner Shell oder CI kommt statt aus der Projektdatei. In Xcode Cloud fügst du ihn als geheime Umgebungsvariable hinzu.

    Auf der Kommandozeile gibt xcodebuild jede Variable, die mit TEST_RUNNER_ beginnt, ohne das Präfix an die Tests weiter:

    TEST_RUNNER_PROXYMAIL_QA_API_KEY="$PROXYMAIL_QA_API_KEY" xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16'
  3. Ein Registrierungstest:

    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. Einen Link öffnest du in der App, so wie ein Tippen in Mail es tun würde:

    Hat die E-Mail sowohl einen Web-Link als auch einen, der die App öffnet (myapp://…), bekommt waitForLink(account.address, since: submitted, app: true) den App-Link. XCUIApplication.open(_:) braucht Xcode 14.3 und iOS 16.4 oder neuer.

    let link = try await qa.waitForLink(account.address, since: submitted)
    app.open(link)
XCTAssertEqual(try await qa.countEmails(account.address, since: submitted), 1)
XCTAssertEqual(try await qa.checkEmail(account.address, since: submitted, expectHost: "links.example.com"), [])

checkEmail gibt die Prüfungen zurück, die die E-Mail nicht besteht: SPF, DKIM und DMARC, Links auf localhost oder reines http, wohin ihr Link führt (expectHost, die Domain deiner Universal Links), eine angegebene Ablaufzeit, ihre Größe und mehr (was jede Prüfung bedeutet). expectNoEmail(address, since:) besteht nur, wenn nichts ankommt, für „ein abgemeldeter Nutzer bekommt nichts“; füge category: "marketing" hinzu, um nur auf Marketing zu achten, damit Belege weiter ankommen können. Nach einem erneuten Senden überspringt waitForCode(address, since: submitted, unused: true) den bereits verwendeten Code. Nach der Anmeldung für ein Angebot wartet waitForCode(address, since: submitted, type: "coupon") stattdessen auf dessen Gutschein- oder Promo-Code. Nachdem er an der Kasse eingelöst wurde (StoreKit-Testing oder der Testmodus der App, nie eine echte Karte), hält recordCouponResult(address, result: "accepted", message: alertText) fest, was die App getan hat, und gibt das Urteil coupon_result zurück: "pass", "warn" oder "fail"; füge oneTime: true für ein Angebot mit einmal pro Person hinzu. recordConsent(address, marketing: "declined", prechecked: false) hält fest, wozu das Konto zugestimmt hat, damit checkEmail sein Marketing nach den Regeln am Wohnort der Persona bewertet.

createAddress(environment: "staging", age: "under18", country: "GB") erfasst, für welche Umgebung das Konto ist, und gibt seiner Persona ein Geburtsdatum und ein Land, für Formulare, die danach fragen.

Mit einem gespeicherten Konto anmelden oder eines ausleihen

Abschnitt betitelt „Mit einem gespeicherten Konto anmelden oder eines ausleihen“

Für einen Anmeldetest gibt signInAccount(tags: ["verified"]) ein gespeichertes Testkonto und sein Passwort zurück oder nil, wenn keines passt. Wenn Tests parallel laufen, leiht checkOutAccount(tags: ["verified"]) eines aus, das kein anderer Test hat, mit seinem Passwort, bis du es zurückgibst:

let account = try await qa.checkOutAccount(tags: ["verified"])
// Sign in with account.address and account.password!, then:
try await qa.returnAccount(account)

Beide lesen gespeicherte Passwörter, also braucht der Schlüssel die Berechtigung Can read saved passwords (Project settings → API keys). Markiere Konten in der App mit ihrem Zustand, um Pools zu bilden: siehe Konto-Pools.

Mit der GitHub Action im Job gibst du den gestarteten Lauf genauso wie den Schlüssel an die Tests weiter und benennst jeden Test, damit der Bericht zeigt, von welchem Test jede E-Mail kam:

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

Wenn ein Coding-Agent die App im Simulator ausführen und sich für dich registrieren soll, sieh dir iOS-Apps mit einem Coding-Agent an.

Eine Frage zu dieser Seite?

Frag in jeder Sprache. Die Doku kann dir sofort antworten oder deine Frage an das Team von Proxy Mail QA schicken, das auf Englisch antwortet.

Doku fragen