~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 :

  1. Au découpage : submitProduct change de signature quand le formulaire devient un composant enfant.
  2. À la migration : products n'est plus un signal local mais une projection du store.
  3. Au passage en événementiel : submitProduct ne 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


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

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.