Aller au contenu
  1. Ajoutez ce fichier à votre cible de test UI (pas à la cible de l’app). Il n’a besoin que de 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. Définissez PROXYMAIL_QA_API_KEY pour l’exécution des tests : dans le plan de test ou le schéma, sous Arguments → Environment Variables, en tant que $(PROXYMAIL_QA_API_KEY), pour que la valeur vienne de votre shell ou de CI plutôt que du fichier de projet. Sur Xcode Cloud, ajoutez-la comme variable d’environnement secrète.

    En ligne de commande, xcodebuild transmet aux tests toute variable qui commence par TEST_RUNNER_, sans le préfixe :

    TEST_RUNNER_PROXYMAIL_QA_API_KEY="$PROXYMAIL_QA_API_KEY" xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16'
  3. Un test d’inscription :

    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. Pour un lien, ouvrez-le dans l’app, comme le ferait un appui dessus dans Mail :

    Si l’e-mail contient à la fois un lien web et un lien qui ouvre l’app (myapp://…), waitForLink(account.address, since: submitted, app: true) récupère celui de l’app. XCUIApplication.open(_:) nécessite Xcode 14.3 et iOS 16.4 ou version ultérieure.

    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 renvoie les vérifications que l’e-mail échoue : SPF, DKIM et DMARC, liens vers localhost ou en http simple, destination de son lien (expectHost, le domaine de vos Universal Links), expiration indiquée, taille, etc. (ce que signifie chaque vérification). expectNoEmail(address, since:) ne réussit que si rien n’arrive, pour « un utilisateur désabonné ne reçoit rien » ; ajoutez category: "marketing" pour ne surveiller que le marketing, afin que les reçus puissent encore arriver. Après un renvoi, waitForCode(address, since: submitted, unused: true) ignore le code déjà utilisé. Après une inscription à une offre, waitForCode(address, since: submitted, type: "coupon") attend plutôt son coupon ou son code promo. Après son application au paiement (test StoreKit ou mode test de l’app, jamais une vraie carte), recordCouponResult(address, result: "accepted", message: alertText) enregistre ce qu’a fait l’app et renvoie le verdict coupon_result, "pass", "warn" ou "fail" ; ajoutez oneTime: true pour une offre limitée à une par personne. recordConsent(address, marketing: "declined", prechecked: false) enregistre ce que le compte a accepté, pour que checkEmail juge son marketing selon les règles du lieu où vit le persona.

createAddress(environment: "staging", age: "under18", country: "GB") enregistre l’environnement auquel le compte est destiné et donne à son persona une date de naissance et un pays, pour les formulaires qui les demandent.

Se connecter avec un compte enregistré, ou en emprunter un

Section intitulée « Se connecter avec un compte enregistré, ou en emprunter un »

Pour un test de connexion, signInAccount(tags: ["verified"]) renvoie un compte de test enregistré et son mot de passe, ou nil si aucun ne convient. Quand les tests tournent en parallèle, checkOutAccount(tags: ["verified"]) en prête un qu’aucun autre test n’a, avec son mot de passe, jusqu’à ce que vous le rendiez :

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

Les deux lisent des mots de passe enregistrés, donc la clé a besoin de l’autorisation Can read saved passwords (Project settings → API keys). Étiquetez les comptes selon leur état dans l’app pour créer des pools : voir un pool de comptes.

Avec l’Action GitHub dans le job, transmettez aux tests l’exécution qu’elle lance de la même façon que la clé, et nommez chaque test, pour que le rapport indique de quel test vient chaque e-mail :

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

Pour qu’un agent de codage lance l’app dans le simulateur et s’inscrive à votre place, consultez iOS des apps avec un agent de codage.

Une question sur cette page ?

Posez votre question dans la langue de votre choix. La documentation peut y répondre tout de suite, ou vous pouvez l'envoyer à l'équipe Proxy Mail QA, qui répond en anglais.

Interroger la documentation