CodeGym /Cours /Swift SELF /Des mocks via protocol : MockBookRepository

Des mocks via protocol : MockBookRepository

Swift SELF
Niveau 53, Leçon 0
Disponible

1. Pourquoi un service a besoin d'un mock de repository

Quand vous commencez a ecrire un programme, tout semble simple : il y a une fonction, elle calcule quelque chose, un test verifie un nombre en sortie — parfait. Mais des que vous arrivez a une application « d'adultes » (par exemple, notre projet CLI LibraryCLI), la logique commence a se repandre entre plusieurs couches. Le service resout une tache metier, tandis que le repository lit et ecrit les donnees. Et si, dans les tests du service, vous commencez par hasard a lire un vrai fichier — felicitations, vous avez maintenant un « test » qui depend du disque, des chemins, des droits d'acces et de la phase de la lune.

Imaginez un scenario typique : le service « renommer un livre » doit recuperer le livre depuis le repository, modifier le titre puis l'enregistrer. Dans une vraie application, le repository peut etre un fichier JSON, une base de donnees, un reseau — n'importe quoi. Et si le test du service utilise le vrai repository, vous cessez de tester le service en tant que tel : vous testez simultanement le service, le systeme de fichiers et votre niveau de patience.

Pour que le test soit rapide, deterministe et clair, dans les tests de service nous faisons un remplacement : au lieu du vrai repository, nous utilisons une implementation de test qui se comporte de facon previsible. C'est cela, un mock.

2. Contrat via protocol et injection de dependance

Tout mock part d'une idee simple : si une dependance ne peut pas etre remplacee, elle ne peut pas etre correctement testee. C'est pourquoi nous faisons dependre le service non pas de JSONFileBookRepository, mais d'une abstraction : BookRepository.

En Swift, la forme naturelle de l'abstraction est protocol. Il decrit « ce que sait faire le repository », mais ne dit pas « comment il le fait ». En production, cela peut etre du JSON ; dans les tests, un mock ; pour le service, cela ne change rien : il travaille avec le contrat.

Schema des dependances

flowchart TD
    CLI["LibraryCLI (commands)"] --> Service["LibraryService"]
    Service -->|depends on protocol| RepoP["BookRepository (protocol)"]
    RepoP --> Prod["JSONFileBookRepository (prod)"]
    RepoP --> Mock["MockBookRepository (tests)"]

Le service se trouve au centre : il doit etre testable. Pour cela, sa dependance doit etre exprimee comme un protocol, et l'injection se fait via init (dependency injection via le constructeur).

3. Mini-domaine pour les exemples : Book, BookID et les erreurs

Avant d'ecrire un mock, nous devons d'abord « dessiner » minimalement le monde dans lequel il existe. Dans les exemples ci-dessous, nous utilisons un modele de domaine simplifie pour un livre. Dans votre projet, il peut etre plus riche, mais le principe ne change pas.

Commencons par BookID et Book. L'essentiel ici est que les types soient simples et Equatable, afin que les tests puissent comparer facilement les valeurs (dans les tests, nous aimons les comparaisons, parce qu'elles ne discutent pas, contrairement aux gens).

import Foundation

struct BookID: Equatable, Hashable {
    let rawValue: String
}

struct Book: Equatable {
    let id: BookID
    let title: String
    let author: String
}

Ajoutons maintenant une erreur de repository. Il est important qu'elle soit elle aussi Equatable : cela permettra de verifier les erreurs dans les tests « sur le fond », et non via une comparaison de chaines.

enum BookRepositoryError: Error, Equatable {
    case notFound(id: BookID)
    case writeFailed
}

4. Le protocole BookRepository : le contrat du stockage

Le protocole du repository est cette fameuse « prise » dans laquelle on peut brancher soit une implementation reale, soit une implementation de test. Ici, il est important de ne pas compliquer inutilement : le protocole doit etre assez petit pour que le mock reste simple et les tests lisibles.

Imaginons que, pour notre scenario, le service ait besoin de deux operations : charger un livre par ID et enregistrer le livre mis a jour.

protocol BookRepository {
    func fetchBook(id: BookID) throws -> Book
    func saveBook(_ book: Book) throws
}

Remarquez deux nuances qui aident enormement dans les tests.

Premiere nuance : les methodes throws. Cela nous donne un contrat clair : le repository renvoie soit un livre, soit une erreur. Deuxieme nuance : nous ne rendons pas les methodes asynchrones et nous n'utilisons pas de callbacks — ce sera un autre sujet, et pour l'instant il est important de garder l'exemple « sec et previsible ».

5. LibraryService : la logique de renommage d'un livre

Creons maintenant le service. Il contient le « sens metier » : renommer un livre. Le repository, lui, est l'infrastructure. Au niveau du raisonnement dans les tests, cela signifie : « je veux verifier que le service renomme correctement le livre et reagit correctement aux erreurs du repository ».

Le service dependra de BookRepository et le recevra via init.

final class LibraryService {
    private let repo: BookRepository

    init(repo: BookRepository) {
        self.repo = repo
    }

    func renameBook(id: BookID, to newTitle: String) throws -> Book {
        let book = try repo.fetchBook(id: id)
        let updated = Book(id: book.id, title: newTitle, author: book.author)
        try repo.saveBook(updated)
        return updated
    }
}

Remarquez : dans le service, il n'y a rien sur les fichiers, JSON et les chemins. C'est excellent. Moins le service sait de choses sur le « materiel », plus il est facile a tester.

6. Stub-mock MockBookRepository base sur Result

Dans les tests, nous voulons controler le comportement du repository : parfois qu'il renvoie un livre, parfois qu'il lance .notFound, parfois qu'il simule une erreur d'enregistrement.

La facon la plus pratique de stocker une « reponse predefinie » est Result. Il s'agit litteralement du type « succes ou erreur », et la bibliotheque standard fournit Result<Success, Failure: Error>.

De plus, Result possede une methode get(), qui renvoie soit une valeur, soit lance une erreur — ce qui correspond parfaitement aux methodes throws. Cela transforme le mock en code tres court et tres clair.

Creons MockBookRepository. Dans cette lecon, nous utilisons le mock comme un stub : il repond avec des donnees preparees a l'avance et rend la dependance deterministe.

final class MockBookRepository: BookRepository {
    var fetchResult: Result<Book, BookRepositoryError> =
        .failure(.notFound(id: BookID(rawValue: "stub")))

    var saveResult: Result<Void, BookRepositoryError> = .success(())

    func fetchBook(id: BookID) throws -> Book {
        try fetchResult.get()
    }

    func saveBook(_ book: Book) throws {
        _ = try saveResult.get()
    }
}

Ici, il y a presque une beaute « scolaire » : pour changer le comportement du mock, le test assigne simplement une autre valeur a fetchResult ou saveResult. Pas de fichiers. Pas de JSON. Pas de magie. Juste du determinisme.

7. Tests du service avec un mock de repository

Ci-dessous, trois scenarios de base qui apportent generalement le maximum de benefices au debut : renommage reussi, erreur au chargement et erreur a l'enregistrement.

Succes : le repository renvoie le livre, l'enregistrement passe

Nous attendons ceci : si le repository renvoie un livre, le service renvoie le livre mis a jour avec le nouveau titre.

import XCTest
@testable import LibraryCLI

final class LibraryServiceTests: XCTestCase {
    func testRenameBook_success_returnsUpdatedBook() throws {
        let repo = MockBookRepository()
        repo.fetchResult = .success(Book(
            id: BookID(rawValue: "b1"),
            title: "Old",
            author: "Alice"
        ))

        let service = LibraryService(repo: repo)
        let updated = try service.renameBook(id: BookID(rawValue: "b1"), to: "New")

        XCTAssertEqual(updated.title, "New")
        XCTAssertEqual(updated.author, "Alice")
    }
}

Remarquez une subtilite : nous n'essayons pas de verifier que « le service a bien appele save ». Cela releve deja d'une verification des interactions (approche spy) et appartient a la prochaine lecon. Ici, nous verifions le comportement via le resultat : le service a renvoye le modele mis a jour, donc la logique de transformation des donnees fonctionne.

Erreur de chargement : le repository lance .notFound

Nous voulons fixer le contrat : si le repository n'a pas trouve le livre, alors le service doit propager cette erreur vers l'exterieur (ou la transformer — mais cela depend deja de votre choix de conception ; pour l'instant, nous la propageons simplement).

import XCTest
@testable import LibraryCLI

final class LibraryServiceErrorTests: XCTestCase {
    func testRenameBook_whenNotFound_throwsNotFound() {
        let repo = MockBookRepository()
        let missingID = BookID(rawValue: "404")
        repo.fetchResult = .failure(.notFound(id: missingID))

        let service = LibraryService(repo: repo)

        XCTAssertThrowsError(try service.renameBook(id: missingID, to: "New")) { error in
            XCTAssertEqual(error as? BookRepositoryError, .notFound(id: missingID))
        }
    }
}

Ici, nous utilisons une technique vue dans les lecons precedentes : a l'interieur de XCTAssertThrowsError, l'erreur arrive comme Error, donc nous la castons vers le type concret avec as? et nous la comparons comme Equatable.

Erreur d'enregistrement : le repository « plante » sur save

Autre scenario utile : le livre a ete trouve, mais son enregistrement a echoue (par exemple, le disque est indisponible). Dans une telle situation, le service doit aussi lancer une erreur (la encore : telle quelle ou transformee — sans compliquer pour l'instant).

import XCTest
@testable import LibraryCLI

final class LibraryServiceSaveErrorTests: XCTestCase {
    func testRenameBook_whenSaveFails_throwsWriteFailed() {
        let repo = MockBookRepository()
        repo.fetchResult = .success(Book(
            id: BookID(rawValue: "b2"),
            title: "Old",
            author: "Bob"
        ))
        repo.saveResult = .failure(.writeFailed)

        let service = LibraryService(repo: repo)

        XCTAssertThrowsError(try service.renameBook(id: BookID(rawValue: "b2"), to: "New")) { error in
            XCTAssertEqual(error as? BookRepositoryError, .writeFailed)
        }
    }
}

Ce test est precieux parce qu'il verifie que le service ne « avale » pas les erreurs et ne fait pas semblant que tout va bien. Dans une application CLI, c'est particulierement important : si nous perdons silencieusement une erreur d'enregistrement, l'utilisateur sera ensuite persuade que le livre a ete renomme, alors que le fichier sur le disque restera inchangé.

8. Nuances utiles : Result, stub et limites de la complexite

Pourquoi Result est un « moteur » de mocks pratique

Quand un debutant voit Result pour la premiere fois, il a souvent cette reaction : « pourquoi me faut-il encore un type, si j'ai throws ? ». Dans le code reel, vous ecrivez effectivement plus souvent throws, mais dans les doubles de test Result devient soudain un conteneur tres pratique pour une « reponse preparee ». Il est stocke comme une valeur ordinaire, il est facile a redefinir, et get() le transforme a nouveau en comportement throws.

De plus, Result, en tant que concept, a ete introduit dans la bibliotheque standard justement comme outil de representation de « succes/erreur » sous forme de valeur et de simplification de la composition dans certains scenarios. Dans les tests, cela est particulierement agreable : le mock ressemble a un petit « interrupteur du destin » que vous controlez directement depuis le test.

Quand un mock cesse d'aider

Il existe une facon classique de rendre les tests inutiles : ecrire un mock avec une logique telle qu'il commence lui-meme a reproduire le comportement du repository ou du service reel. Vous obtenez alors une situation du type « nous testons le service a travers un autre service ». Une erreur de logique peut exister dans les deux endroits, et le test confirmera joyeusement un comportement incorrect comme etant « correct » — parce que le mock calcule lui aussi mal.

Un bon mock-stub est en general aussi simple que possible (c'est un compliment). Il doit etre mecanique : « renvoie ceci », « lance cela ». Si vous vous surprenez a ajouter un if dans le mock, puis encore un if, puis « bon, mettons aussi un tableau dedans et cherchons par id » — arrete-vous, vous ecrivez deja un petit repository. Parfois c'est pertinent (cela se rapproche alors d'un fake), mais dans le cadre de tests unitaires de service, des stubs suffisent le plus souvent.

Repository reel et mock dans les tests

Pour ne pas se perdre dans la terminologie, il est utile de garder en tete une comparaison simple. Ici, nous parlons bien de la lecon actuelle (stub-mock), sans verification des interactions.

Implementation Pourquoi elle existe De quoi elle depend Ce qu'elle fait dans le test
JSONFileBookRepository (prod) Stocke les donnees pour de vrai Fichiers, chemins, droits, format En unit tests du service, en general inutile
MockBookRepository (stub) Rend les tests deterministes Seulement le code du test Renvoie un Result prepare a l'avance

9. Erreurs typiques lors de l'ecriture de MockRepository

Erreur n°1 : le service cree lui-meme le repository en interne.
Si, dans LibraryService, vous ecrivez quelque chose comme let repo = JSONFileBookRepository(...), vous privez le test de la possibilite de substitution. Au final, le service ne peut pas etre teste de facon isolee : il traine toujours l'infrastructure avec lui. La solution est simple : la dependance est passee via init(repo:).

Erreur n°2 : le mock devient « plus intelligent » que necessaire et se transforme en implementation alternative du repository.
Quand le mock commence a stocker des collections, a les mettre a jour, a valider des donnees et a « fonctionner presque pour de vrai », il cesse d'etre un outil de test simple. Le test commence a dependre de la logique du mock, et vous perdez la certitude de tester precisement le service. Pour un stub-mock, gardez la regle suivante : comportement minimal, previsibilite maximale.

Erreur n°3 : le mock lance des erreurs qui ne sont pas du bon type par rapport au contrat.
Parfois, on a envie de lancer dans le mock un quelconque NSError(domain:..., code:...), parce que « quelle difference cela fait-il ? ». La difference existe : le code du service et les tests doivent travailler avec les memes types d'erreurs que le contrat reel. Si BookRepository promet BookRepositoryError, alors le mock doit utiliser le meme type — sinon les tests commencent a verifier un autre scenario.

Erreur n°4 : le test verifie seulement « ne plante pas », mais ne verifie pas le resultat.
Le scenario « ne doit pas lever » est important, mais en general insuffisant. Si renameBook ne plante pas, mais renvoie un livre avec l'ancien titre, alors formellement le “no throw” passe, mais le sens du test est perdu. Separez les attentes : d'un cote, fixez qu'il ne lance pas ; de l'autre, que la valeur soit correcte.

Erreur n°5 : essayer de verifier les interactions via un stub-mock et obtenir un test fragile.
Des que vous commencez a ajouter des journaux d'appels dans le mock et a verifier l'ordre des methodes, vous passez deja dans le domaine des spy-mocks et des tests d'interactions. C'est utile, mais si l'on melange tout, les tests deviennent fragiles : chaque petit changement dans l'implementation interne du service commence a « casser » les tests, alors que le comportement pour l'utilisateur n'a pas change. Dans cette lecon, gardons le focus sur les stubs : entree -> reponse preparee a l'avance -> verification du resultat/de l'erreur.

1
Mission
Swift SELF, niveau 53, leçon 0
Bloqué
Salutation du bot
Salutation du bot
1
Mission
Swift SELF, niveau 53, leçon 0
Bloqué
Renommer un volume
Renommer un volume
1
Mission
Swift SELF, niveau 53, leçon 0
Bloqué
Estimation de la commande
Estimation de la commande
1
Mission
Swift SELF, niveau 53, leçon 0
Bloqué
Classement des auteurs
Classement des auteurs
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION