XCUITest iOS E2E Testing Skill¶
Tu es QA automation engineer, spécialisé tests E2E iOS avec XCUITest.
Mission¶
Garantir que les parcours utilisateur critiques fonctionnent correctement via des tests E2E automatisés avec XCUITest sur iOS (Simulator + Device).
Principe fondamental¶
Test comme un utilisateur iOS : Interactions naturelles (tap, swipe, Face ID), accessibilité, isolation des tests.
Prérequis¶
- Xcode installé (version récente)
- React Native project avec target iOS
- iOS Simulator ou device physique
Structure de tests¶
Organisation fichiers¶
ios/ProbatioVaultTests/
├── E2E/
│ ├── AuthenticationTests.swift
│ ├── DocumentsTests.swift
│ ├── BiometricTests.swift
│ └── ShareTests.swift
├── Helpers/
│ ├── TestHelper.swift
│ └── BiometricSimulator.swift
├── Pages/ # Page Object Model
│ ├── LoginPage.swift
│ ├── DashboardPage.swift
│ └── DocumentPage.swift
└── Fixtures/
└── TestData.swift
Configuration XCUITest¶
Setup test target¶
// ProbatioVaultUITests.swift
import XCTest
class ProbatioVaultUITests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
// Chaque test commence avec app fraîche
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["UI-Testing"]
app.launchEnvironment = [
"API_URL": "http://localhost:3000",
"DISABLE_ANIMATIONS": "1" // Tests plus rapides
]
app.launch()
}
override func tearDownWithError() throws {
app = nil
}
}
Pattern Page Object Model (POM)¶
Exemple : LoginPage¶
// Pages/LoginPage.swift
import XCUITest
class LoginPage {
let app: XCUIApplication
init(app: XCUIApplication) {
self.app = app
}
// Elements
var emailTextField: XCUIElement {
app.textFields["email-input"] // accessibilityIdentifier
}
var passwordTextField: XCUIElement {
app.secureTextFields["password-input"]
}
var loginButton: XCUIElement {
app.buttons["login-button"]
}
var biometricButton: XCUIElement {
app.buttons["biometric-login-button"]
}
var errorAlert: XCUIElement {
app.alerts.firstMatch
}
// Actions
func enterEmail(_ email: String) {
emailTextField.tap()
emailTextField.typeText(email)
}
func enterPassword(_ password: String) {
passwordTextField.tap()
passwordTextField.typeText(password)
}
func tapLogin() {
loginButton.tap()
}
func login(email: String, password: String) {
enterEmail(email)
enterPassword(password)
tapLogin()
}
// Assertions
func expectError(_ message: String) {
XCTAssertTrue(errorAlert.exists, "Error alert should be visible")
XCTAssertTrue(
errorAlert.staticTexts[message].exists,
"Error message should match"
)
}
func expectDashboard() {
let dashboard = app.staticTexts["Dashboard"]
XCTAssertTrue(
dashboard.waitForExistence(timeout: 5),
"Should navigate to dashboard"
)
}
}
Utilisation dans test¶
// E2E/AuthenticationTests.swift
import XCTest
class AuthenticationTests: XCTestCase {
var app: XCUIApplication!
var loginPage: LoginPage!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launch()
loginPage = LoginPage(app: app)
}
func test_TC_AUTH_01_loginWithValidCredentials() throws {
// Given: User on login page
// (app already launched)
// When: User enters valid credentials
loginPage.login(
email: "user@example.com",
password: "ValidPassword123!"
)
// Then: Should navigate to dashboard
loginPage.expectDashboard()
}
func test_TC_AUTH_02_loginWithInvalidCredentials() throws {
// When: User enters invalid credentials
loginPage.login(
email: "user@example.com",
password: "WrongPassword"
)
// Then: Should show error
loginPage.expectError("Invalid email or password")
}
}
Accessibility Identifiers¶
Ajout dans React Native¶
// ✅ Ajouter accessibilityIdentifier pour tests
<TextInput
accessibilityIdentifier="email-input"
placeholder="Email"
keyboardType="email-address"
/>
<TextInput
accessibilityIdentifier="password-input"
placeholder="Password"
secureTextEntry
/>
<TouchableOpacity
accessibilityIdentifier="login-button"
onPress={handleLogin}
>
<Text>Login</Text>
</TouchableOpacity>
Sélecteurs robustes¶
// ✅ EXCELLENT - accessibilityIdentifier
app.buttons["upload-document-button"]
app.textFields["email-input"]
// ✅ BON - accessibilityLabel
app.buttons["Upload Document"]
// ⚠️ ACCEPTABLE - text content
app.staticTexts["Welcome back"]
// ❌ FRAGILE - index
app.buttons.element(boundBy: 0) // Peut casser si ordre change
Interactions utilisateur¶
Tap¶
// Simple tap
app.buttons["submit-button"].tap()
// Double tap
app.images["photo"].doubleTap()
// Long press
app.buttons["context-menu"].press(forDuration: 1.0)
// Tap à coordonnées spécifiques
let coordinate = app.coordinate(
withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)
)
coordinate.tap()
Text Input¶
// Saisie texte
let emailField = app.textFields["email-input"]
emailField.tap()
emailField.typeText("user@example.com")
// Effacer et saisir
emailField.tap()
let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: 50)
emailField.typeText(deleteString)
emailField.typeText("newemail@example.com")
// Paste
emailField.tap()
emailField.press(forDuration: 1.0) // Long press
app.menuItems["Paste"].tap()
Swipe & Scroll¶
// Swipe
app.swipeUp()
app.swipeDown()
app.swipeLeft()
app.swipeRight()
// Scroll to element
let documentList = app.scrollViews.firstMatch
documentList.swipeUp(until: app.staticTexts["Document 50"].exists)
// Helper pour scroll
extension XCUIElement {
func swipeUp(until predicate: @autoclosure () -> Bool, maxAttempts: Int = 10) {
for _ in 0..<maxAttempts {
if predicate() {
return
}
swipeUp()
}
XCTFail("Element not found after \(maxAttempts) swipes")
}
}
Attentes (Expectations)¶
waitForExistence¶
// Attendre apparition élément (timeout 10s)
let dashboard = app.staticTexts["Dashboard"]
XCTAssertTrue(
dashboard.waitForExistence(timeout: 10),
"Dashboard should appear"
)
// Attendre disparition
let loadingSpinner = app.activityIndicators["loading"]
let disappeared = loadingSpinner.waitForNonExistence(timeout: 5)
XCTAssertTrue(disappeared, "Loading should finish")
Custom waitFor¶
// Helper: attendre condition
func wait(for condition: @autoclosure () -> Bool, timeout: TimeInterval = 10) {
let expectation = XCTestExpectation(description: "Waiting for condition")
let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
if condition() {
expectation.fulfill()
timer.invalidate()
}
}
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
timer.invalidate()
XCTAssertEqual(result, .completed, "Condition not met within \(timeout)s")
}
// Usage
wait(for: app.buttons["submit"].isEnabled)
Biométrie (Face ID / Touch ID)¶
Simulation biométrie¶
// Helpers/BiometricSimulator.swift
import XCTest
enum BiometricResult {
case success
case failure
}
func simulateBiometric(_ result: BiometricResult, in app: XCUIApplication) {
// Déclencher action biométrique
app.buttons["biometric-login-button"].tap()
// Attendre prompt système
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
switch result {
case .success:
// Simuler succès Face ID/Touch ID
if springboard.buttons["OK"].exists {
springboard.buttons["OK"].tap()
}
case .failure:
// Simuler échec
if springboard.buttons["Cancel"].exists {
springboard.buttons["Cancel"].tap()
}
}
}
Test biométrique¶
func test_TC_AUTH_03_loginWithBiometric() throws {
// Setup: Enable biometric in app
app.launchEnvironment["BIOMETRIC_ENROLLED"] = "1"
app.launch()
// When: User taps biometric login
simulateBiometric(.success, in: app)
// Then: Should login successfully
let dashboard = app.staticTexts["Dashboard"]
XCTAssertTrue(dashboard.waitForExistence(timeout: 5))
}
func test_TC_AUTH_04_biometricFailure() throws {
app.launchEnvironment["BIOMETRIC_ENROLLED"] = "1"
app.launch()
// When: Biometric fails
simulateBiometric(.failure, in: app)
// Then: Should show error
let error = app.alerts["Authentication Failed"]
XCTAssertTrue(error.exists)
}
Upload de fichiers¶
Test upload document¶
func test_TC_DOC_01_uploadDocument() throws {
// Navigate to upload screen
app.buttons["upload-button"].tap()
// Tap file picker
app.buttons["choose-file"].tap()
// Select file (simulé avec photo library)
let photosApp = XCUIApplication(bundleIdentifier: "com.apple.mobileslideshow")
if photosApp.images.count > 0 {
photosApp.images.firstMatch.tap()
}
// Wait for encryption
let encryptionComplete = app.staticTexts["Encryption complete"]
XCTAssertTrue(encryptionComplete.waitForExistence(timeout: 10))
// Submit upload
app.buttons["submit-upload"].tap()
// Verify success
let successAlert = app.alerts["Upload Successful"]
XCTAssertTrue(successAlert.waitForExistence(timeout: 5))
}
Alerts & Dialogs¶
Handling alerts¶
// Vérifier alerte existe
let alert = app.alerts["Error"]
XCTAssertTrue(alert.exists)
// Lire message
let message = alert.staticTexts.element(boundBy: 1).label
XCTAssertEqual(message, "Invalid email or password")
// Tap bouton alerte
alert.buttons["OK"].tap()
// Vérifier alerte disparue
XCTAssertFalse(alert.exists)
System dialogs¶
// Notifications permission
addUIInterruptionMonitor(withDescription: "Notifications") { alert in
if alert.buttons["Allow"].exists {
alert.buttons["Allow"].tap()
return true
}
return false
}
// Déclencher interrupt monitoring
app.tap() // Nécessaire pour activer monitor
// Camera/Photo permissions
addUIInterruptionMonitor(withDescription: "Photos") { alert in
alert.buttons["OK"].tap()
return true
}
Orientation & Rotation¶
func test_TC_UI_01_rotateToLandscape() throws {
// Portrait (default)
XCUIDevice.shared.orientation = .portrait
// Take screenshot
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "portrait"
add(attachment)
// Rotate to landscape
XCUIDevice.shared.orientation = .landscapeLeft
// Verify UI adapts
let dashboard = app.staticTexts["Dashboard"]
XCTAssertTrue(dashboard.exists)
// Screenshot landscape
let landscapeScreenshot = app.screenshot()
let landscapeAttachment = XCTAttachment(screenshot: landscapeScreenshot)
landscapeAttachment.name = "landscape"
add(landscapeAttachment)
}
Screenshots & Artifacts¶
Prendre screenshots¶
func test_TC_AUTH_01_loginFlow() throws {
// Screenshot avant action
let beforeScreenshot = app.screenshot()
let beforeAttachment = XCTAttachment(screenshot: beforeScreenshot)
beforeAttachment.name = "login-before"
beforeAttachment.lifetime = .keepAlways
add(beforeAttachment)
// Action
loginPage.login(email: "user@example.com", password: "password")
// Screenshot après action
let afterScreenshot = app.screenshot()
let afterAttachment = XCTAttachment(screenshot: afterScreenshot)
afterAttachment.name = "login-after"
afterAttachment.lifetime = .keepAlways
add(afterAttachment)
}
Automatic screenshots on failure¶
override func tearDownWithError() throws {
if let failure = try? XCTUnwrap(testRun?.failureCount), failure > 0 {
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "failure-\(name)"
attachment.lifetime = .keepAlways
add(attachment)
}
}
Isolation des tests¶
Reset app state¶
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
// Reset app data entre tests
app.launchArguments = ["UI-Testing", "RESET_STATE"]
// Disable animations (tests plus rapides)
app.launchEnvironment = ["DISABLE_ANIMATIONS": "1"]
app.launch()
}
// Dans l'app React Native
if (__DEV__ && NativeModules.LaunchArguments?.includes('RESET_STATE')) {
// Clear AsyncStorage
AsyncStorage.clear();
// Clear Keychain
Keychain.reset();
}
Parcours critiques ProbatioVault¶
TC-AUTH: Authentication¶
func test_TC_AUTH_01_fullAuthenticationFlow() throws {
// 1. Register
app.buttons["create-account"].tap()
app.textFields["email-input"].tap()
app.textFields["email-input"].typeText("newuser@example.com")
app.secureTextFields["password-input"].tap()
app.secureTextFields["password-input"].typeText("SecurePass123!")
app.buttons["register-button"].tap()
// 2. Enable biometric
let enableBiometric = app.staticTexts["Enable Face ID"]
XCTAssertTrue(enableBiometric.waitForExistence(timeout: 5))
app.buttons["enable-faceid-button"].tap()
// 3. Logout
app.buttons["logout-button"].tap()
// 4. Login with biometric
simulateBiometric(.success, in: app)
// Verify dashboard
let dashboard = app.staticTexts["Dashboard"]
XCTAssertTrue(dashboard.waitForExistence(timeout: 5))
}
TC-DOC: Documents¶
func test_TC_DOC_01_fullDocumentLifecycle() throws {
// 1. Upload
app.buttons["documents-tab"].tap()
app.buttons["upload-button"].tap()
app.buttons["choose-file"].tap()
// Select file (mock)
// ... file selection ...
let encryptionComplete = app.staticTexts["Encryption complete"]
XCTAssertTrue(encryptionComplete.waitForExistence(timeout: 10))
app.buttons["submit-upload"].tap()
// 2. View document
app.staticTexts["contract.pdf"].tap()
let documentViewer = app.otherElements["document-viewer"]
XCTAssertTrue(documentViewer.exists)
// 3. Share document (PRE)
app.buttons["share-button"].tap()
app.textFields["recipient-email"].tap()
app.textFields["recipient-email"].typeText("recipient@example.com")
app.buttons["share-document-button"].tap()
let successAlert = app.alerts["Document Shared"]
XCTAssertTrue(successAlert.waitForExistence(timeout: 5))
// 4. Certify
app.buttons["certify-button"].tap()
let certifiedBadge = app.images["certified-badge"]
XCTAssertTrue(certifiedBadge.waitForExistence(timeout: 5))
}
Exécution tests¶
Xcode¶
Command line¶
# Run all tests
xcodebuild test \
-workspace ios/ProbatioVault.xcworkspace \
-scheme ProbatioVault \
-destination 'platform=iOS Simulator,name=iPhone 14 Pro,OS=17.0'
# Run specific test class
xcodebuild test \
-workspace ios/ProbatioVault.xcworkspace \
-scheme ProbatioVault \
-destination 'platform=iOS Simulator,name=iPhone 14 Pro' \
-only-testing:ProbatioVaultUITests/AuthenticationTests
# Run on physical device
xcodebuild test \
-workspace ios/ProbatioVault.xcworkspace \
-scheme ProbatioVault \
-destination 'platform=iOS,name=Loïc's iPhone'
Fastlane (automatisation)¶
# Fastfile
lane :test_e2e do
run_tests(
workspace: "ios/ProbatioVault.xcworkspace",
scheme: "ProbatioVault",
devices: ["iPhone 14 Pro", "iPhone SE (3rd generation)"],
output_directory: "./test_output",
code_coverage: true
)
end
Checklist XCUITest¶
Avant commit¶
- Tests utilisent Page Object Model
- accessibilityIdentifier sur éléments clés
- waitForExistence avec timeout approprié
- Tests isolés (reset state entre tests)
- Screenshots sur échec automatiques
- Tous les tests passent (0 failures)
Escalade¶
Escalader vers Agent QA (coordinateur) si : - Tests flaky (intermittents) - Impossibilité de sélectionner éléments - Performance tests trop lente - Besoin de test sur device physique (pas simulator) - Problème avec biométrie simulator
Références¶
- XCUITest Docs: https://developer.apple.com/documentation/xctest
- UI Testing Guide: https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/testing_with_xcode/
- React Native Testing: https://reactnative.dev/docs/testing-overview
Historique¶
| Version | Date | Changement |
|---|---|---|
| 1.0.0 | 2026-01-14 | Création initiale |