Bonjour ! Si c’est la première fois que vous lisez un de mes articles, sachez que je m’appelle Skander. Je suis un développeur iOS passionné. Ici, je partage régulièrement mes connaissances, et pour éviter de faire du contenu répétitif (car expliquer comment écrire une boucle for se trouve des milliards de fois sur Internet), j’essaye autant que possible de vous offrir du contenu qualitatif.

À chaque fois que j’écris un article, sachez que je suis dans un train. J’adore l’écriture tout en observant les beaux paysages français.

Aujourd’hui, je vous embarque avec moi depuis la gare de Paris Montparnasse en direction de la gare de Bordeaux, de retour de la SwiftConnection, qui a eu lieu les 23 et 24 septembre au Théâtre de Paris.

Je pense que de nombreux développeurs trouvent ennuyeux d’écrire des Mocks ou tout autre type de code redondant. Personnellement, cela m’a souvent agacé, mais c’est quelque chose qu’il faut faire pour simuler des scénarios de tests cohérents.

Heureusement, je pense avoir trouvé la solution, bien que vous deviez l’affiner selon vos besoins spécifiques.

Étape 1 : Créer un projet Swift

Pour commencer, créez un dossier que vous nommerez par exemple MocksGenerator. Ensuite, faites un clic droit sur le dossier et sélectionnez « Nouveau terminal dans le dossier », puis exécutez la commande suivante :

swift package init

Cela va générer un package Swift. Vous devriez obtenir le résultat suivant :

Creating library package: MocksGenerator
Creating Package.swift
Creating .gitignore
Creating Sources/
Creating Sources/MocksGenerator/MocksGenerator.swift
Creating Tests/
Creating Tests/MocksGeneratorTests/
Creating Tests/MocksGeneratorTests/MocksGeneratorTests.swift

Parmi ces fichiers et dossiers, vous pouvez supprimer le .gitignore (facultatif) et tout le contenu du dossier Tests, car nous n’en aurons pas besoin pour cette cible exécutable.

Étape 2 : Modifier la cible du package

À ce stade normalement le :

Package.swift
Sources/
Sources/MocksGenerator/MocksGenerator.swift

Ensuite, dans le fichier Package.swift, nous allons supprimer la cible par défaut et la remplacer par une cible exécutable.

// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "MocksGenerator",
    platforms: [
        .macOS(.v11)
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"),
        .package(url: "https://github.com/stencilproject/Stencil.git", from: "0.15.1"),
        .package(url: "https://github.com/MarkCodable/MarkCodable.git", from: "0.6.9"),
    ],
    targets: [
        .executableTarget(
            name: "MocksGenerator",
            dependencies: [
                .product(name: "ArgumentParser", package: "swift-argument-parser"),
                .product(name: "Stencil", package: "Stencil"),
                .product(name: "MarkCodable", package: "MarkCodable")
            ],
            resources: [
                .copy("Resources/Template.stencil")
            ]
        ),
    ]
)

Étape 3 : Créer les fichiers nécessaires

Les dépendances que nous utiliserons sont :

  • swift-argument-parser : pour décoder les arguments passés en ligne de commande.
  • Stencil : un projet open-source qui permet de créer des templates dynamiques.
  • MarkCodable : une bibliothèque open-source qui permet d’encoder et de décoder du Markdown en objets Swift.

Dans le dossier Sources/MocksGenerator, créez un dossier Resources et, à l’intérieur, un fichier vide appelé Template.stencil. Faites attention à cette étape, car l’extension .swift peut s’ajouter automatiquement. Supprimez-la si nécessaire.

Voici un exemple de contenu pour Template.stencil :

import Foundation
{% for event in events -%}
import {{ event.moduleName }}

class {{ event.className }}Mock {
    {% for variable in event.variables %}
    let {{ variable }}: String
    {%- endfor %}
}

extension {{ event.className }}Mock: {{ event.className }}Protocol {

}
{% endfor %}

Tous les paramètres des événements devraient provenir d’un fichier Events.md que vous allez créer à la racine de votre module.

Étape 4 : Créer le fichier Events.md

Ce fichier sera la source de notre générateur, ou une base de données locale si vous préférez. Il ressemblera à ça :

| moduleName    | className        | variables                |
|---------------|------------------|--------------------------|
| Contact       | ContactViewModel | firstname,lastname,email |

Cela crée un tableau en Markdown, jouant le rôle de séparateurs, ce qui permet à l’algorithme de décoder les champs et aux humains de les manipuler facilement.

Étape 5 : Hiérarchie du projet

Si vous avez suivi jusqu’ici, votre hiérarchie de fichiers devrait ressembler à ceci :

.
├── Package.swift
└── Sources
    ├── Events.md
    └── MocksGenerator
        ├── MocksGenerator.swift
        ├── Resources
        │   └── Template.stencil

Étape 6 : Passage à l’action

Maintenant, passons à l’action ! Mais avant tout, je dois changer de place, car il y a un monsieur qui ronfle juste à côté de moi…

Pour ce projet, nous allons créer quatre fichiers.

  1. Le modèle
    Le modèle va nous servir à décoder les données extraites de Events.md pour les manipuler plus facilement. Ce modèle devrait ressembler à ceci :
import Foundation

struct Model: Decodable {
    let moduleName: String
    let className: String
    let variables: [String]

    private enum CodingKeys: String, CodingKey {
        case moduleName
        case className
        case variables
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        moduleName = try container.decode(String.self, forKey: .moduleName)
        className = try container.decode(String.self, forKey: .className)
        variables = try container.decode(String.self, forKey: .variables)
            .components(separatedBy: ",")
    }
}
  1. Helper Extension
    Dans un fichier Swift, ou ailleurs si vous préférez, créez une extension qui permet de décoder les données en String avec un encodage optionnel en .utf8 :
import Foundation

extension URL {
    func loadString() throws -> String {
        let data = try Data(contentsOf: self)
        guard let string = String(data: data, encoding: .utf8) else {
            fatalError("Le fichier \(self) n'est pas un fichier texte valide.")
        }
        return string
    }
}
  1. MocksGenerator
    Dans MocksGenerator.swift, nous allons développer toute la logique pour décoder et encoder depuis les commandes parsables.
import ArgumentParser
import Foundation

@main
struct MocksGenerator: ParsableCommand {
    @Option(name: .shortAndLong)
    var input: String
    
    @Option(name: .shortAndLong)
    var output: String = "GeneratedMocks.swift"
}

Si vous avez remarqué, dans la sortie, j’ai utilisé un nom de classe en format string. Vous pouvez le rendre dynamique si vous le souhaitez, mais dans tous les cas, le fichier de sortie doit exister pour que notre argument fonctionne.

  1. Charger les classes depuis le fichier Events.md

La première fonction de notre struct s’appellera loadClasses. L’idée est d’utiliser MarkCodable pour récupérer toutes les classes enregistrées dans notre tableau créé avec le Markdown, puis de les décoder en utilisant notre modèle défini à l’étape précédente.

import ArgumentParser
import Foundation
import MarkCodable

@main
struct MocksGenerator: ParsableCommand {
    @Option(name: .shortAndLong)
    var input: String
    
    @Option(name: .shortAndLong)
    var output: String = "GeneratedMocks.swift"
    
    func loadClasses() throws -> [Model] {
        let inputPath = FileManager.default.currentDirectoryPath + "/Sources/" + input
        let items = try URL(fileURLWithPath: inputPath).loadString()

        return try MarkDecoder().decode([Model].self, from: items)
    }
}
  1. Générer du code à partir du template

Une fois que les classes sont récupérées et décodées en un tableau de [Model], nous devons générer du code dans le fichier GeneratedMocks.swift en suivant le template Stencil que nous avons créé précédemment.

La fonction generate aura besoin du tableau de modèles, que nous récupérons avec la fonction loadClasses. Ensuite, nous chargeons le template à partir de l’URL des ressources, et avec l’aide du module Stencil, nous remplissons le template automatiquement.

import ArgumentParser
import Foundation
import MarkCodable
import Stencil

@main
struct MocksGenerator: ParsableCommand {
    @Option(name: .shortAndLong)
    var input: String
    
    @Option(name: .shortAndLong)
    var output: String = "GeneratedMocks.swift"
    
    func loadClasses() throws -> [Model] {
        let inputPath = FileManager.default.currentDirectoryPath + "/Sources/" + input
        let items = try URL(fileURLWithPath: inputPath).loadString()

        return try MarkDecoder().decode([Model].self, from: items)
    }
    
    func generate(using classes: [Model]) throws -> String {
        guard let templateURL = Bundle.module
            .url(forResource: "Template", withExtension: "stencil") else {
            fatalError("Template non trouvé")
        }
        
        return try Environment().renderTemplate(
            string: templateURL.loadString(),
            context: ["events": classes]
        )
    }
}
  1. Exécution finale

Pour finir, la dernière étape sera d’exécuter la fonction run, qui sera appelée via la commande depuis le terminal. Dans cette fonction, nous allons :

  1. Récupérer les classes en utilisant loadClasses().
  2. Générer le code correspondant au template.
  3. Écrire ce code dans le fichier GeneratedMocks.swift.
import ArgumentParser
import Foundation
import MarkCodable
import Stencil

@main
struct MocksGenerator: ParsableCommand {
    @Option(name: .shortAndLong)
    var input: String
    
    @Option(name: .shortAndLong)
    var output: String = "GeneratedMocks.swift"
    
    func loadClasses() throws -> [Model] {
        let inputPath = FileManager.default.currentDirectoryPath + "/Sources/" + input
        let items = try URL(fileURLWithPath: inputPath).loadString()

        return try MarkDecoder().decode([Model].self, from: items)
    }
    
    func generate(using classes: [Model]) throws -> String {
        guard let templateURL = Bundle.module
            .url(forResource: "Template", withExtension: "stencil") else {
            fatalError("Template non trouvé")
        }
        
        return try Environment().renderTemplate(
            string: templateURL.loadString(),
            context: ["events": classes]
        )
    }
    
    mutating func run() throws {
        let classes = try loadClasses()
        let generatedCode = try generate(using: classes)

        // Écrire le fichier de sortie sur le disque
        let outputPath = FileManager.default.currentDirectoryPath + "/" + output
        try generatedCode.write(
            to: URL(fileURLWithPath: outputPath),
            atomically: true,
            encoding: .utf8
        )
    }
}
  1. Résultat final

Votre hiérarchie de fichiers devrait maintenant ressembler à ceci :

.
├── Package.resolved
├── Package.swift
└── Sources
    ├── Events.md
    └── MocksGenerator
        ├── GeneratedMocks.swift
        ├── MocksGenerator.swift
        ├── Model.swift
        ├── Resources
        │   └── Template.stencil
        └── URL+Extension.swift
  1. Exécution de la commande

Depuis le même dossier racine, exécutez la commande suivante dans votre terminal :

swift run MocksGenerator --input Events.md

Le résultat apparaîtra dans le fichier GeneratedMocks.swift, où vous trouverez un code généré comme celui-ci :

import Foundation
import Contact

class ContactViewModelMock {
    let firstname: String
    let lastname: String
    let email: String
}

extension ContactViewModelMock: ContactViewModelProtocol {

}

Conclusion

Félicitations ! Vous avez maintenant un générateur de Mocks automatisé en Swift qui peut vous faire économiser un temps précieux lors de la création de tests unitaires. Il vous suffit d’adapter cette solution en fonction de vos besoins spécifiques, et vous verrez rapidement les gains en productivité que cela peut apporter.

Categorized in:

Swift,