~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

  1. Le test devient tautologique. Tu déclares que getAll renvoie [mockList], puis tu vérifies que l'écran affiche mockList. Tu as testé ton propre mockReturnValue.

  2. toHaveBeenCalledOnce() teste l'implémentation. Tu figes le fait que la sauvegarde passe par save(), une fois. Change pour un batch, un debounce, un saveAll() : le comportement utilisateur est identique, ton test est rouge.

  3. 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.

  4. 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 :

  1. Soit tu écris un test dédié à l'adaptateur réel, qui vérifie le contrat contre le vrai localStorage.
  2. Soit tu écris un test de contrat partagé, exécuté contre les deux implémentations, qui garantit qu'elles se comportent pareil.
  3. 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


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.