---
title: "iOS (XCUITest) | Proxy Mail QA"
description: "Test email verification codes and magic links in an iOS app's XCUITest UI tests with disposable Proxy Mail QA inboxes: a small Swift helper, no dependencies."
url: https://proxymail.qa/docs/xcuitest
---

# iOS (XCUITest)

Add this file to your UI test target (not the app target). It needs nothing but Foundation:

ProxyMailQA.swift

```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.
    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:

Terminal window

```bash
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:

SignUpTests.swift

```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)
    }
}
```

For a link, open it in the app, the way tapping it in Mail would:

```swift
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

```swift
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](https://proxymail.qa/docs/rest-api#checking-an-emails-sending-and-build)). `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

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:

```swift
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](https://proxymail.qa/docs/rest-api#an-account-pool-for-parallel-tests).

## A report on the pull request

With the [GitHub Action](https://proxymail.qa/docs/ci#a-report-on-the-pull-request) 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:

Terminal window

```bash
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'
```

```swift
let qa = try ProxyMailQA(test: name) // XCTestCase's name for this test
```

To have a coding agent run the app in the simulator and sign up for you instead, see [iOS apps with a coding agent](https://proxymail.qa/docs/ios-agents).

[Previous Maestro](https://proxymail.qa/docs/maestro.html) [Next Android (Espresso)](https://proxymail.qa/docs/espresso.html)
