Développement Mobile

Tester une app SwiftUI avec Swift Testing et les Xcode Previews

3 min de lecture

Tester du code est rarement la première priorité d’un débutant Swift, et pourtant les tests offrent une boucle de retour beaucoup plus courte qu’un cycle build-run-cliquer dans le simulateur. Apple a introduit Swift Testing en 2024 avec Swift 6.0, un framework moderne qui remplace progressivement XCTest pour les tests unitaires. En parallèle, les Xcode Previews permettent d’inspecter le rendu d’une vue SwiftUI sans la lancer dans le simulateur. Combinés, ces deux outils raccourcissent drastiquement la boucle d’itération. Ce tutoriel détaille les deux pas à pas avec un projet exemple, un view model observable et un client réseau qu’on teste sous toutes ses coutures.

📘 Guide principal de la série : Développer une application iOS avec Swift et SwiftUI : panorama 2026. Ce tutoriel suppose acquise la base SwiftUI ; pour le client réseau testé ici, voir Consommer une API REST en Swift avec async/await.

Prérequis

  • Xcode 26 sur macOS Tahoe.
  • Cible iOS 17 minimum (Swift Testing + Xcode Previews modernes).
  • Bases du langage Swift, notions de structs et closures.
  • Temps estimé : 80 à 120 minutes.
  • Niveau : intermédiaire.

Étape 1 — Ajouter une cible de tests à un projet existant

Si le projet a été créé sans cocher la case Include Tests, on ajoute une cible de tests a posteriori. La procédure est mécanique et ne demande pas de configuration manuelle complexe.

Dans le navigateur Xcode, cliquer sur le nom du projet (icône bleue racine), puis sur le bouton + en bas de la colonne Targets. Choisir Unit Testing Bundle, cliquer Next, accepter le nom suggéré (MonAppTests), associer au target principal. Xcode génère un fichier MonAppTests.swift avec un squelette XCTest. On peut le supprimer et créer un fichier SwiftTestingDemoTests.swift à la place.

import Testing
@testable import MonApp

@Suite("Suite de démarrage")
struct DemarrageTests {
    @Test func sanityCheck() async throws {
        #expect(1 + 1 == 2)
    }
}

@testable import ouvre l’accès aux symboles internal du module testé (sans cette annotation, seuls les symboles public sont visibles). La macro @Suite groupe les tests par sujet ; @Test marque une fonction de test ; #expect est la macro d’assertion. Lancer le test avec ⌘U doit afficher une coche verte dans le rapporteur.

Étape 2 — Écrire un test pour un view model @Observable

Un view model marqué @Observable est une classe ordinaire. On le teste en l’instanciant dans un test, en appelant ses méthodes et en vérifiant ses propriétés. Aucun harnais SwiftUI n’est requis.

@Observable
final class CompteurVM {
    var valeur: Int = 0
    var maximum: Int = 10

    func incrementer() {
        if valeur < maximum { valeur += 1 }
    }

    func reinitialiser() { valeur = 0 }
}

@Suite("CompteurVM")
struct CompteurVMTests {

    @Test func incrementerAugmenteLaValeur() {
        let vm = CompteurVM()
        vm.incrementer()
        #expect(vm.valeur == 1)
    }

    @Test func incrementerStagneAuMaximum() {
        let vm = CompteurVM()
        vm.maximum = 3
        for _ in 0..<5 { vm.incrementer() }
        #expect(vm.valeur == 3)
    }

    @Test func reinitialiserRamenAZero() {
        let vm = CompteurVM()
        vm.incrementer()
        vm.incrementer()
        vm.reinitialiser()
        #expect(vm.valeur == 0)
    }
}

Chaque test est indépendant : une nouvelle instance de CompteurVM est créée par test, ce qui élimine les effets de bord croisés. #expect évalue une expression booléenne et affiche les valeurs des variables impliquées en cas d'échec — un avantage sensible sur XCTAssertEqual qui devait recevoir les deux valeurs séparément.

Étape 3 — Paramétrer un test avec @Test(arguments:)

Tester la même logique sur plusieurs jeux d'entrées sans dupliquer le corps de la fonction est une grande force de Swift Testing. On passe les paramètres à la macro @Test et le framework exécute la fonction une fois par tuple.

@Test(arguments: [
    (0, 1),
    (5, 6),
    (9, 10),
    (10, 10)
])
func incrementationParametree(initial: Int, attendu: Int) {
    let vm = CompteurVM()
    vm.valeur = initial
    vm.incrementer()
    #expect(vm.valeur == attendu)
}

Le rapporteur affiche quatre exécutions distinctes, une par tuple. Si l'une échoue, on identifie immédiatement laquelle. Ce pattern remplace les anciennes boucles dans XCTestCase qui mélangeaient toutes les assertions dans un seul test agrégé.

Étape 4 — Tester du code asynchrone

Les tests qui consomment des fonctions async sont eux-mêmes marqués async. Pas de XCTestExpectation, pas de waitForExpectations — le framework attend simplement la fin de la fonction de test.

final class ClientAPIMock {
    let data: Data
    var nbAppels = 0

    init(data: Data) { self.data = data }

    func fetchPosts() async throws -> [Post] {
        nbAppels += 1
        return try JSONDecoder().decode([Post].self, from: data)
    }
}

@Test func clientDecodeUnePostSimple() async throws {
    let json = """
    [{ "userId": 1, "id": 42, "title": "T", "body": "B" }]
    """.data(using: .utf8)!

    let client = ClientAPIMock(data: json)
    let posts = try await client.fetchPosts()

    #expect(posts.count == 1)
    #expect(posts.first?.id == 42)
    #expect(client.nbAppels == 1)
}

La fonction de test peut throw et propager l'erreur ; le framework rapporte l'échec automatiquement. La triple guillemet de Swift permet d'écrire un JSON multi-lignes lisible dans le code source sans escape gymnastics. Cette technique du fixture inline évite la prolifération de fichiers JSON externes pour des tests simples.

Étape 5 — Tester un cas d'erreur

Pour vérifier qu'une fonction lève bien une erreur attendue, on utilise #expect(throws:). Cette macro accepte soit un type d'erreur, soit une closure de prédicat plus précise.

enum CalculError: Error, Equatable {
    case divisionParZero
}

func diviser(_ a: Int, par b: Int) throws -> Int {
    guard b != 0 else { throw CalculError.divisionParZero }
    return a / b
}

@Test func divisionParZeroLeveLErreur() {
    #expect(throws: CalculError.divisionParZero) {
        try diviser(10, par: 0)
    }
}

@Test func divisionNormaleReussit() throws {
    let r = try diviser(10, par: 2)
    #expect(r == 5)
}

La macro #expect(throws:) exécute la closure, capture l'erreur si elle est lancée, et vérifie qu'elle correspond à l'erreur attendue. Si la closure ne lève rien, le test échoue avec un message explicite. Combiné aux enums Swift, ce pattern modélise précisément les états d'erreur d'une API.

Étape 6 — Découvrir les Xcode Previews

Un Preview est un mini-rendu d'une vue SwiftUI exécuté dans le canvas de Xcode, sans lancer le simulateur. Cette boucle de retour est presque instantanée : modifier le code et voir le rendu se mettre à jour en moins d'une seconde.

struct CompteurView: View {
    @State private var modele = CompteurVM()

    var body: some View {
        VStack(spacing: 16) {
            Text("Valeur : \(modele.valeur)")
                .font(.title)
            Button("Incrémenter") { modele.incrementer() }
                .buttonStyle(.borderedProminent)
            Button("Réinitialiser") { modele.reinitialiser() }
                .buttonStyle(.bordered)
        }
        .padding()
    }
}

#Preview("Compteur — valeur initiale 0") {
    CompteurView()
}

#Preview("Compteur — valeur initiale 5") {
    let vm = CompteurVM()
    vm.valeur = 5
    return CompteurView()
}

La macro #Preview remplace les anciennes PreviewProvider structs. Plusieurs previews dans le même fichier produisent autant d'aperçus côte à côte — utile pour comparer des états (vide, plein, erreur) ou des configurations (mode clair/sombre). Pour activer un mode particulier, ajouter le modifier sur la vue : .preferredColorScheme(.dark), .environment(\\.locale, .init(identifier: "ar")), etc.

Étape 7 — Mode interactif et raccourcis Preview

Le canvas Xcode propose deux modes : statique (rendu rapide, pas d'interaction) et live (interaction réelle, animation, état). Le mode live est plus lent à démarrer mais permet de cliquer dans les boutons, taper dans les TextFields, faire défiler les listes — comme dans un mini-simulateur.

Pour basculer entre les modes, utiliser les boutons à droite du canvas ou les raccourcis : ⌥⌘P reprend le preview ; ⌥⌘⏎ bascule l'affichage du canvas ; cliquer sur l'icône Live en bas à droite active l'interaction. En cas de bug d'aperçu, le bouton Resume force un re-rendu propre.

Étape 8 — Lier Preview et données de test

Pour qu'un Preview affiche un état réaliste, on lui passe un view model pré-rempli avec des données factices. Définir un constructeur preview sur le modèle facilite cette pratique.

extension CompteurVM {
    static var preview: CompteurVM {
        let vm = CompteurVM()
        vm.valeur = 7
        vm.maximum = 20
        return vm
    }
}

#Preview("Compteur — état pré-rempli") {
    CompteurView()
        .environment(CompteurVM.preview)
}

Ce pattern centralise les données de démo et évite de répéter les valeurs dans chaque preview. Pour des modèles plus complexes (utilisateur avec photo, liste de posts), on peut placer ces stubs dans un fichier PreviewData.swift à part, exclu de la cible de production via Target Membership.

Étape 9 — Mesurer la performance avec Swift Testing

Au-delà des tests fonctionnels, on veut parfois mesurer la durée d'une opération. Swift Testing propose Test.timeLimit qui interrompt un test trop long, et la machinerie Duration permet de chronométrer manuellement.

@Test(.timeLimit(.seconds(1)))
func decodageRapide() throws {
    let big = String(repeating: "{\"k\":1},", count: 1000).dropLast()
    let json = "[\(big)]".data(using: .utf8)!
    struct Item: Codable { let k: Int }
    let items = try JSONDecoder().decode([Item].self, from: json)
    #expect(items.count == 1000)
}

Si le test dépasse la limite, il échoue avec un message explicite. Cette macro est utile pour détecter une régression de performance qui ne se voit pas à l'exécution sur une machine puissante.

Étape 10 — Exécuter les tests en ligne de commande

Pour intégrer les tests dans un pipeline CI, on les lance via xcodebuild test. La syntaxe est verbeuse mais cohérente avec les autres commandes de build.

xcodebuild test \\
    -project MonApp.xcodeproj \\
    -scheme MonApp \\
    -destination 'platform=iOS Simulator,name=iPhone 16 Pro'

La commande compile, lance le simulateur, exécute la cible de tests, puis affiche un résumé textuel. Le code de sortie est non-nul si un test échoue, ce qui permet aux runners CI (GitHub Actions, Bitrise, Xcode Cloud) de marquer la pipeline en échec. L'option -resultBundlePath sauvegarde un bundle réutilisable pour annoter les rapports.

Construire une suite de tests qui reste rentable dans le temps

Une suite de tests devient un boulet quand elle teste les détails d'implémentation plutôt que le comportement. Le symptôme typique : modifier un nom de variable interne casse vingt tests. Pour éviter ce piège, tester les contrats publics — ce qu'une fonction promet de faire — et pas la mécanique interne. Pour un view model, les tests décrivent la transition d'un état à un autre suite à un appel de méthode publique. Pour un client API, les tests vérifient que tel input produit telle sortie. La représentation interne (cache, retry, structures temporaires) reste libre d'évoluer sans casser les tests, à condition que les contrats soient respectés.

Une stratégie complémentaire consiste à appliquer la pyramide des tests classique. À la base, beaucoup de tests unitaires rapides sur les fonctions pures et les view models. Au milieu, quelques tests d'intégration qui combinent plusieurs unités (par exemple, un view model qui consomme un client API mocké). Au sommet, très peu de tests bout-en-bout (XCUITest qui pilote le simulateur). Cette répartition garde la suite rapide à exécuter — un blocage à 30 secondes pour lancer tous les tests décourage de les lancer souvent, ce qui défait leur intérêt. Cibler 80 % à 90 % de tests unitaires et 10 % à 20 % d'intégration est un équilibre éprouvé.

Erreurs fréquentes en tests Swift

Erreur Cause Solution
« No such module 'Testing' » Cible iOS antérieure à 17 ou Xcode antérieur à 16 Mettre à jour la cible et Xcode ; alternative XCTest pour les anciens projets
« Type X is not visible from this module » Oubli de @testable import Ajouter l'annotation pour ouvrir l'accès aux symboles internal
Preview affiche un écran blanc Vue qui crash silencieusement à l'instanciation Cliquer Resume, regarder la console Preview, simplifier l'initialisation
« This expression depends on a variable used outside of its observability scope » Capture dans une closure async Capturer explicitement avec [self] ou refactorer
Test passe localement, échoue en CI Différence de fuseau horaire ou de locale Forcer la locale dans le test via setlocale ou via FileManager
Preview lent à démarrer Mode live activé sur une vue lourde Passer en mode statique pour les itérations rapides ; live uniquement pour valider l'interaction

Foire aux questions

Swift Testing remplace-t-il complètement XCTest ?

Apple positionne Swift Testing comme la voie recommandée pour les nouveaux projets. XCTest reste supporté indéfiniment et reste utilisé pour les tests UI (XCUITest n'a pas d'équivalent Swift Testing à ce jour). On peut mélanger les deux dans une même cible.

Comment isoler les tests entre eux ?

Chaque test instancie ses propres dépendances dans son corps. Pour des dépendances coûteuses (base SQLite, gros fixture), utiliser @Suite(.serialized) avec une propriété partagée et une méthode de reset entre tests.

Peut-on mocker URLSession dans Swift Testing ?

Oui, via URLProtocol : créer une classe URLProtocolMock qui intercepte les requêtes et retourne des réponses pré-définies. Cette technique fonctionne dans Swift Testing comme dans XCTest. Une bibliothèque comme OHHTTPStubs simplifie la mise en place.

Quelle différence entre #expect et #require ?

#expect enregistre l'échec mais continue l'exécution du test. #require arrête le test immédiatement si la condition échoue. Utiliser #require quand la suite du test n'a pas de sens si la condition est fausse (par exemple un déballage d'optional).

Comment tester une vue SwiftUI elle-même ?

Les Xcode Previews sont l'outil visuel de référence. Pour des assertions automatisées sur le rendu (snapshot testing), des bibliothèques comme swift-snapshot-testing capturent une image de la vue et la comparent à une référence.

Les Xcode Previews sont-ils fiables sur Mac Intel ?

Moins qu'avec Apple Silicon. Les Previews exigent beaucoup de RAM et CPU ; sur Intel, ils peuvent timeout ou crasher fréquemment. Pour une expérience fluide, un Mac M1 ou supérieur est nettement plus confortable.

Ressources officielles

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité