iOS (XCUITest)
Add this file to your UI test target (not the app target). It needs nothing but Foundation:
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. func waitForCode(_ address: String, since: Date, unused: Bool = false) async throws -> String { try await waitFor("code", address: address, since: since, 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. func expectNoEmail(_ address: String, since: Date, timeout: Int = 30) async throws { _ = try await get(address, "none", ["since": iso(since), "timeout": String(timeout)]) }
/// 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) }
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]) async throws -> Data { var request = URLRequest(url: URL(string: baseURL.absoluteString + "/" + path)!) request.httpMethod = "POST" 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 }}Set PROXYMAIL_QA_API_KEY for the test run: in the test plan or scheme under Arguments → Environment Variables, as $(PROXYMAIL_QA_API_KEY) so the value comes from your shell or CI rather than the project file. On Xcode Cloud, add it as a secret environment variable.
From the command line, xcodebuild hands the tests any variable that starts with TEST_RUNNER_, without the prefix:
TEST_RUNNER_PROXYMAIL_QA_API_KEY="$PROXYMAIL_QA_API_KEY" xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16'A sign-up test:
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) }}For a link, open it in the app, the way tapping it in Mail would:
let link = try await qa.waitForLink(account.address, since: submitted)app.open(link)If the email has both a web link and one that opens the app (myapp://…), waitForLink(account.address, since: submitted, app: true) gets the app one. XCUIApplication.open(_:) needs Xcode 14.3 and iOS 16.4 or later.
Check the email, not just the code
Section titled “Check the email, not just the code”XCTAssertEqual(try await qa.countEmails(account.address, since: submitted), 1)XCTAssertEqual(try await qa.checkEmail(account.address, since: submitted, expectHost: "links.example.com"), [])checkEmail returns the checks the email fails: SPF, DKIM and DMARC, links to localhost or plain http, where its link goes (expectHost, the domain your Universal Links use), a stated expiry, its size and more (what each check means). expectNoEmail(address, since:) passes only if nothing arrives, for “an unsubscribed user gets nothing”. After a resend, waitForCode(address, since: submitted, unused: true) skips the code already used.
createAddress(environment: "staging", age: "under18", country: "GB") records which environment the account is for and gives its persona a date of birth and a country, for forms that ask.
Sign in as a saved account, or borrow one
Section titled “Sign in as a saved account, or borrow one”For a sign-in test, signInAccount(tags: ["verified"]) returns a saved test account and its password, or nil when none fits. When tests run in parallel, checkOutAccount(tags: ["verified"]) lends one no other test has, with its password, until you give it back:
let account = try await qa.checkOutAccount(tags: ["verified"])// Sign in with account.address and account.password!, then:try await qa.returnAccount(account)Both read saved passwords, so the key needs Can read saved passwords (Project settings → API keys). Tag accounts with their state in the app to make pools: see an account pool.
A report on the pull request
Section titled “A report on the pull request”With the GitHub Action in the job, pass the run it starts to the tests the same way as the key, and name each test, so the report says which test each email came from:
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 testTo have a coding agent run the app in the simulator and sign up for you instead, see iOS apps with a coding agent.