Skip to content

Testing recipe

Test real SMS verification in the iOS simulator

The app runs in the simulator while a real SMS arrives at your company test number. A Swift fixture in the UI test runner reads the fresh code through Phonekit’s API, types it into the app, and checks the signed-in screen.

Put each part in its natural place

PartResponsibility
App under testRequest and verify the SMS through its configured authentication service
Company Phonekit numberReceive the real message
XCUITest runnerMark the inbox, wait through HTTP, and enter the code
Final UI assertionConfirm the app reached its authenticated state

Scroll horizontally to see all columns.

The simulator is the application UI, not the SMS destination. This recipe exercises manual code entry; it does not prove iOS SMS AutoFill, SIM behavior or the Messages app. Keep those device-specific checks in physical-device coverage when needed.

Configure the native test target

Add the two Swift files below to your XCUITest target. Use an existing staging account whose phone is your test number, and reset the application's test state before running. Configure PHONEKIT_API_KEY and TEST_PHONE_NUMBER in the test runner's secure environment. Explicitly select the number when creating the read-only key.

Keep the key in the runner. Do not put it in XCUIApplication.launchEnvironment or your app bundle. Your CI/test harness must make the variables available to ProcessInfo in the test runner; validate that setup with the required-variable check below. Do not commit a key-bearing test plan.

Give your app accessible identifiers phone-number, send-code, verification-code, verify-code, and account-heading, or change the selectors to its existing identifiers. Use an asynchronous test with enough overall execution time for the 120-second receipt wait plus UI steps. Apple's asynchronous testing, XCUIApplication.

Add a small Swift HTTP receiver

This fixture uses the HTTP API directly. It captures the number's cursor and polls immediate /messages/after reads locally within a 120-second overall receive window. It intentionally fails on unexpected HTTP statuses or a fresh message without a detected OTP; add the retry behavior your runner needs rather than masking a configuration failure.

PhonekitFixture.swiftswift
import Foundation
 
actor PhonekitFixture {
    enum Failure: Error {
        case invalidURL, invalidResponse, httpStatus(Int), invalidOptions, timeout, missingOtp
    }
    private struct Mark: Decodable { let cursor: String }
    private struct Receipt: Decodable {
        struct Message: Decodable { let otp: String? }
        let data: [Message]
        let cursor: String
    }
    private let session: URLSession
    private let apiKey: String
    private let numberURL: URL
 
    init(apiKey: String, numberRef: String, session: URLSession = .shared) throws {
        guard let base = URL(string: "https://www.phonekit.io/api/v1/numbers") else {
            throw Failure.invalidURL
        }
        self.session = session
        self.apiKey = apiKey
        self.numberURL = base.appendingPathComponent(numberRef)
    }
 
    func mark() async throws -> String {
        let (data, response) = try await request("cursor", timeout: 30)
        guard response.statusCode == 200 else {
            throw Failure.httpStatus(response.statusCode)
        }
        return try JSONDecoder().decode(Mark.self, from: data).cursor
    }
 
    func waitForOtp(
        after: String,
        timeout: TimeInterval,
        pollInterval: TimeInterval = 1
    ) async throws -> String {
        guard timeout.isFinite, timeout > 0,
              pollInterval.isFinite, pollInterval > 0 else {
            throw Failure.invalidOptions
        }
        let clock = ContinuousClock()
        let deadline = clock.now.advanced(by: .seconds(timeout))
        var cursor = after
        while clock.now < deadline {
            try Task.checkCancellation()
            let remaining = clock.now.duration(to: deadline).components
            let seconds = Double(remaining.seconds) + Double(remaining.attoseconds) / 1e18
            let (data, response) = try await request(
                "messages/after",
                query: [
                    URLQueryItem(name: "after", value: cursor),
                    URLQueryItem(name: "limit", value: "1")
                ],
                timeout: seconds
            )
            try Task.checkCancellation()
            guard clock.now < deadline else { throw Failure.timeout }
            guard response.statusCode == 200 else {
                throw Failure.httpStatus(response.statusCode)
            }
            let receipt = try JSONDecoder().decode(Receipt.self, from: data)
            cursor = receipt.cursor
            if let message = receipt.data.first {
                guard let code = message.otp, !code.isEmpty else {
                    throw Failure.missingOtp
                }
                return code
            }
            let nextRead = min(clock.now.advanced(by: .seconds(pollInterval)), deadline)
            try await clock.sleep(until: nextRead)
        }
        throw Failure.timeout
    }
 
    private func request(
        _ suffix: String,
        query: [URLQueryItem] = [],
        timeout: TimeInterval
    ) async throws -> (Data, HTTPURLResponse) {
        var components = URLComponents(
            url: numberURL.appendingPathComponent(suffix),
            resolvingAgainstBaseURL: false
        )
        components?.queryItems = query.isEmpty ? nil : query
        guard let url = components?.url else { throw Failure.invalidURL }
        var request = URLRequest(url: url)
        request.timeoutInterval = timeout
        request.setValue("Bearer " + apiKey, forHTTPHeaderField: "Authorization")
        let (data, response) = try await session.data(for: request)
        guard let http = response as? HTTPURLResponse else {
            throw Failure.invalidResponse
        }
        return (data, http)
    }
}

Finish the native UI flow

PhoneVerificationUITests.swiftswift
import XCTest
 
@MainActor
final class PhoneVerificationUITests: XCTestCase {
    func testSignInWithRealSMS() async throws {
        let environment = ProcessInfo.processInfo.environment
        guard let key = environment["PHONEKIT_API_KEY"], !key.isEmpty,
              let phone = environment["TEST_PHONE_NUMBER"], !phone.isEmpty else {
            XCTFail("Set PHONEKIT_API_KEY and TEST_PHONE_NUMBER for the test runner")
            return
        }
        let inbox = try PhonekitFixture(apiKey: key, numberRef: phone)
        let app = XCUIApplication()
        app.launch()
        let phoneField = app.textFields["phone-number"]
        XCTAssertTrue(phoneField.waitForExistence(timeout: 10))
        phoneField.tap()
        phoneField.typeText(phone)
 
        let after = try await inbox.mark()
        app.buttons["send-code"].tap()
        let code = try await inbox.waitForOtp(after: after, timeout: 120)
        let codeField = app.textFields["verification-code"]
        XCTAssertTrue(codeField.waitForExistence(timeout: 10))
        codeField.tap()
        codeField.typeText(code)
        app.buttons["verify-code"].tap()
 
        XCTAssertTrue(app.staticTexts["account-heading"].waitForExistence(timeout: 15))
    }
}

Run the target through Xcode's Test action or your existing xcodebuild test lane. Use the simulator/device destination already configured for your app and verify that the test runner has its environment. No JS SDK or subprocess is needed in the native test.

Read failures by stage

FailureFirst check
Required runner environment missingTest-plan/CI environment injection into the runner
HTTP 401 or 404Key validity and selected-number access
Receipt deadline expiresApplication's accepted send and intended number
missingOtpFresh message format and the expected sender
Code field or account heading missingUI state, selectors and application's verification result

Scroll horizontally to see all columns.

Xcode logs and result bundles may capture typeText arguments, screenshots or recordings. Restrict result-bundle access and review your runner's capture/redaction policy before retaining verification artifacts. The receiver itself never logs codes or message bodies.

Common questions

Does the simulator receive an SMS on its own?

The real SMS arrives at the Phonekit company number. The XCUITest runner retrieves the code through HTTP and enters it into the simulator’s app UI. This is useful for exercising the application’s real verification path without making the simulator a carrier endpoint.

Does this test SMS AutoFill?

No. It tests the app’s manual code-entry path with actual receipt. SMS AutoFill, physical-device behavior and carrier/device interactions need their own appropriate device coverage.

Where does the API key belong?

In the UI test runner’s environment or its secure test fixture. Keep it out of the production application, the app bundle and XCUIApplication.launchEnvironment. The app needs only its normal authentication-service configuration.

Can I use this approach with Appium?

Yes. Keep the receiver in the Appium test host, mark before the app requests SMS, then type the received code using the native field selector and assert the authenticated UI. Use the SDK for a JS/TS host or the HTTP API for a native-language host.

What if Xcode records the entered code?

Treat UI result bundles as sensitive test evidence. Review typing logs and screenshots, restrict access, and apply the runner’s redaction/capture policy before uploading. Avoid publishing raw result bundles from verification tests.