コンテンツにスキップ
  1. このファイルを UI のテストターゲット(アプリのターゲットではありません)に追加します。必要なのは 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. テスト実行用に PROXYMAIL_QA_API_KEY を設定します。テストプランまたはスキームの Arguments → Environment Variables で $(PROXYMAIL_QA_API_KEY) として設定し、値がプロジェクトファイルではなくシェルや CI から渡されるようにします。Xcode Cloud では、シークレットの環境変数として追加します。

    コマンドラインからは、xcodebuild は TEST_RUNNER_ で始まる変数をプレフィックスを除いてテストに渡します。

    TEST_RUNNER_PROXYMAIL_QA_API_KEY="$PROXYMAIL_QA_API_KEY" xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16'
  3. サインアップのテスト:

    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. リンクは、Mail でタップしたときと同じようにアプリで開きます。

    メールにウェブ用のリンクとアプリを開くリンク(myapp://…)の両方がある場合、waitForLink(account.address, since: submitted, app: true) はアプリ用のリンクを取得します。XCUIApplication.open(_:) には Xcode 14.3 と iOS 16.4 以降が必要です。

    let link = try await qa.waitForLink(account.address, since: submitted)
    app.open(link)

コードだけでなくメールもチェックする

Section titled “コードだけでなくメールもチェックする”
XCTAssertEqual(try await qa.countEmails(account.address, since: submitted), 1)
XCTAssertEqual(try await qa.checkEmail(account.address, since: submitted, expectHost: "links.example.com"), [])

checkEmail は、メールが通らなかったチェックを返します。SPF、DKIM、DMARC、localhost やプレーンな http へのリンク、リンクの行き先(expectHost、Universal Links が使うドメイン)、記載された有効期限、サイズなどです(各チェックの意味)。expectNoEmail(address, since:) は何も届かなかった場合にのみ成功します。「配信停止したユーザーには何も届かない」の確認用です。category: "marketing" を追加するとマーケティングメールだけを監視するので、領収書は届いても構いません。再送後、waitForCode(address, since: submitted, unused: true) は使用済みのコードをスキップします。オファーに登録した後は、waitForCode(address, since: submitted, type: "coupon") が代わりにクーポンやプロモーションコードを待ちます。チェックアウトで適用した後(StoreKit テストまたはアプリのテストモードで行い、実際のカードは使いません)、recordCouponResult(address, result: "accepted", message: alertText) はアプリの動作を記録し、coupon_result の判定として "pass"、"warn"、"fail" のいずれかを返します。1 人 1 回のオファーには oneTime: true を追加します。recordConsent(address, marketing: "declined", prechecked: false) はアカウントが同意した内容を記録し、checkEmail がペルソナの居住地のルールに基づいてマーケティングメールを判定できるようにします。

createAddress(environment: "staging", age: "under18", country: "GB") は、アカウントがどの環境用かを記録し、入力を求めるフォーム向けに、ペルソナに生年月日と国を設定します。

保存したアカウントでサインインする、または借りる

Section titled “保存したアカウントでサインインする、または借りる”

サインインのテストでは、signInAccount(tags: ["verified"]) が保存済みのテスト用アカウントとそのパスワードを返し、合うものがなければ nil を返します。テストを並列で実行する場合、checkOutAccount(tags: ["verified"]) は他のテストが使っていないアカウントをパスワード付きで、返却されるまで貸し出します:

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

どちらも保存済みのパスワードを読むため、キーには Can read saved passwords 権限が必要です(Project settings → API keys)。アプリでの状態をアカウントにタグ付けするとプールを作れます。アカウントプール を参照してください。

ジョブに GitHub Action がある場合は、それが開始する実行をキーと同じ方法でテストに渡し、各テストに名前を付けます。これで、各メールがどのテストから来たかがレポートに表示されます。

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

代わりにコーディングエージェントにシミュレーターでアプリを動かしてサインアップさせるには、コーディングエージェントで iOS アプリをテストする を参照してください。

このページについて質問がありますか?

質問はどの言語でもかまいません。ドキュメントからすぐに回答を得ることも、Proxy Mail QA チームに質問を送ることもできます。チームからの返信は英語です。