Angular Schematics : créer des générateurs ng generate, ng add et ng update pour automatiser migrations et scaffolding de code sur mesure.
Qu'est-ce qu'un Angular Schematic ?
Les Angular Schematics sont le moteur de transformation de code qui alimente Angular CLI en coulisses. Chaque fois que vous tapez ng generate component, ng add @angular/material ou ng update, vous déclenchez un schematic. Ce ne sont pas de simples scripts qui copient des fichiers — ce sont des transformations de code déclaratives, réversibles et composables.
La différence fondamentale avec un script Node classique tient à trois propriétés :
- Virtualité : les Schematics opèrent sur un arbre de fichiers virtuel (
Tree) avant de toucher le disque. Si une erreur survient, rien n'est écrit. - Compositionalité : plusieurs Schematics peuvent être chainés via
chain()pour former des pipelines complexes. - Testabilité : grâce au
Treevirtuel, on peut tester des transformations sans toucher au système de fichiers réel.
Tree (arbre de fichiers virtuel) et un SchematicContext (contexte d'exécution), applique des transformations, et retourne un nouveau Tree. C'est du functional programming appliqué au scaffolding de code.
Voici les trois grands types de Schematics que vous rencontrerez :
| Type | Commande CLI | Cas d'usage |
|---|---|---|
| Generation | ng generate my-lib:component |
Scaffolding de fichiers, création de composants, services, modules custom |
| Add | ng add my-lib |
Installer une librairie et configurer le projet (imports, styles, polyfills) |
| Update / Migration | ng update my-lib |
Transformer le code existant lors des mises à jour de version majeure |
Dans cet article, nous allons construire les trois types from scratch, avec des exemples concrets et des tests unitaires. Vous comprendrez comment Angular CLI lui-même génère vos composants, et vous serez capable d'automatiser les migrations répétitives de votre équipe.
Créer son premier Schematic
Pour créer une collection de Schematics, vous avez besoin du package @angular-devkit/schematics-cli qui fournit des outils de scaffolding et la commande schematics pour tester localement.
Installation et initialisation du projet
# Installer le CLI Schematics globalement
npm install -g @angular-devkit/schematics-cli
# Créer un nouveau projet de collection de Schematics
schematics blank --name=my-schematics
# Naviguer dans le projet
cd my-schematics
# Installer les dépendances (TypeScript + devkit)
npm install
La structure générée ressemble à ceci :
my-schematics/
├── src/
│ └── my-schematics/
│ ├── index.ts ← Le point d'entrée du schematic
│ ├── index_spec.ts ← Les tests unitaires
│ └── schema.json ← Schéma JSON des options (facultatif)
├── collection.json ← Manifeste de la collection
├── package.json
└── tsconfig.json
Le fichier collection.json
C'est le manifeste de votre collection — il déclare tous les Schematics disponibles et leurs points d'entrée.
// collection.json — déclare tous les schematics de votre package
{
"$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
"schematics": {
// Schematic appelé par : ng generate my-schematics:my-component
"my-component": {
"description": "Génère un composant Angular avec structure custom",
"factory": "./src/my-component/index#myComponent",
"schema": "./src/my-component/schema.json"
},
// Schematic d'installation appelé par : ng add my-schematics
"ng-add": {
"description": "Installe et configure my-schematics dans le projet",
"factory": "./src/ng-add/index#ngAdd"
},
// Schematic de migration appelé par : ng update my-schematics
"migration-v2": {
"description": "Migration automatique vers la v2",
"factory": "./src/migrations/v2/index#migrateToV2",
"version": "2.0.0"
}
}
}
Compiler et tester localement
# Compiler les Schematics TypeScript en JavaScript
npm run build
# Lier le package localement pour tester dans un projet Angular
npm link
# Dans votre projet Angular, lier le package
cd /path/to/angular-project
npm link my-schematics
# Tester le schematic sans modifier les fichiers (--dry-run)
ng generate my-schematics:my-component --name=dashboard --dry-run
--dry-run est votre meilleur ami lors du développement. Elle simule toutes les transformations et affiche les fichiers qui seraient créés ou modifiés, sans toucher au disque. Indispensable pour déboguer vos Schematics.
L'API Tree : manipuler le filesystem virtuel
Le Tree est l'abstraction centrale des Schematics. C'est une représentation virtuelle du système de fichiers du projet Angular cible. Toutes vos transformations s'appliquent d'abord sur ce Tree virtuel ; elles ne sont écrites sur le disque que lorsque le Schematic se termine avec succès.
Les opérations fondamentales du Tree
// src/my-schematic/index.ts
import { Rule, Tree, SchematicContext } from '@angular-devkit/schematics';
export function mySchematic(): Rule {
// Une Rule est une fonction (Tree, SchematicContext) => Tree | Observable<Tree>
return (tree: Tree, context: SchematicContext): Tree => {
// --- LIRE un fichier existant ---
const content = tree.read('src/app/app.component.ts');
if (content) {
// content est un Buffer — convertir en string
const contentStr = content.toString('utf-8');
context.logger.info(`Taille fichier : ${contentStr.length} chars`);
}
// --- VÉRIFIER l'existence d'un fichier ---
if (tree.exists('src/environments/environment.ts')) {
context.logger.info('Fichier environment.ts trouvé');
}
// --- CRÉER un nouveau fichier ---
tree.create(
'src/app/my-feature/my-feature.component.ts',
// Contenu du fichier à créer
`import { Component } from '@angular/core';\n\n@Component({ selector: 'app-my-feature', template: '' })\nexport class MyFeatureComponent {}\n`
);
// --- MODIFIER un fichier existant ---
tree.overwrite(
'src/app/app.module.ts',
// Nouveau contenu complet du fichier
getUpdatedAppModule(content?.toString('utf-8') ?? '')
);
// --- SUPPRIMER un fichier ---
if (tree.exists('src/app/old-file.ts')) {
tree.delete('src/app/old-file.ts');
}
// --- RENOMMER un fichier ---
tree.rename(
'src/app/old-name.component.ts',
'src/app/new-name.component.ts'
);
// Retourner le Tree modifié (les changements seront écrits sur disque)
return tree;
};
}
Lister les fichiers d'un répertoire
// Parcourir récursivement le répertoire src/app/
import { Rule, Tree, SchematicContext } from '@angular-devkit/schematics';
export function listComponents(): Rule {
return (tree: Tree, context: SchematicContext): Tree => {
// getDir() retourne un DirEntry pour naviguer l'arborescence
const appDir = tree.getDir('src/app');
// Lister les sous-répertoires immédiats
context.logger.info('Sous-répertoires :');
appDir.subdirs.forEach(dir => {
context.logger.info(` ${dir}`);
});
// Lister tous les fichiers .ts récursivement
const tsFiles: string[] = [];
tree.visit(filePath => {
// Filtrer uniquement les fichiers TypeScript dans src/app/
if (filePath.startsWith('/src/app/') && filePath.endsWith('.ts')) {
tsFiles.push(filePath);
}
});
context.logger.info(`${tsFiles.length} fichiers TypeScript trouvés`);
return tree;
};
}
Utiliser les templates de fichiers
Pour générer plusieurs fichiers avec un contenu dynamique (variables interpolées), Schematics fournit un système de templates puissant basé sur apply() et url().
// src/my-component/index.ts
import {
Rule, Tree, SchematicContext,
apply, url, template, move, mergeWith, MergeStrategy
} from '@angular-devkit/schematics';
import { strings } from '@angular-devkit/core';
export interface MyComponentOptions {
name: string; // Nom du composant
path: string; // Chemin de destination
module?: string; // Module à importer (optionnel)
}
export function myComponent(options: MyComponentOptions): Rule {
return (tree: Tree, context: SchematicContext): Rule => {
// Charger les fichiers templates depuis le dossier ./files/
const templateSource = apply(
url('./files'), // Chemin relatif vers les templates
[
// Interpoler les variables dans les noms de fichiers ET le contenu
template({
...strings, // Utilitaires : dasherize, classify, camelize...
...options, // Les options passées par l'utilisateur
// Ajouter des helpers personnalisés
upper: (s: string) => s.toUpperCase(),
}),
// Déplacer les fichiers générés vers le chemin cible
move(options.path)
]
);
// Fusionner les fichiers générés avec le Tree existant
return mergeWith(templateSource, MergeStrategy.Default)(tree, context);
};
}
Les fichiers templates utilisent la syntaxe <%= variable %> dans leurs noms et leur contenu :
// src/my-component/files/__name@dasherize__.component.ts.template
// Le nom du fichier sera transformé : "MyDashboard" → "my-dashboard.component.ts"
import { Component } from '@angular/core';
// Le nom de classe sera "classify" : "my-dashboard" → "MyDashboard"
@Component({
selector: 'app-<%= dasherize(name) %>',
standalone: true,
templateUrl: './<%= dasherize(name) %>.component.html',
styleUrls: ['./<%= dasherize(name) %>.component.scss']
})
export class <%= classify(name) %>Component {
// Composant généré automatiquement par my-schematics
title = '<%= name %>';
}
strings de @angular-devkit/core sont essentiels. dasherize('MyComponent') → 'my-component', classify('my-component') → 'MyComponent', camelize('my-component') → 'myComponent'. Angular CLI les utilise en interne pour tous ses générateurs.
Créer un générateur ng generate
Un générateur ng generate est le type de Schematic le plus courant. Il permet de scaffolding des composants, services, modules ou toute structure de fichiers avec des conventions propres à votre organisation. Voici comment créer un générateur de composants avec une structure enrichie — tests, Storybook et barrel exports inclus.
Définir le schéma des options (schema.json)
// src/smart-component/schema.json
// Décrit toutes les options disponibles pour ng generate my-lib:smart-component
{
"$schema": "http://json-schema.org/schema",
"$id": "SmartComponentSchema",
"title": "Générateur de Smart Component",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Nom du composant (ex: user-profile)",
"$default": { "$source": "argv", "index": 0 }
},
"path": {
"type": "string",
"description": "Chemin de destination",
"default": "src/app"
},
"withStore": {
"type": "boolean",
"description": "Générer un service de store NgRx associé",
"default": false
},
"withStorybook": {
"type": "boolean",
"description": "Générer un fichier Storybook .stories.ts",
"default": true
},
"prefix": {
"type": "string",
"description": "Préfixe du sélecteur CSS",
"default": "app"
}
},
"required": ["name"]
}
Implémenter le générateur
// src/smart-component/index.ts
import {
Rule, Tree, SchematicContext,
apply, url, template, move, mergeWith,
MergeStrategy, chain, noop
} from '@angular-devkit/schematics';
import { strings } from '@angular-devkit/core';
import { addDeclarationToModule } from '@schematics/angular/utility/ast-utils';
import { InsertChange } from '@schematics/angular/utility/change';
import * as ts from 'typescript';
export interface SmartComponentOptions {
name: string;
path: string;
withStore: boolean;
withStorybook: boolean;
prefix: string;
}
export function smartComponent(options: SmartComponentOptions): Rule {
return chain([
// Étape 1 : Générer les fichiers depuis les templates
generateFiles(options),
// Étape 2 : Ajouter le store si demandé
options.withStore ? generateStore(options) : noop(),
// Étape 3 : Ajouter le Storybook si demandé
options.withStorybook ? generateStorybook(options) : noop(),
// Étape 4 : Mettre à jour le barrel (index.ts) du dossier parent
updateBarrelFile(options),
]);
}
// Rule 1 : Générer les fichiers du composant depuis les templates
function generateFiles(options: SmartComponentOptions): Rule {
return mergeWith(
apply(url('./files/component'), [
template({
...strings, // dasherize, classify, camelize, etc.
...options, // Toutes les options de l'utilisateur
}),
move(`${options.path}/${strings.dasherize(options.name)}`)
]),
MergeStrategy.Default
);
}
// Rule 2 : Générer un service NgRx store si --with-store
function generateStore(options: SmartComponentOptions): Rule {
return mergeWith(
apply(url('./files/store'), [
template({ ...strings, ...options }),
move(`${options.path}/${strings.dasherize(options.name)}/store`)
]),
MergeStrategy.Default
);
}
// Rule 3 : Générer le fichier Storybook
function generateStorybook(options: SmartComponentOptions): Rule {
return mergeWith(
apply(url('./files/storybook'), [
template({ ...strings, ...options }),
move(`${options.path}/${strings.dasherize(options.name)}`)
]),
MergeStrategy.Default
);
}
// Rule 4 : Mettre à jour ou créer le barrel index.ts
function updateBarrelFile(options: SmartComponentOptions): Rule {
return (tree: Tree, context: SchematicContext): Tree => {
const barrelPath = `${options.path}/index.ts`;
const exportLine = `export * from './${strings.dasherize(options.name)}/${strings.dasherize(options.name)}.component';\n`;
if (tree.exists(barrelPath)) {
// Ajouter l'export au barrel existant
const current = tree.read(barrelPath)!.toString('utf-8');
// Éviter les doublons si on regénère
if (!current.includes(exportLine)) {
tree.overwrite(barrelPath, current + exportLine);
context.logger.info(`Barrel mis à jour : ${barrelPath}`);
}
} else {
// Créer un nouveau barrel
tree.create(barrelPath, exportLine);
context.logger.info(`Barrel créé : ${barrelPath}`);
}
return tree;
};
}
Template du composant généré
// src/smart-component/files/component/__name@dasherize__.component.ts.template
// Ce fichier est le template TypeScript du composant généré
// Les <%= ... %> sont remplacés lors de la génération
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: '<%= prefix %>-<%= dasherize(name) %>',
standalone: true,
imports: [CommonModule],
templateUrl: './<%= dasherize(name) %>.component.html',
styleUrls: ['./<%= dasherize(name) %>.component.scss']
})
export class <%= classify(name) %>Component implements OnInit {
// Propriété titre initialisée avec le nom du composant
title = '<%= classify(name) %>';
ngOnInit(): void {
// Initialisation du composant
console.log('<%= classify(name) %>Component initialisé');
}
}
Utilisation du générateur
# Générer un smart component simple
ng generate my-lib:smart-component user-profile
# Générer avec store NgRx et sans Storybook
ng generate my-lib:smart-component dashboard --with-store --no-with-storybook
# Générer dans un chemin spécifique
ng generate my-lib:smart-component product-card --path=src/app/features/products
# Voir ce qui serait généré sans rien créer
ng generate my-lib:smart-component order-list --dry-run
ng generate votre-org:smart-component pour respecter les conventions maison sans effort de mémorisation.
Créer un installateur ng add
Le Schematic ng-add est déclenché par ng add votre-package. Son rôle est d'automatiser tout ce qu'un développeur devrait faire manuellement après avoir installé votre librairie : ajouter des imports dans app.config.ts, injecter des styles dans angular.json, configurer les variables d'environnement, etc.
Structure du ng-add
// src/ng-add/index.ts
import {
Rule, Tree, SchematicContext, chain,
SchematicsException
} from '@angular-devkit/schematics';
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
export interface NgAddOptions {
project?: string; // Projet Angular cible (monorepo)
theme?: string; // Thème CSS à installer
locale?: string; // Locale à configurer
}
export function ngAdd(options: NgAddOptions): Rule {
return chain([
// Étape 1 : Valider que c'est bien un projet Angular
validateAngularProject(),
// Étape 2 : Ajouter les styles CSS au angular.json
addStylesToAngularJson(options),
// Étape 3 : Configurer le provider dans app.config.ts
addProviderToAppConfig(options),
// Étape 4 : Créer le fichier de configuration par défaut
createConfigFile(options),
// Étape 5 : Déclencher l'installation npm (optionnel)
installDependencies(),
]);
}
Modifier angular.json pour ajouter des styles
// Ajouter un fichier CSS de librairie dans les styles Angular
function addStylesToAngularJson(options: NgAddOptions): Rule {
return (tree: Tree, context: SchematicContext): Tree => {
// Lire et parser angular.json
const angularJsonBuffer = tree.read('angular.json');
if (!angularJsonBuffer) {
throw new SchematicsException('angular.json non trouvé. Ce projet est-il un projet Angular ?');
}
const angularJson = JSON.parse(angularJsonBuffer.toString('utf-8'));
const projectName = options.project || Object.keys(angularJson.projects)[0];
const project = angularJson.projects[projectName];
// Naviguer dans la structure angular.json jusqu'aux styles
const buildOptions = project.architect.build.options;
if (!buildOptions.styles) {
buildOptions.styles = [];
}
// Vérifier que le style n'est pas déjà ajouté (idempotence)
const styleToAdd = 'node_modules/my-lib/styles/my-lib.css';
if (!buildOptions.styles.includes(styleToAdd)) {
buildOptions.styles.unshift(styleToAdd); // Ajouter en premier
// Réécrire angular.json avec l'indentation préservée
tree.overwrite('angular.json', JSON.stringify(angularJson, null, 2));
context.logger.info(`Style ajouté dans angular.json : ${styleToAdd}`);
} else {
context.logger.info('Style déjà présent dans angular.json, ignoré.');
}
return tree;
};
}
Ajouter un provider dans app.config.ts
// Modifier app.config.ts pour ajouter provideMyLib()
function addProviderToAppConfig(options: NgAddOptions): Rule {
return (tree: Tree, context: SchematicContext): Tree => {
const configPath = 'src/app/app.config.ts';
if (!tree.exists(configPath)) {
// Projet NgModule classique — modifier app.module.ts à la place
context.logger.warn('app.config.ts non trouvé. Modification de app.module.ts...');
return addImportToAppModule()(tree, context);
}
let configContent = tree.read(configPath)!.toString('utf-8');
// Ajouter l'import si absent
if (!configContent.includes('provideMyLib')) {
// Ajouter la ligne d'import en haut du fichier
const importStatement = `import { provideMyLib } from 'my-lib';\n`;
configContent = importStatement + configContent;
// Ajouter provideMyLib() dans le tableau providers
// Chercher le dernier provider dans le tableau et insérer après
configContent = configContent.replace(
/providers:\s*\[([^\]]*)\]/,
(match, providers) => {
const hasProviders = providers.trim().length > 0;
const separator = hasProviders ? ',\n ' : '\n ';
return `providers: [${providers}${separator}provideMyLib()\n ]`;
}
);
tree.overwrite(configPath, configContent);
context.logger.info('provideMyLib() ajouté dans app.config.ts');
}
return tree;
};
}
Déclencher l'installation npm
// Demander à Angular CLI d'exécuter npm install après le schematic
function installDependencies(): Rule {
return (_tree: Tree, context: SchematicContext): void => {
// NodePackageInstallTask déclenche npm install automatiquement
context.addTask(new NodePackageInstallTask());
context.logger.info('Installation des dépendances npm en cours...');
};
}
ng-add doit être idempotent — si on l'exécute deux fois, le résultat doit être identique. Vérifiez toujours si un import ou une configuration existe déjà avant de l'ajouter. Les utilisateurs réexécutent parfois ng add par erreur.
Créer des migrations ng update
Les migrations ng update sont les Schematics les plus complexes — et les plus précieux. Quand vous publiez une version majeure avec des breaking changes, vous pouvez fournir une migration automatique qui transforme le code des utilisateurs. C'est exactement ce que fait Angular CLI lui-même pour chaque version majeure d'Angular.
Déclarer une migration dans package.json
// package.json de votre librairie
// Pointer vers le fichier de collection de migrations
{
"name": "my-lib",
"version": "2.0.0",
"schematics": "./collection.json",
// Déclaration OBLIGATOIRE pour que ng update trouve vos migrations
"ng-update": {
"migrations": "./migrations.json",
"packageGroup": ["my-lib", "my-lib-testing"]
}
}
// migrations.json — catalogue des migrations par version
{
"$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
"schematics": {
// Migration déclenchée lors de ng update my-lib (v1 → v2)
"migration-v2-rename-service": {
"version": "2.0.0",
"description": "Renomme OldService en NewService (breaking change v2)",
"factory": "./src/migrations/v2-rename-service/index#migrateRenameService"
},
// Migration déclenchée lors de ng update my-lib (v2 → v3)
"migration-v3-standalone": {
"version": "3.0.0",
"description": "Migre les modules vers Standalone Components",
"factory": "./src/migrations/v3-standalone/index#migrateToStandalone"
}
}
}
Implémenter une migration de renommage
Cas concret : entre la v1 et la v2, vous renommez OldDataService en DataService. La migration doit mettre à jour tous les imports et toutes les injections dans le projet de l'utilisateur.
// src/migrations/v2-rename-service/index.ts
import { Rule, Tree, SchematicContext } from '@angular-devkit/schematics';
export function migrateRenameService(): Rule {
return (tree: Tree, context: SchematicContext): Tree => {
context.logger.info('Migration v2 : renommage OldDataService → DataService');
// Compteur pour le rapport de migration
let filesModified = 0;
// Parcourir tous les fichiers TypeScript du projet
tree.visit(filePath => {
// Ignorer les fichiers hors src/ et les fichiers de spec
if (!filePath.startsWith('/src/') || filePath.endsWith('.spec.ts')) {
return;
}
// Ne traiter que les fichiers TypeScript
if (!filePath.endsWith('.ts')) {
return;
}
const content = tree.read(filePath);
if (!content) return;
let contentStr = content.toString('utf-8');
let modified = false;
// Remplacement 1 : l'import de l'ancien service
if (contentStr.includes('OldDataService')) {
contentStr = contentStr
// Mettre à jour le nom d'import dans la déclaration import
.replace(
/import\s*\{\s*OldDataService\s*\}\s*from\s*['"]my-lib['"]/g,
`import { DataService } from 'my-lib'`
)
// Mettre à jour toutes les occurrences du nom de classe
.replace(/OldDataService/g, 'DataService');
modified = true;
}
// Écrire le fichier modifié dans le Tree
if (modified) {
tree.overwrite(filePath, contentStr);
filesModified++;
context.logger.info(` Migré : ${filePath}`);
}
});
// Rapport final de la migration
context.logger.info(`Migration v2 terminée. ${filesModified} fichier(s) modifié(s).`);
return tree;
};
}
Migration AST-based pour des transformations précises
Pour des transformations complexes (modifier des décorateurs, restructurer des imports), l'approche regex peut produire des faux positifs. On préfère alors utiliser le compilateur TypeScript directement via l'API AST.
// src/migrations/v3-standalone/index.ts
// Migration qui utilise l'AST TypeScript pour transformer les décorateurs
import { Rule, Tree, SchematicContext } from '@angular-devkit/schematics';
import * as ts from 'typescript';
export function migrateToStandalone(): Rule {
return (tree: Tree, context: SchematicContext): Tree => {
// Récupérer tous les fichiers de composants Angular
const componentFiles: string[] = [];
tree.visit(filePath => {
if (filePath.endsWith('.component.ts') && filePath.startsWith('/src/')) {
componentFiles.push(filePath);
}
});
for (const filePath of componentFiles) {
const content = tree.read(filePath)?.toString('utf-8');
if (!content) continue;
// Parser le fichier avec l'API TypeScript pour obtenir l'AST
const sourceFile = ts.createSourceFile(
filePath,
content,
ts.ScriptTarget.Latest,
true // Préserver les trivia (espaces, commentaires)
);
// Chercher le décorateur @Component
const updatedContent = addStandaloneToDecorator(sourceFile, content);
if (updatedContent !== content) {
tree.overwrite(filePath, updatedContent);
context.logger.info(`Standalone ajouté : ${filePath}`);
}
}
return tree;
};
}
// Ajoute standalone: true dans le décorateur @Component s'il est absent
function addStandaloneToDecorator(sourceFile: ts.SourceFile, content: string): string {
let result = content;
// Parcourir les noeuds de l'AST récursivement
const visit = (node: ts.Node): void => {
// Chercher les classes avec des décorateurs
if (ts.isClassDeclaration(node) && node.modifiers) {
for (const modifier of node.modifiers) {
if (
ts.isDecorator(modifier) &&
ts.isCallExpression(modifier.expression)
) {
const expr = modifier.expression;
// Vérifier que c'est @Component (pas @Directive, @Pipe...)
if (
ts.isIdentifier(expr.expression) &&
expr.expression.text === 'Component' &&
expr.arguments.length > 0
) {
const arg = expr.arguments[0];
if (ts.isObjectLiteralExpression(arg)) {
// Vérifier que standalone n'est pas déjà présent
const hasStandalone = arg.properties.some(
p => ts.isPropertyAssignment(p) &&
ts.isIdentifier(p.name) &&
p.name.text === 'standalone'
);
if (!hasStandalone) {
// Insérer standalone: true après le { du décorateur
const insertPos = arg.getStart() + 1;
result =
result.slice(0, insertPos) +
'\n standalone: true,' +
result.slice(insertPos);
}
}
}
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return result;
}
Tester ses Schematics
Le Tree virtuel rend les Schematics exceptionnellement testables. Le package @angular-devkit/schematics/testing fournit SchematicTestRunner et UnitTestTree — tout ce dont vous avez besoin pour des tests unitaires rapides, sans système de fichiers réel.
Tester un générateur ng generate
// src/smart-component/index_spec.ts
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
import * as path from 'path';
// Chemin vers le collection.json de notre package
const collectionPath = path.join(__dirname, '../../collection.json');
describe('smart-component schematic', () => {
// Le runner charge notre collection de Schematics
const runner = new SchematicTestRunner('my-lib', collectionPath);
// App tree de base simulant un projet Angular minimal
let appTree: UnitTestTree;
beforeEach(async () => {
// Créer un workspace Angular vide pour les tests
appTree = await runner.runSchematic(
'workspace', // Schematic Angular CLI : créer un workspace
{ name: 'test-app', version: '17.0.0' },
new UnitTestTree(null as any) // Tree vide de départ
);
});
it('devrait créer les fichiers du composant', async () => {
// Exécuter notre schematic avec les options voulues
const tree = await runner.runSchematic(
'smart-component', // Nom du schematic dans collection.json
{ name: 'user-profile', path: 'src/app' }, // Options
appTree // Tree de base (projet Angular)
);
// Vérifier que les fichiers ont été créés
expect(tree.exists('src/app/user-profile/user-profile.component.ts')).toBeTruthy();
expect(tree.exists('src/app/user-profile/user-profile.component.html')).toBeTruthy();
expect(tree.exists('src/app/user-profile/user-profile.component.scss')).toBeTruthy();
expect(tree.exists('src/app/user-profile/user-profile.component.spec.ts')).toBeTruthy();
});
it('devrait générer le bon sélecteur CSS', async () => {
const tree = await runner.runSchematic(
'smart-component',
{ name: 'user-profile', path: 'src/app', prefix: 'app' },
appTree
);
// Lire le contenu du composant généré
const componentContent = tree.readContent(
'src/app/user-profile/user-profile.component.ts'
);
// Vérifier la présence du bon sélecteur dans le template
expect(componentContent).toContain("selector: 'app-user-profile'");
});
it('devrait générer le store si --with-store est activé', async () => {
const tree = await runner.runSchematic(
'smart-component',
{ name: 'dashboard', path: 'src/app', withStore: true },
appTree
);
// Vérifier que le dossier store a été créé
expect(tree.exists('src/app/dashboard/store/dashboard.store.ts')).toBeTruthy();
});
it('ne devrait pas créer de doublon dans le barrel si relancé', async () => {
// Premier lancement
let tree = await runner.runSchematic(
'smart-component',
{ name: 'product', path: 'src/app' },
appTree
);
// Second lancement (simuler une réexécution)
tree = await runner.runSchematic(
'smart-component',
{ name: 'order', path: 'src/app' },
tree // Utiliser le tree résultant du premier lancement
);
const barrelContent = tree.readContent('src/app/index.ts');
// Vérifier qu'il n'y a pas de doublons d'export
const exportLines = barrelContent.split('\n').filter(l => l.includes('export'));
const uniqueExports = new Set(exportLines);
expect(exportLines.length).toBe(uniqueExports.size);
});
});
Tester une migration ng update
// src/migrations/v2-rename-service/index_spec.ts
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
import * as path from 'path';
// Pointer vers le fichier migrations.json (pas collection.json !)
const migrationsPath = path.join(__dirname, '../../../migrations.json');
describe('migration v2 : renommage service', () => {
const runner = new SchematicTestRunner('my-lib-migrations', migrationsPath);
it('devrait renommer OldDataService en DataService', async () => {
// Créer un Tree avec un fichier simulant un composant de l'utilisateur
const tree = new UnitTestTree(null as any);
// Simuler un fichier existant qui utilise l'ancien service
tree.create('src/app/home/home.component.ts', `
import { Component, inject } from '@angular/core';
import { OldDataService } from 'my-lib';
@Component({ selector: 'app-home', template: '' })
export class HomeComponent {
// Injection de l'ancien service (sera migré automatiquement)
private dataService = inject(OldDataService);
}
`.trim());
// Exécuter la migration
const migratedTree = await runner.runSchematic(
'migration-v2-rename-service',
{}, // Pas d'options pour les migrations
tree
);
const content = migratedTree.readContent('src/app/home/home.component.ts');
// Vérifier que l'import a été mis à jour
expect(content).toContain("import { DataService } from 'my-lib'");
// Vérifier que l'ancien nom n'existe plus
expect(content).not.toContain('OldDataService');
// Vérifier que le nouveau nom est bien utilisé
expect(content).toContain('inject(DataService)');
});
it('ne devrait pas modifier les fichiers sans OldDataService', async () => {
const tree = new UnitTestTree(null as any);
const originalContent = `
import { Component } from '@angular/core';
@Component({ selector: 'app-other', template: '' })
export class OtherComponent {}
`.trim();
tree.create('src/app/other/other.component.ts', originalContent);
const migratedTree = await runner.runSchematic(
'migration-v2-rename-service',
{},
tree
);
// Le fichier ne doit pas avoir été modifié
const content = migratedTree.readContent('src/app/other/other.component.ts');
expect(content).toBe(originalContent);
});
});
Lancer les tests
# Lancer tous les tests Schematics
npm test
# Lancer avec coverage
npm test -- --coverage
# En watch mode pendant le développement
npm test -- --watch
Checklist qualité pour vos Schematics
- Chaque Schematic est couvert par des tests unitaires (
SchematicTestRunner) - Les Schematics
ng-addsont idempotents (double exécution = même résultat) - Les migrations gèrent le cas "fichier non trouvé" sans planter
-
--dry-runtesté manuellement avant chaque publication - Les messages de log (
context.logger) sont clairs et informatifs - Le
schema.jsonvalide les options et fournit des messages d'erreur utiles - Les migrations
ng updatesont testées sur un projet Angular réel avant publication - Le
migrations.jsondéclare la bonne version cible ("version")
Conclusion
Les Angular Schematics sont un outil de productivité puissant, trop souvent ignoré en dehors des mainteneurs de librairies. Que ce soit pour standardiser le scaffolding au sein d'une équipe, automatiser l'installation d'un design system, ou fournir des migrations automatiques lors des mises à jour de version majeure, ils permettent d'éliminer des tâches répétitives et sources d'erreurs humaines.
La courbe d'apprentissage initiale — comprendre le Tree, les Rule, les templates et le SchematicTestRunner — est compensée rapidement par le gain de temps dans toute équipe qui développe des librairies Angular partagées. Dans un contexte monorepo Nx, les Schematics sont encore plus pertinents : Nx les utilise pour tous ses générateurs et expose une API compatible qui vous permet de créer vos propres générateurs de workspace.
ng generate simple pour votre composant maison le plus utilisé. C'est le meilleur point d'entrée — la valeur ajoutée est immédiatement visible, et vous maîtriserez naturellement l'API Tree avant de passer aux migrations ng update, plus complexes.