Ir al contenido
  1. Agrega este archivo a tu target de pruebas de UI (no al target de la app). Solo necesita 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. Define PROXYMAIL_QA_API_KEY para la ejecución de pruebas: en el plan de pruebas o el esquema, en Arguments → Environment Variables, como $(PROXYMAIL_QA_API_KEY), para que el valor venga de tu shell o de CI y no del archivo del proyecto. En Xcode Cloud, agrégala como variable de entorno secreta.

    Desde la línea de comandos, xcodebuild pasa a las pruebas toda variable que empiece por TEST_RUNNER_, sin el prefijo:

    TEST_RUNNER_PROXYMAIL_QA_API_KEY="$PROXYMAIL_QA_API_KEY" xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16'
  3. Una prueba de registro:

    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. Para un enlace, ábrelo en la app, como si lo tocaras en Mail:

    Si el correo tiene un enlace web y otro que abre la app (myapp://…), waitForLink(account.address, since: submitted, app: true) obtiene el de la app. XCUIApplication.open(_:) requiere Xcode 14.3 y iOS 16.4 o posterior.

    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 devuelve las comprobaciones que el correo no supera: SPF, DKIM y DMARC, enlaces a localhost o a http simple, adónde lleva su enlace (expectHost, el dominio que usan tus Universal Links), una caducidad indicada, su tamaño y más (qué significa cada comprobación). expectNoEmail(address, since:) pasa solo si no llega nada, para “un usuario dado de baja no recibe nada”; agrega category: "marketing" para vigilar solo el marketing, de modo que los recibos puedan seguir llegando. Tras un reenvío, waitForCode(address, since: submitted, unused: true) omite el código ya usado. Tras registrarse en una oferta, waitForCode(address, since: submitted, type: "coupon") espera en su lugar su cupón o código promocional. Tras aplicarlo en el pago (pruebas de StoreKit o el modo de prueba de la app, nunca una tarjeta real), recordCouponResult(address, result: "accepted", message: alertText) registra lo que hizo la app y devuelve el veredicto coupon_result: "pass", "warn" o "fail"; agrega oneTime: true para una oferta de una por persona. recordConsent(address, marketing: "declined", prechecked: false) registra lo que aceptó la cuenta, para que checkEmail evalúe su marketing según las reglas del lugar donde vive el persona.

createAddress(environment: "staging", age: "under18", country: "GB") registra para qué entorno es la cuenta y da a su persona una fecha de nacimiento y un país, para los formularios que lo piden.

Inicia sesión con una cuenta guardada o toma una prestada

Sección titulada «Inicia sesión con una cuenta guardada o toma una prestada»

Para una prueba de inicio de sesión, signInAccount(tags: ["verified"]) devuelve una cuenta de prueba guardada y su contraseña, o nil si ninguna encaja. Cuando las pruebas se ejecutan en paralelo, checkOutAccount(tags: ["verified"]) presta una que ninguna otra prueba tiene, con su contraseña, hasta que la devuelvas:

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

Ambas leen contraseñas guardadas, así que la clave necesita el permiso Can read saved passwords (Project settings → API keys). Etiqueta las cuentas con su estado en la app para crear grupos: consulta un grupo de cuentas.

Con la Action de GitHub en el job, pasa la ejecución que inicia a las pruebas igual que la clave, y nombra cada prueba, para que el informe indique de qué prueba vino cada correo:

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

Para que un agente de programación ejecute la app en el simulador y se registre por ti, consulta Apps de iOS con un agente de programación.

¿Tienes una pregunta sobre esta página?

Pregunta en cualquier idioma. La documentación puede responderte de inmediato, o puedes enviar tu pregunta al equipo de Proxy Mail QA, que responde en inglés.