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
| Part | Responsibility |
|---|---|
| App under test | Request and verify the SMS through its configured authentication service |
| Company Phonekit number | Receive the real message |
| XCUITest runner | Mark the inbox, wait through HTTP, and enter the code |
| Final UI assertion | Confirm 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.
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
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
| Failure | First check |
|---|---|
| Required runner environment missing | Test-plan/CI environment injection into the runner |
| HTTP 401 or 404 | Key validity and selected-number access |
| Receipt deadline expires | Application's accepted send and intended number |
missingOtp | Fresh message format and the expected sender |
| Code field or account heading missing | UI 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.