~7 min de lecture
Migrer vers NgRx Signal Store sans toucher à un test
« Avec des tests, tu refactores sans stress. »
Tout le monde le répète. Presque personne ne le prouve. Alors voici une démonstration, avec des commits datés et des chiffres vérifiables.
Le contexte : une app de liste de courses en Angular 20, développée en TDD (Test Driven Development : on écrit le
test avant le code). Un soir de septembre, en 44 minutes, elle est passée d'un composant monolithique avec un
signal() local à quatre composants et un NgRx Signal Store événementiel.
Nombre de lignes de test modifiées : zéro.
📖 Le vocabulaire, en deux lignes
L'article manipule le vocabulaire du Signal Store. Si tu ne le pratiques pas, voilà le strict nécessaire :
- Événementiel : le composant n'appelle plus de méthode sur le store. Il dispatche (émet) un événement, du genre « on m'a demandé de créer un produit ». Le store écoute.
- Reducer : une fonction pure qui reçoit l'état courant et l'événement, et renvoie le nouvel état. Elle ne fait rien d'autre : pas d'appel réseau, pas d'effet de bord.
Si tu veux le détail du store lui-même, il est dans cet article. Ici, le sujet n'est pas le store : c'est ce que les tests ont fait pendant qu'on l'installait.
Le point de départ
Un seul composant. Un état local. Le strict minimum pour faire passer 5 tests.
export default class ShoppingListPage {
protected name = signal('');
protected readonly products = signal<string[]>([]);
protected submitProduct(): void {
this.products.update((products) => [...products, this.name()]);
this.name.set('');
}
}
Le template, lui, contient tout : le formulaire, le message « pas encore d'article », la liste des produits. C'est laid, mais c'est vert. En TDD, c'est ce qui compte à cet instant précis : on n'écrit pas la belle architecture d'abord, on la fait émerger ensuite.
Et les 5 tests qui gardent cet état, eux, ne parlent jamais de signal() ni de submitProduct(). Ils parlent de ça :
it('should add an article after article submission', async () => {
// GIVEN
await pageModel.setProductName('Lait');
// WHEN
await pageModel.submit();
// THEN
const compiled = fixture.nativeElement.querySelector('[data-testid=product-items]');
expect(compiled.textContent).toMatch('Lait');
});
Retiens cette phrase, c'est toute l'explication de l'article : le test décrit ce que voit l'utilisateur. Pas ce que fait la classe.
Refacto 1 : découper le composant (3 extractions, 14 minutes)
Le template est devenu trop gros. On le découpe.
- 22:32 - extraction de
NoProductMessage - 22:38 - extraction de
ProductsList - 22:46 - extraction de
CreateProductForm
Chacun de ces trois commits touche exactement deux fichiers : la page qui rétrécit, et le nouveau composant.
.../shopping-list/pages/shopping-list-page.ts | 20 ++++----------------
.../features/shopping-list/ui/no-product-message.ts | 21 +++++++++++++++++++++
.../shopping-list/pages/shopping-list-page.ts | 33 ++----------------
src/app/features/shopping-list/ui/products-list.ts | 39 ++++++++++++++++++++++
.../shopping-list/pages/shopping-list-page.ts | 50 +++----------------
.../shopping-list/ui/create-product-form.ts | 56 ++++++++++++++++++++++
Aucun *.spec.ts dans ces diffs. Le DOM rendu est identique, donc les tests qui interrogent le DOM ne bronchent pas.
💡 Pourquoi les tests ne voient rien
Un test qui fait querySelector('[data-testid=product-items]') se moque de savoir quel composant a produit cet
élément. Que le markup vienne de la page ou d'un enfant <app-products-list>, le résultat dans le DOM est le même.
C'est la définition d'un test de comportement. Et c'est ce qui rend le découpage gratuit.
💡 Au passage, la signature a bougé. submitProduct() est devenu submitProduct(name: string), et le signal name
a disparu de la page : le formulaire est maintenant un composant enfant qui remonte sa valeur. La signature d'une
méthode et l'état interne de la classe ont changé, sans que les tests s'en aperçoivent. On y revient plus bas.
Refacto 2 : passer au Signal Store (23 minutes plus tard)
L'état vit encore dans le composant. On le sort.
// AVANT
export default class ShoppingListPage {
protected readonly products = signal<string[]>([]);
protected submitProduct(name: string): void {
this.products.update((products) => [...products, name]);
}
}
// APRÈS
export default class ShoppingListPage {
protected readonly products = inject(shoppingListStore).products;
private readonly _dispatcher = inject(Dispatcher);
protected submitProduct(name: string): void {
this._dispatcher.dispatch(createProductEvents.createProductRequested(name));
}
}
Avec, à côté, deux fichiers neufs. Le groupe d'événements :
export const createProductEvents = eventGroup({
source: 'Shopping List Page',
events: {
createProductRequested: type<string>(),
},
});
Et le store :
export const shoppingListStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withReducer(
on(createProductEvents.createProductRequested, ({ payload }, state) => ({
products: [...state.products, payload],
}))
)
);
On vient de changer :
- le mécanisme de stockage de l'état (signal local ➜ store global
providedIn: 'root') - le mode de communication (appel de méthode ➜ dispatch d'événement)
- le cycle de mise à jour (
update()impératif ➜ reducer pur)
Trois choses structurantes. Le commit touche 3 fichiers, et pas un seul n'est un fichier de test.
.../shopping-list/pages/shopping-list-page.ts | 11 ++++++++---
.../shopping-list/store/shopping-list-store.ts | 21 +++++++++++++++++++++
.../use-cases/create-product-events.ts | 9 +++++++++
3 files changed, 38 insertions(+), 3 deletions(-)
Le bilan de la soirée
| Heure | Commit | Fichiers touchés | Tests modifiés |
|---|---|---|---|
| 22:25 | 5e2d330 - ajout du 5e test | 2 | (le test lui-même) |
| 22:32 | 72a91c6 - extraction NoProductMessage |
2 | 0 |
| 22:38 | e59bf0b - extraction ProductsList |
2 | 0 |
| 22:46 | e3124af - extraction CreateProductForm |
2 | 0 |
| 23:09 | b85cec7 - migration Signal Store | 3 | 0 |
Un mois plus tard, l'extraction d'un quatrième composant (NextListDialog) donne le même résultat : 2 fichiers touchés,
0 test modifié, alors que la suite compte cette fois 18 tests.
Quatre extractions de composants et une migration d'architecture d'état. Zéro ligne de test réécrite.
C'est ça, « refactorer sans stress ». Pas « j'ai des tests donc je suis rassuré », mais « je change l'architecture et mon filet de sécurité ne me demande aucun travail supplémentaire ».
Pourquoi ça marche : la frontière du test
Le résultat n'a rien de magique. Il découle d'une seule décision, prise dès le premier test : tester par le DOM, pas par la classe.
✅ Ce qui rend un refactoring gratuit
// Le test entre par le formulaire et sort par la liste affichée
await pageModel.addProductToList({ name: 'Lait' });
pageModel.assertProductExists({ id: '...', name: 'Lait' });
Entre les deux, tu peux mettre ce que tu veux : un signal, un store NgRx, un service, trois composants imbriqués. Le
test ne s'en aperçoit pas.
❌ Ce qui aurait tout cassé
// ❌ Le test connaît l'implémentation
it('should add a product', () => {
const component = fixture.componentInstance;
component.submitProduct('Lait');
expect(component.products()).toEqual(['Lait']);
});
Ce test-là serait mort trois fois dans la soirée :
- Au découpage :
submitProductchange de signature quand le formulaire devient un composant enfant. - À la migration :
productsn'est plus unsignallocal mais une projection du store. - Au passage en événementiel :
submitProductne met plus rien à jour, il dispatche.
Trois refactorings, trois réécritures de tests. Et à chaque réécriture, tu perds la garantie que le comportement n'a pas bougé : tu ne sais plus si tu as adapté le test ou masqué une régression.
Le piège du « je teste le store »
Une objection légitime : « mais alors, tu ne testes jamais le store directement ? »
Non. Et c'est volontaire.
Le store n'est pas une fonctionnalité, c'est un choix d'implémentation. Le jour où tu le remplaces par autre chose, tu veux que tes tests continuent de valider le produit, pas d'archiver ta décision technique de septembre.
Il existe une exception, quand l'état n'est pas observable depuis le DOM :
it('should mark a product as picked up when I click on it', async () => {
// GIVEN
await pageModel.addProductToList({ name: 'Lait' });
// WHEN
await pageModel.checkProduct(0);
// THEN
expect(store.products()[0].pickedUp).toBe(true);
});
Ici, on lit le store. Mais regarde bien : l'action reste une interaction utilisateur (checkProduct, un vrai clic
sur une vraie checkbox). Seule l'assertion emprunte un raccourci.
Et d'ailleurs, le test suivant dans la suite refait la même vérification par le DOM :
it('should set product checkbox checked when picked up the product', async () => {
await pageModel.addProductToList({ name: 'Lait' });
await pageModel.checkProduct(0);
const compiled: HTMLInputElement = fixture.nativeElement.querySelector(
'[data-testid=product-item] input[type=checkbox]'
);
expect(compiled.checked).toBe(true);
});
Règle pratique : si tu peux l'observer dans le DOM, observe-le dans le DOM. Sinon, lis le store, mais garde une action utilisateur en entrée.
Ce que ça change pour toi
Cette soirée n'est pas un exploit, c'est une conséquence mécanique. Si tes tests décrivent des comportements, tes refactorings sont gratuits. S'ils décrivent des classes, chaque refactoring se paie deux fois.
Ce que tu dois retenir :
✅ Tester par le DOM rend le découpage de composants invisible aux tests
✅ Un store est une implémentation, pas une fonctionnalité à tester en soi
✅ componentInstance dans un test est le signal d'alarme numéro un
✅ Une action utilisateur en entrée, toujours, même quand l'assertion lit le store
✅ Un refactoring qui oblige à réécrire des tests n'est pas couvert par ces tests
La règle d'or :
Si ton refactoring t'oblige à modifier des tests, ce ne sont pas tes tests qui te protègent. Ce sont eux que tu es en train de protéger.
Pour aller plus loin
- La série complète en vidéo : la playlist Angular & TDD
- Le point de départ, sur un projet vide : par où commencer quand rien n'existe
- Le pattern qui rend ces tests lisibles : le Page Model
- Le store en détail : découverte du NgRx Signal Store
Note sur les extraits : les commits de l'époque écrivaient encore l'attribut datatest-id, renommé depuis en
data-testid. Les extraits de cet article utilisent la convention finale, la seule à retenir.
Envie de découvrir EasyAngularKit et son programme ?
→ Découvre EasyAngularKit : https://www.easyangularkit.com/easyangularkit