iOS (XCUITest)
Traducido automáticamente del inglés.
Leer en inglésAñade el helper
Sección titulada «Añade el helper»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: 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}}Define la clave de API
Sección titulada «Define la clave de API»Define
PROXYMAIL_QA_API_KEYpara 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,
xcodebuildpasa a las pruebas toda variable que empiece porTEST_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'Escribe una prueba de registro
Sección titulada «Escribe una prueba de registro»Una prueba de registro:
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)}}O abre un enlace
Sección titulada «O abre un enlace»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)
Comprueba el correo, no solo el código
Sección titulada «Comprueba el correo, no solo el código»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.
Un informe en el pull request
Sección titulada «Un informe en el pull request»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 testPara 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.