Swift Testing Framework en entretien 2026 : macros #expect et #require vs XCTest
Maîtrisez le nouveau Swift Testing Framework pour vos entretiens iOS : macros #expect et #require, migration depuis XCTest, patterns avancés et pièges à éviter.

Introduit à la WWDC 2024 et livré avec Swift 6 et Xcode 16, Swift Testing représente une refonte complète de la façon dont les tests fonctionnent en Swift. Ce framework remplace les 40+ assertions XCTest par seulement deux macros : #expect et #require. Les recruteurs testent désormais régulièrement cette connaissance lors des entretiens iOS.
Chaque section reproduit une question d'entretien technique avec sa réponse détaillée et du code fonctionnel. La progression va des concepts fondamentaux vers les patterns avancés.
Fondamentaux de Swift Testing
Question 1 : Quelles différences majeures entre Swift Testing et XCTest ?
Swift Testing apporte cinq changements fondamentaux par rapport à XCTest :
- Syntaxe déclarative : attribut
@Testau lieu de préfixestest - Deux macros universelles :
#expectet#requireremplacent 40+ assertions - Parallélisme par défaut : tous les tests tournent en parallèle
- Support async natif : intégration complète avec Swift Concurrency
- Cross-platform : fonctionne sur Apple, Linux et Windows
import XCTest
import Testing
// ❌ Ancien pattern XCTest
class UserServiceXCTests: XCTestCase {
// Doit commencer par "test"
func testUserCreation() {
let user = User(name: "Alice", age: 25)
// Assertions multiples et verbeuses
XCTAssertNotNil(user)
XCTAssertEqual(user.name, "Alice")
XCTAssertGreaterThan(user.age, 18)
XCTAssertTrue(user.isValid)
}
}
// ✅ Nouveau pattern Swift Testing
@Test("User creation with valid data")
func userCreation() {
let user = User(name: "Alice", age: 25)
// Une seule macro pour toutes les vérifications
#expect(user.name == "Alice")
#expect(user.age > 18)
#expect(user.isValid)
}La différence clé réside dans l'expressivité : Swift Testing utilise des expressions Swift standard au lieu d'assertions spécialisées, rendant les tests plus lisibles et les messages d'erreur plus informatifs.
Question 2 : Comment fonctionne la macro #expect ?
La macro #expect valide qu'une expression booléenne est vraie. Elle capture automatiquement les valeurs évaluées pour fournir des messages d'erreur détaillés. Contrairement à XCTAssert, elle utilise la syntaxe Swift native.
import Testing
@Test func basicExpectations() {
let numbers = [1, 2, 3, 4, 5]
let user = User(name: "Bob", email: "bob@example.com")
// Comparaisons simples - expression Swift standard
#expect(numbers.count == 5)
#expect(user.name == "Bob")
// Comparaisons avec opérateurs
#expect(numbers.first! < numbers.last!)
#expect(user.email.contains("@"))
// Vérification de nil
#expect(numbers.first != nil)
// Vérification de collections
#expect(numbers.contains(3))
#expect(!numbers.isEmpty)
}
@Test func expectWithCustomMessage() {
let balance = 150.0
let withdrawAmount = 200.0
// Message personnalisé pour clarifier l'intention
#expect(
balance >= withdrawAmount,
"Insufficient funds: balance \(balance) < withdrawal \(withdrawAmount)"
)
}Quand un #expect échoue, le test continue son exécution. Cette caractéristique permet de collecter plusieurs échecs dans un seul test run, facilitant le diagnostic.
Question 3 : Quelle différence entre #expect et #require ?
La différence fondamentale concerne le comportement après échec :
#expect: enregistre l'échec et continue l'exécution#require: enregistre l'échec et stoppe immédiatement le test
#require doit toujours être utilisé avec try car il peut lancer une erreur.
import Testing
struct ApiResponse {
let data: Data?
let items: [Item]?
}
@Test func demonstrateExpectContinues() {
let values = [1, 2, 3]
// Premier #expect échoue mais le test continue
#expect(values.count == 10) // ❌ Échec enregistré
// Ces vérifications s'exécutent quand même
#expect(values.first == 1) // ✅ Succès
#expect(values.last == 3) // ✅ Succès
// Résultat : 1 échec, 2 succès dans le même test
}
@Test func demonstrateRequireStops() throws {
let response = ApiResponse(data: nil, items: nil)
// #require stoppe immédiatement si la condition échoue
let data = try #require(response.data) // ❌ Échec et STOP
// Ce code ne s'exécute JAMAIS si data est nil
let json = try JSONDecoder().decode(User.self, from: data)
#expect(json.name == "Alice")
}En pratique, #require remplace parfaitement XCTUnwrap pour le unwrapping d'optionnels sécurisé.
Utiliser #require quand les étapes suivantes dépendent du résultat (unwrapping, préconditions). Utiliser #expect pour des vérifications indépendantes qui peuvent échouer sans bloquer le reste du test.
Patterns avancés avec #require
Question 4 : Comment utiliser #require pour unwrapper des optionnels ?
#require excelle dans le unwrapping d'optionnels. Il retourne la valeur non-optionnelle si elle existe, ou échoue le test immédiatement si nil.
import Testing
struct UserProfile {
let id: Int
let name: String
let settings: Settings?
}
struct Settings {
let theme: String
let notifications: Bool
}
@Test func unwrapOptionalChain() throws {
let profile = UserProfile(
id: 1,
name: "Alice",
settings: Settings(theme: "dark", notifications: true)
)
// Unwrap sécurisé - stoppe si nil
let settings = try #require(profile.settings)
// Maintenant settings n'est plus optionnel
#expect(settings.theme == "dark")
#expect(settings.notifications == true)
}
@Test func unwrapArrayElement() throws {
let users = ["Alice", "Bob", "Charlie"]
// Unwrap du premier élément
let firstUser = try #require(users.first)
#expect(firstUser == "Alice")
// Unwrap avec index sécurisé
let secondUser = try #require(users[safe: 1])
#expect(secondUser == "Bob")
}
@Test func unwrapDictionaryValue() throws {
let config: [String: Any] = [
"apiUrl": "https://api.example.com",
"timeout": 30,
"retryCount": 3
]
// Unwrap et cast en une seule opération
let apiUrl = try #require(config["apiUrl"] as? String)
let timeout = try #require(config["timeout"] as? Int)
#expect(apiUrl.contains("https"))
#expect(timeout > 0)
}Cette approche élimine les pyramides de guard let et rend le code de test linéaire et lisible.
Question 5 : Comment tester qu'une fonction lance une erreur ?
Swift Testing propose #expect(throws:) pour vérifier qu'une fonction lance bien une erreur spécifique.
import Testing
enum ValidationError: Error, Equatable {
case emptyName
case invalidEmail
case underAge(minimum: Int)
}
struct Validator {
static func validateUser(name: String, email: String, age: Int) throws {
guard !name.isEmpty else {
throw ValidationError.emptyName
}
guard email.contains("@") else {
throw ValidationError.invalidEmail
}
guard age >= 18 else {
throw ValidationError.underAge(minimum: 18)
}
}
}
@Test func testThrowsSpecificError() {
// Vérifie qu'une erreur spécifique est lancée
#expect(throws: ValidationError.emptyName) {
try Validator.validateUser(name: "", email: "test@mail.com", age: 25)
}
#expect(throws: ValidationError.invalidEmail) {
try Validator.validateUser(name: "Alice", email: "invalid", age: 25)
}
}
@Test func testThrowsErrorType() {
// Vérifie le type d'erreur sans valeur spécifique
#expect(throws: ValidationError.self) {
try Validator.validateUser(name: "Bob", email: "bob@mail.com", age: 15)
}
}
@Test func testThrowsWithInspection() throws {
// Capture l'erreur pour inspection détaillée
let error = try #require(
throws: ValidationError.self
) {
try Validator.validateUser(name: "Charlie", email: "charlie@mail.com", age: 16)
}
// Vérifie les détails de l'erreur
if case .underAge(let minimum) = error {
#expect(minimum == 18)
}
}Cette syntaxe remplace XCTAssertThrowsError avec une API plus claire et type-safe.
Prêt à réussir tes entretiens iOS ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
Organisation des tests avec @Test et @Suite
Question 6 : Comment organiser les tests avec @Suite ?
@Suite groupe logiquement des tests liés. Contrairement à XCTestCase, il ne nécessite pas d'héritage de classe.
import Testing
// Suite pour les tests d'authentification
@Suite("Authentication Tests")
struct AuthenticationTests {
// Propriété partagée pour tous les tests de la suite
let authService = AuthService()
@Test("Login with valid credentials succeeds")
func loginWithValidCredentials() async throws {
let result = try await authService.login(
email: "user@example.com",
password: "validPass123"
)
#expect(result.isSuccess)
let token = try #require(result.token)
#expect(!token.isEmpty)
}
@Test("Login with invalid password fails")
func loginWithInvalidPassword() async {
let result = await authService.login(
email: "user@example.com",
password: "wrong"
)
#expect(!result.isSuccess)
#expect(result.error == .invalidCredentials)
}
}
// Suites imbriquées pour une organisation hiérarchique
@Suite("User Management")
struct UserManagementTests {
@Suite("Creation")
struct CreationTests {
@Test func createUserWithValidData() {
// Test de création
}
@Test func createUserWithDuplicateEmail() {
// Test d'erreur doublon
}
}
@Suite("Deletion")
struct DeletionTests {
@Test func deleteExistingUser() {
// Test de suppression
}
@Test func deleteNonExistentUser() {
// Test d'erreur
}
}
}Les suites permettent d'exécuter des sous-ensembles de tests et d'organiser les rapports de façon lisible.
Question 7 : Comment utiliser les traits pour configurer les tests ?
Les traits modifient le comportement des tests : conditions d'exécution, tags, timeouts, etc.
import Testing
@Suite("API Integration Tests")
struct APITests {
// Test désactivé temporairement
@Test(.disabled("Backend en maintenance"))
func fetchUserProfile() async {
// Ne s'exécute pas
}
// Test conditionnel selon la plateforme
@Test
@available(iOS 17, *)
func useNewAPIFeature() {
// S'exécute uniquement sur iOS 17+
}
// Test avec tags pour filtrage
@Test(.tags(.critical, .network))
func criticalNetworkOperation() async throws {
// Test tagué pour filtrage
}
// Test avec timeout personnalisé
@Test(.timeLimit(.minutes(2)))
func longRunningOperation() async {
// Doit terminer en 2 minutes max
}
// Combinaison de traits
@Test(
"Complex data sync",
.tags(.slow),
.timeLimit(.minutes(5)),
.bug("JIRA-1234", "Flaky on CI")
)
func complexDataSync() async throws {
// Test documenté avec bug connu
}
}
// Définition de tags personnalisés
extension Tag {
@Tag static var critical: Self
@Tag static var network: Self
@Tag static var slow: Self
}Les traits rendent les tests auto-documentés et permettent une exécution sélective via la ligne de commande.
Tests paramétrés
Question 8 : Comment créer des tests paramétrés ?
Swift Testing permet d'exécuter le même test avec différentes entrées via les paramètres.
import Testing
struct EmailValidator {
static func isValid(_ email: String) -> Bool {
let pattern = #"^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$"#
return email.range(of: pattern, options: .regularExpression) != nil
}
}
// Test paramétré avec une collection
@Test(arguments: [
"user@example.com",
"test.name@domain.org",
"contact@company.co.uk"
])
func validEmailsAreAccepted(email: String) {
#expect(EmailValidator.isValid(email))
}
@Test(arguments: [
"invalid",
"@nodomain.com",
"no@tld",
"spaces in@email.com"
])
func invalidEmailsAreRejected(email: String) {
#expect(!EmailValidator.isValid(email))
}
// Test avec tuples pour cas input/output
@Test(arguments: [
("hello", "HELLO"),
("World", "WORLD"),
("Swift", "SWIFT")
])
func uppercaseConversion(input: String, expected: String) {
#expect(input.uppercased() == expected)
}
// Test avec produit cartésien de deux collections
@Test(arguments: [1, 2, 3], ["a", "b"])
func combinationTest(number: Int, letter: String) {
// S'exécute pour (1,"a"), (1,"b"), (2,"a"), (2,"b"), (3,"a"), (3,"b")
let combined = "\(number)\(letter)"
#expect(combined.count == 2)
}Chaque combinaison de paramètres génère un test indépendant, facilitant l'identification des cas en échec.
Question 9 : Comment tester du code asynchrone ?
Swift Testing intègre nativement async/await, simplifiant drastiquement les tests asynchrones.
import Testing
actor DataStore {
private var items: [String] = []
func add(_ item: String) {
items.append(item)
}
func getAll() -> [String] {
items
}
func clear() {
items.removeAll()
}
}
@Suite("Async Operations")
struct AsyncTests {
@Test func basicAsyncOperation() async {
// Pas besoin d'expectation ou de wait
let result = await fetchData()
#expect(!result.isEmpty)
}
@Test func asyncWithTimeout() async throws {
// Utilise Task.sleep pour simuler un délai
try await Task.sleep(for: .milliseconds(100))
let data = await loadConfiguration()
let config = try #require(data)
#expect(config.isValid)
}
@Test func testActorIsolation() async {
let store = DataStore()
// Opérations sur actor de manière séquentielle
await store.add("Item 1")
await store.add("Item 2")
let items = await store.getAll()
#expect(items.count == 2)
#expect(items.contains("Item 1"))
}
@Test func testConcurrentOperations() async {
let store = DataStore()
// Exécution concurrente avec TaskGroup
await withTaskGroup(of: Void.self) { group in
for i in 1...10 {
group.addTask {
await store.add("Item \(i)")
}
}
}
let items = await store.getAll()
#expect(items.count == 10)
}
}
// Helpers asynchrones pour les tests
func fetchData() async -> [String] {
try? await Task.sleep(for: .milliseconds(50))
return ["data1", "data2"]
}
func loadConfiguration() async -> Configuration? {
Configuration(isValid: true)
}
struct Configuration {
let isValid: Bool
}Swift Testing exécute les tests en parallèle par défaut. Pour les tests qui modifient un état partagé, utiliser .serialized sur la suite ou isoler l'état avec des actors.
Migration XCTest vers Swift Testing
Question 10 : Comment migrer progressivement de XCTest ?
Les deux frameworks coexistent dans le même projet. Une migration progressive est recommandée.
import XCTest
import Testing
// ⚠️ RÈGLE CRITIQUE : Ne jamais mélanger les frameworks dans un même test
// ❌ INCORRECT - Mélange interdit
class BadMixedTest: XCTestCase {
func testMixed() {
#expect(true) // Ne fonctionne pas dans XCTestCase
}
}
// ✅ CORRECT - XCTest pur pour tests existants
class LegacyUserTests: XCTestCase {
func testUserCreation() {
let user = User(name: "Test")
XCTAssertNotNil(user)
XCTAssertEqual(user.name, "Test")
}
}
// ✅ CORRECT - Swift Testing pour nouveaux tests
@Suite("User Tests - Modern")
struct ModernUserTests {
@Test func userCreation() {
let user = User(name: "Test")
#expect(user.name == "Test")
}
}
// Stratégie de migration par fichier
// 1. Identifier les tests à migrer (commencer par les plus simples)
// 2. Créer un nouveau fichier avec @Suite
// 3. Réécrire les tests un par un
// 4. Supprimer l'ancien fichier XCTest une fois validé| XCTest | Swift Testing |
|--------|---------------|
| XCTAssertTrue(x) | #expect(x) |
| XCTAssertFalse(x) | #expect(!x) |
| XCTAssertEqual(a, b) | #expect(a == b) |
| XCTAssertNil(x) | #expect(x == nil) |
| XCTAssertNotNil(x) | #expect(x != nil) |
| XCTUnwrap(x) | try #require(x) |
| XCTAssertThrowsError | #expect(throws:) |
Question 11 : Quelles fonctionnalités XCTest ne sont pas encore dans Swift Testing ?
Swift Testing (Swift 6) ne couvre pas encore tous les cas d'usage de XCTest.
// ❌ NON SUPPORTÉ : Tests de performance
// Rester sur XCTest pour mesurer les performances
class PerformanceTests: XCTestCase {
func testPerformance() {
measure {
// Code à mesurer
_ = (0..<1000).map { $0 * 2 }
}
}
}
// ❌ NON SUPPORTÉ : Tests UI (XCUITest)
// Continuer d'utiliser XCUITest pour les tests d'interface
class UITests: XCTestCase {
func testLoginFlow() {
let app = XCUIApplication()
app.launch()
// Tests UI...
}
}
// ✅ SUPPORTÉ : Tests d'intégration async
@Test func integrationTest() async throws {
let api = APIClient()
let response = try await api.fetchUsers()
#expect(!response.isEmpty)
}
// ✅ SUPPORTÉ : Mocking avec protocols
@Test func mockingWithProtocols() async {
let mockService = MockUserService()
let viewModel = UserViewModel(service: mockService)
await viewModel.loadUser(id: 1)
#expect(viewModel.user?.name == "Mock User")
}Pour les projets avec tests UI ou de performance, maintenir XCTest pour ces cas spécifiques.
Prêt à réussir tes entretiens iOS ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
Questions pièges d'entretien
Question 12 : Pourquoi #require nécessite try mais pas #expect ?
#require peut lancer une erreur si la condition échoue, car il doit interrompre le test. #expect ne fait qu'enregistrer l'échec et continue, donc ne lance rien.
import Testing
@Test func explainTryRequirement() throws {
let optionalValue: String? = nil
// #expect retourne Void - pas d'erreur lancée
// Le test continue même si ça échoue
#expect(optionalValue != nil) // Pas de try
// #require peut lancer ExpectationFailedError
// Le test s'arrête si ça échoue
// Doit être marqué avec try
let value = try #require(optionalValue) // Requiert try
// Si on arrive ici, optionalValue n'était pas nil
#expect(!value.isEmpty)
}
// La signature interne ressemble à :
// func #expect(_ condition: Bool) -> Void
// func #require<T>(_ value: T?) throws -> TCette distinction architecturale permet au compilateur de garantir la gestion correcte des échecs.
Question 13 : Comment Swift Testing gère-t-il le parallélisme ?
Par défaut, tous les tests s'exécutent en parallèle, ce qui accélère les suites de tests mais requiert une isolation correcte de l'état.
import Testing
// ❌ PROBLÈME : État partagé mutable
var sharedCounter = 0 // Danger en parallèle !
@Suite("Problematic Parallel Tests")
struct ProblematicTests {
@Test func incrementCounter1() {
sharedCounter += 1 // Race condition !
}
@Test func incrementCounter2() {
sharedCounter += 1 // Race condition !
}
}
// ✅ SOLUTION 1 : Forcer l'exécution séquentielle
@Suite("Sequential Tests", .serialized)
struct SequentialTests {
static var counter = 0
@Test func first() {
Self.counter += 1
#expect(Self.counter == 1)
}
@Test func second() {
Self.counter += 1
#expect(Self.counter == 2)
}
}
// ✅ SOLUTION 2 : Isoler l'état par test
@Suite("Isolated Tests")
struct IsolatedTests {
@Test func independentTest1() {
var localCounter = 0
localCounter += 1
#expect(localCounter == 1)
}
@Test func independentTest2() {
var localCounter = 0
localCounter += 1
#expect(localCounter == 1)
}
}
// ✅ SOLUTION 3 : Utiliser un actor pour état partagé
actor TestState {
var value = 0
func increment() -> Int {
value += 1
return value
}
}
@Suite("Actor-based Tests")
struct ActorTests {
let state = TestState()
@Test func safeIncrement() async {
let result = await state.increment()
#expect(result > 0)
}
}Le trait .serialized garantit l'exécution séquentielle d'une suite entière.
Question 14 : Comment utiliser les confirmations pour les callbacks ?
Pour les API avec callbacks (non-async), Swift Testing propose confirmation.
import Testing
// Service legacy avec callback
class LegacyService {
func fetchData(completion: @escaping (Result<String, Error>) -> Void) {
DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
completion(.success("Data loaded"))
}
}
}
@Test func testCallbackWithConfirmation() async {
let service = LegacyService()
// confirmation attend qu'elle soit appelée
await confirmation("Data callback received") { confirm in
service.fetchData { result in
if case .success(let data) = result {
#expect(data == "Data loaded")
confirm() // Signale que le callback a été exécuté
}
}
}
}
// Pour les callbacks appelés plusieurs fois
@Test func testMultipleCallbacks() async {
let publisher = EventPublisher()
// expectedCount spécifie le nombre d'appels attendus
await confirmation("Events received", expectedCount: 3) { confirm in
publisher.onEvent = { event in
#expect(!event.isEmpty)
confirm() // Appelé 3 fois
}
publisher.emit("Event 1")
publisher.emit("Event 2")
publisher.emit("Event 3")
}
}
class EventPublisher {
var onEvent: ((String) -> Void)?
func emit(_ event: String) {
DispatchQueue.global().async {
self.onEvent?(event)
}
}
}confirmation remplace élégamment XCTestExpectation et wait(for:timeout:).
Conclusion
Swift Testing représente l'avenir des tests sur les plateformes Apple. Les deux macros #expect et #require simplifient radicalement l'écriture de tests tout en améliorant la qualité des messages d'erreur.
Points clés à retenir pour les entretiens :
- ✅
#expectcontinue après échec,#requirestoppe immédiatement - ✅
#requirenécessitetrycar il peut lancer une erreur - ✅ Swift Testing tourne en parallèle par défaut
- ✅ Les deux frameworks coexistent mais ne doivent pas être mélangés dans un même test
- ✅ XCTest reste nécessaire pour les tests UI et de performance
- ✅ Les traits permettent de configurer finement le comportement des tests
- ✅ Les tests paramétrés évitent la duplication de code
La migration vers Swift Testing peut se faire progressivement, fichier par fichier, en commençant par les tests les plus simples.
Passe à la pratique !
Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.
Tags
Partager
Articles similaires

Top 25 questions d'entretien Swift pour développeurs iOS
Préparez vos entretiens iOS avec les 25 questions Swift les plus posées : optionals, closures, ARC, protocols, async/await et patterns avancés.

Questions entretien technique iOS senior France 2026 : architecture et design patterns
Préparez votre entretien iOS senior avec les questions clés sur MVVM, VIPER, Clean Architecture et les design patterns. Guide complet avec exemples Swift.

Combine vs async/await en Swift : patterns de migration progressive
Guide complet sur la migration de Combine vers async/await en Swift : stratégies progressives, bridging patterns, et coexistence des deux paradigmes dans une codebase iOS.