~7 min de lecture
Angular : quand faut-il vraiment mocker dans un test ?
Sur une app Angular 20 développée entièrement en TDD, la suite finale compte 23 tests. Ils couvrent le formulaire, la liste, les catégories, la suppression, le marquage « pris », la popup de fin de liste et la persistance.
Nombre d'appels à vi.mock(), vi.fn() ou vi.spyOn() dans tout le projet : un seul.
$ grep -rE "vi\.(spyOn|mock|fn)" src --include=*.spec.ts
src/app/features/shopping-list/pages/shopping-list-page.spec.ts:52:
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValueOnce(
Ce n'est pas de la chance, c'est une méthode. Voici laquelle.
Le réflexe qu'on nous a appris
Tu testes un composant. Il injecte trois services. Le réflexe :
// ❌ Le réflexe par défaut
describe('CurrentListPage', () => {
let mockRepository: Partial<ShoppingListRepositoryPort>;
beforeEach(() => {
mockRepository = {
getAll: vi.fn(),
save: vi.fn(),
};
TestBed.configureTestingModule({
providers: [{ provide: ShoppingListRepositoryPort, useValue: mockRepository }],
});
});
it('should add an item', () => {
mockRepository.getAll = vi.fn().mockReturnValue(of([mockList]));
mockRepository.save = vi.fn().mockReturnValue(of(updatedList));
// ... action ...
expect(mockRepository.save).toHaveBeenCalledOnce();
});
});
Ce code n'est pas inventé pour l'article : c'est une vraie version d'un vrai projet, supprimée depuis. Le message du commit qui l'a remplacée dit tout :
feat: Add FakeShoppingListRepository for robust testing and replace brittle mocks
Brittle. Cassant. Voilà le mot.
🚨 Ce qui ne va pas
Le test devient tautologique. Tu déclares que
getAllrenvoie[mockList], puis tu vérifies que l'écran affichemockList. Tu as testé ton propremockReturnValue.toHaveBeenCalledOnce()teste l'implémentation. Tu figes le fait que la sauvegarde passe parsave(), une fois. Change pour un batch, un debounce, unsaveAll(): le comportement utilisateur est identique, ton test est rouge.Le mock ne connaît aucune règle. Un vrai repository refuse un doublon, met à jour au lieu d'insérer, renvoie ce qu'on vient d'écrire. Le mock, lui, renvoie ce que tu lui as dit de renvoyer. Tes tests passent avec une implémentation qui ne pourrait pas exister.
La configuration écrase le test. Dans l'exemple ci-dessus, il y a plus de lignes de préparation de mocks que de lignes décrivant le comportement.
Le vocabulaire, parce qu'il change tout
On dit « mocker » pour tout et n'importe quoi. Les quatre doublures ci-dessous n'ont pourtant rien à voir.
| Doublure | Ce que c'est | Ce qu'on vérifie |
|---|---|---|
| Stub | renvoie une valeur figée | l'état final |
| Fake | une vraie implémentation, simplifiée (en mémoire) | l'état final |
| Mock | enregistre les appels reçus | les interactions |
| Spy | enveloppe une fonction réelle pour espionner ses appels ou forcer sa valeur de retour | les interactions, ou rien (neutralisation) |
Le spy est le seul des quatre qui s'applique à du code existant plutôt que de le remplacer : vi.spyOn(objet, 'methode')
laisse la vraie méthode en place et vient se greffer dessus. C'est cette propriété qui le rend utile pour un cas bien
précis, qu'on verra plus bas.
La distinction qui compte : un mock sert à vérifier comment le code a parlé à ses collaborateurs. Un fake sert à faire tourner le code pour de vrai, en plus rapide.
Le TDD de l'école de Detroit (ou classiciste) part d'un principe simple : on teste des états, pas des conversations. Donc on utilise des fakes, et on garde les mocks pour les cas où il n'existe aucune autre option.
La bonne frontière : un port, deux adaptateurs
Plutôt que de mocker au hasard des injections, on définit un seul point de sortie de la feature : un port.
Un port, c'est le contrat que ta feature passe avec le monde extérieur - « voilà ce dont j'ai besoin, peu importe qui le fournit ». Une implémentation concrète de ce contrat s'appelle un adaptateur. Tu en écris un pour la production, un pour les tests.
export abstract class ProductsPort {
abstract getAll(): Observable<Product[]>;
abstract save(product: Product): void;
abstract remove(productId: Product['id']): void;
}
Une classe abstraite, qui sert à la fois de contrat et de token d'injection Angular. Ensuite, deux implémentations.
En production : le vrai adaptateur
export class LocalStorageProductsAdapter implements ProductsPort {
getAll(): Observable<Product[]> {
return defer(() => {
const item = localStorage.getItem('products');
if (item === null) return of([]);
return of(JSON.parse(item));
});
}
save(product: Product): void {
let products: Product[] = JSON.parse(localStorage.getItem('products') || '[]');
if (products.some((p) => p.id === product.id)) {
products = products.map((p) => (p.id === product.id ? product : p));
localStorage.setItem('products', JSON.stringify(products));
return;
}
localStorage.setItem('products', JSON.stringify([...products, product]));
}
// remove(...)
}
En test : le fake
export class InMemoryProductsAdapter implements ProductsPort {
products: BehaviorSubject<Product[]> = new BehaviorSubject<Product[]>([]);
getAll(): Observable<Product[]> {
return this.products.asObservable();
}
save(product: Product): void {
if (this.products.value.some((p) => p.id === product.id)) {
this.products.next(
this.products.value.map((p) => (p.id === product.id ? product : p))
);
return;
}
this.products.next([...this.products.value, product]);
}
remove(productId: Product['id']): void {
this.products.next(
this.products.value.filter((product) => product.id !== productId)
);
}
}
Regarde bien : le fake implémente la même règle que le vrai. Si l'id existe déjà, on met à jour ; sinon on ajoute. Ce n'est pas un objet creux qui renvoie ce qu'on veut, c'est un vrai magasin de données, simplement stocké en RAM.
Le branchement, une fois pour toutes
En production, dans app.config.ts :
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideRouter(appRoutes),
{ provide: ProductsPort, useClass: LocalStorageProductsAdapter },
],
};
En test, dans test-setup.ts :
beforeEach(() =>
TestBed.configureTestingModule({
providers: [...appConfig.providers],
}).overrideProvider(ProductsPort, {
useFactory: () => new InMemoryProductsAdapter(),
})
);
Une seule ligne de substitution, globale. Aucun test individuel n'a à s'en préoccuper. C'est ce qui explique qu'il ne reste qu'un seul spy dans tous les fichiers de spec réunis.
💡 Et pour préparer un état initial ?
Pas besoin de mock non plus : on instancie le fake avec des données.
beforeEach(async () => {
fixture = TestBed.overrideProvider(ProductsPort, {
useFactory: () => {
const adapter = new InMemoryProductsAdapter();
adapter.products.next([product]);
return adapter;
},
}).createComponent(ShoppingListPage);
// ...
});
Et l'assertion se fait sur l'état réel du fake :
it('should remove product from storage when product is removed', async () => {
// WHEN
await pageModel.removeProduct(0);
// THEN
expect(adapter.products.value.length).toEqual(0);
});
On ne vérifie pas que remove() a été appelé. On vérifie que le produit n'est plus là. C'est toute la
différence : la deuxième formulation survit à un changement d'implémentation, la première non.
Le seul mock légitime : le non-déterminisme
Reste un cas que le fake ne résout pas.
map(({ payload }) => ({
...payload,
id: crypto.randomUUID(),
category: payload.category.length ? payload.category : 'Autres',
pickedUp: false,
})),
L'id est généré côté front, aléatoirement. Impossible d'écrire une assertion dessus. D'où l'unique spy du projet :
it('should add an article after article submission', async () => {
// GIVEN
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValueOnce(
'11111-1111-1111-1111-111111111111'
);
// WHEN
await pageModel.addProductToList({ name: 'Lait' });
// THEN
pageModel.assertProductExists({
id: '11111-1111-1111-1111-111111111111',
name: 'Lait',
});
});
La règle qui se dégage
On ne mocke pas un collaborateur. On neutralise une source d'indéterminisme.
La liste est courte, et c'est normal :
crypto.randomUUID(),Math.random()Date.now(),new Date()- la géolocalisation, les permissions navigateur
- le vrai réseau, quand il n'est pas déjà derrière un port
Tout le reste - tes services, tes stores, tes repositories - passe par un fake.
⚠️ Le coût, parce qu'il y en a un
Il faut être honnête : cette approche déplace un problème, elle ne le supprime pas.
Sur ce projet, la couverture globale est de 93 % des lignes. Si on met de côté un fichier de type TypeScript pur,
qui ne produit aucune instruction exécutable et n'a donc rien à couvrir, chaque fichier applicatif est à 100 %...
sauf un.
...ports/adapters | 52.27 | 88.88 | 50 | 52.27
local-storage-products-adapter.ts | 19.23 | 100 | 0 | 19.23
Le vrai adaptateur localStorage n'est presque pas testé. Logique : il est remplacé par le fake dans tous les tests.
C'est le prix du pattern, et il faut le payer sciemment :
- Soit tu écris un test dédié à l'adaptateur réel, qui vérifie le contrat contre le vrai
localStorage. - Soit tu écris un test de contrat partagé, exécuté contre les deux implémentations, qui garantit qu'elles se comportent pareil.
- Soit tu assumes que ce code est trivial et couvert par les tests end-to-end.
Ce qu'il ne faut pas faire, c'est regarder les 93 % et croire que tout est vérifié. Un fake qui diverge de la vraie implémentation est un bug qu'aucun test unitaire ne verra.
Conclusion
Le mock n'est pas interdit, il est rare. Quand tu en écris un, pose-toi une question simple : « est-ce que je neutralise du hasard, ou est-ce que je remplace une vraie logique par une réponse figée ? »
Dans le second cas, tu es probablement en train d'écrire un test qui ne prouve rien.
Ce que tu dois retenir :
✅ Un port par frontière, une classe abstraite qui sert de token
✅ Un fake en mémoire qui respecte les mêmes règles que le vrai
✅ La substitution dans test-setup.ts, pas dans chaque test
✅ Asserter sur l'état, jamais sur toHaveBeenCalled()
✅ Le mock pour le non-déterminisme uniquement (uuid, date, random)
✅ Couvrir le vrai adaptateur ailleurs : c'est le trou que laisse la méthode
La règle d'or :
Si ton test doit apprendre au collaborateur quoi répondre, ce n'est plus le code que tu testes, c'est ta mise en scène.
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
- Rendre ces tests lisibles : le Page Model
- Ce que ça permet ensuite : migrer vers Signal Store sans toucher à un test
Envie de découvrir EasyAngularKit et son programme ?
→ Découvre EasyAngularKit : https://www.easyangularkit.com/easyangularkit