~8 min de lecture

TDD Angular : par où commencer quand rien n'existe ?

Le TDD, sur le papier, tout le monde comprend : on écrit le test avant le code.

En pratique, il y a un moment très inconfortable que personne ne raconte : tu as un projet vide. Pas de composant, pas de template, pas de modèle de données. Et on te demande d'écrire un test qui décrit... quoi, exactement ?

Cet article traite ce moment-là. Le premier test, sur une vraie app, dans l'ordre où ça s'est passé.


Le contexte

Une application de liste de courses. Angular 20 en zoneless, Vitest comme runner, aucun composant écrit.

La configuration minimale, une fois pour toutes :

// app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideZonelessChangeDetection(),
    provideRouter(appRoutes),
  ],
};
// test-setup.ts
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());

beforeEach(() =>
  TestBed.configureTestingModule({
    providers: [...appConfig.providers],
  })
);

💡 Le détail qui compte : le setup de test réutilise appConfig.providers. Tes tests tournent avec la même configuration que la production. Tu ne testes pas une application parallèle qui aurait sa propre DI.


La mauvaise question de départ : « quelle classe je teste ? »

Le premier réflexe, quand on vient du test unitaire classique, c'est de se demander quelle classe on va tester. Et comme aucune classe n'existe, on bloque.

Mauvaise question. En TDD, tu ne pars pas d'une classe, tu pars d'un scénario utilisateur. La classe, c'est ce qui apparaîtra à la fin pour faire passer le test, et son existence n'est même pas décidée à ce stade.

Le bon point de départ est une phrase du type :

« Quand j'arrive sur ma liste et qu'elle est vide, je dois voir un message qui me le dit. »

Pas de classe. Pas de méthode. Un comportement observable.


Le premier scénario : l'état vide

Contre-intuitif, mais c'est le bon choix : commence par l'état vide, pas par l'action principale.

Trois raisons :

  1. C'est le scénario le plus simple : aucune donnée à préparer, aucune interaction à simuler.
  2. C'est le premier écran que voit l'utilisateur. Un état vide raté, c'est une première impression ratée.
  3. Ça te force à créer le squelette (composant, route, rendu) sans avoir à traiter de logique en même temps.

🔴 RED

describe('ShoppingListPage', () => {
  let fixture: ComponentFixture<ShoppingListPage>;

  beforeEach(async () => {
    fixture = TestBed.createComponent(ShoppingListPage);
    await fixture.whenStable();
  });

  it('should display empty message when no article', async () => {
    const compiled = fixture.nativeElement.querySelector(
      '[data-testid=empty-message]'
    );
    expect(compiled.textContent).toMatch("📝pas encore d'article");
  });
});

Ce test ne compile même pas : ShoppingListPage n'existe pas.

C'est normal, et c'est même souhaitable. Une erreur de compilation est un rouge parfaitement valide. Le principe de la phase RED n'est pas « le test échoue proprement », c'est « le test ne passe pas ».

🟢 GREEN

Le minimum absolu pour passer au vert :

@Component({
  imports: [FormsModule],
  template: `
    @if (products().length === 0) {
      <section data-testid="empty-message">
        <p>📝pas encore d'article</p>
      </section>
    }
  `,
})
export default class ShoppingListPage {
  protected readonly products = signal<string[]>([]);
}

⚠️ Résiste à l'envie d'écrire le formulaire tout de suite. Tu sais qu'il faudra l'ajouter. Ce n'est pas une raison pour l'écrire maintenant : rien ne le demande encore, et donc rien ne le teste.

C'est le principe YAGNI (You Aren't Gonna Need It), et c'est la discipline la plus dure à tenir au début.


Le deuxième scénario : la première action

Maintenant seulement, on ajoute un article.

🔴 RED

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');
});

Remarque deux choses.

Le format Given / When / Then. Trois commentaires qui structurent chaque test : la situation de départ, l'action, le résultat attendu. Ça paraît scolaire, mais c'est ce qui rend un test lisible six mois plus tard sans avoir à lire l'implémentation.

Le pageModel. Il apparaît dès le deuxième test, parce que remplir un champ en Angular n'est pas une ligne :

class PageModel {
  constructor(readonly fixture: ComponentFixture<ShoppingListPage>) {}

  async setProductName(name: string) {
    const productNameInput = this.fixture.debugElement.query(
      By.css('input[name=name]')
    );
    productNameInput.nativeElement.value = name;
    productNameInput.triggerEventHandler('input', {
      target: productNameInput.nativeElement,
    });
    await this.fixture.whenStable();
  }

  async submit() {
    this.fixture.debugElement
      .query(By.css('button[type=submit]'))
      .nativeElement.click();
    await this.fixture.whenStable();
  }
}

Cette classe vit en bas du fichier de spec. Elle grossira avec la suite. C'est le sujet d'un article dédié, mais retiens dès maintenant qu'elle se met en place au deuxième test, pas au trentième.

🟢 GREEN

@Component({
  imports: [FormsModule],
  template: `
    <form (ngSubmit)="submitProduct()">
      <input type="text" name="name" id="product-name" [(ngModel)]="name" />
      <label for="product-name">Produit</label>
      <button type="submit">Ajouter</button>
    </form>

    @if (products().length === 0) {
      <section data-testid="empty-message">
        <p>📝pas encore d'article</p>
      </section>
    }

    <section data-testid="product-items">
      @for (product of products(); track product) {
        <p>{{ product }}</p>
      }
    </section>
  `,
})
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('');
  }
}

Un signal<string[]>. Pas de store, pas de service, pas de modèle Product. Un tableau de chaînes.

C'est volontairement pauvre, et c'est exactement ce qu'il faut. La bonne architecture n'est pas décidée le premier soir : elle est extraite plus tard, sous la protection des tests.

🔵 REFACTOR

C'est la phase que tout le monde saute, et c'est celle qui rend la méthode rentable. Deux règles :

  • la suite est verte avant, la suite est verte après ;
  • on ne change aucun comportement, seulement la forme.

Au deuxième test, il n'y a pas encore grand-chose à nettoyer côté production. Le refactoring porte donc sur le code de test, et c'est normal : les tests sont du code, ils se refactorent au même titre.

Concrètement, ici : les deux appels setProductName() puis submit() reviennent dans chaque test. On les fusionne en une méthode qui dit ce que fait l'utilisateur.

async addProductToList(product: { name: string }) {
  await this.setProductName(product.name);
  await this.submit();
}

Et le test se resserre :

it('should add an article after article submission', async () => {
  // WHEN
  await pageModel.addProductToList({ name: 'Lait' });

  // THEN
  const compiled = fixture.nativeElement.querySelector(
    '[data-testid=product-items]'
  );
  expect(compiled.textContent).toMatch('Lait');
});

L'objet en paramètre plutôt qu'une simple chaîne n'est pas gratuit : quand la quantité et le rayon arriveront, ils s'y grefferont en champs optionnels sans casser un seul appel existant. Cette montée en puissance est détaillée dans l'article sur le Page Model.

⚠️ Le piège : ne fais jamais ce nettoyage pendant que la suite est rouge. Tu mélangerais deux causes de changement, et si quelque chose casse, tu ne saurais plus laquelle accuser.

Sur ce projet, c'est cette phase qui a ensuite permis de découper la page en composants puis de passer à un store NgRx, sans réécrire un seul test.


Le piège du zoneless : await whenStable()

Un point technique qui fait perdre du temps à tout le monde au démarrage.

En zoneless, Angular ne surveille plus les événements via zone.js. Quand tu déclenches une interaction dans un test, le template n'est pas immédiatement à jour. Si tu assertes tout de suite, tu lis un DOM périmé.

// ❌ Le DOM n'est pas encore à jour
pageModel.submit();
expect(compiled.textContent).toMatch('Lait');

// ✅ On laisse Angular propager et re-rendre
await pageModel.submit();
expect(compiled.textContent).toMatch('Lait');

La bonne pratique : mettre le await this.fixture.whenStable() à la fin de chaque méthode du Page Model. Comme ça, tu ne peux plus l'oublier dans un test.

Si tu n'as pas encore mis en place Vitest en zoneless, c'est expliqué ici.


Les cinq erreurs de débutant

Elles reviennent systématiquement. Autant les connaître avant.

1. Écrire trop de code en phase GREEN

Tu vois déjà les cinq features suivantes, tu les écris « tant que tu y es ». Résultat : du code non testé, et une phase RED qui ne sert plus à rien. Le minimum pour passer au vert. Rien de plus.

2. Refactorer pendant la phase RED

Ton test est rouge, et tu en profites pour renommer trois trucs. Le refactoring a sa phase, c'est la troisième, pour la raison expliquée plus haut.

3. Tester l'implémentation plutôt que le comportement

// ❌ Testera la classe, pas le produit
component.submitProduct();
expect(component.products()).toEqual(['Lait']);

// ✅ Teste ce que voit l'utilisateur
await pageModel.addProductToList({ name: 'Lait' });
pageModel.assertProductExists({ id: '...', name: 'Lait' });

Le premier meurt au premier refactoring. Le second survit à un changement complet d'architecture interne.

4. Se passer du Page Model

« C'est juste trois lignes de querySelector, je verrai plus tard. » Au dixième test, tes specs sont illisibles et un renommage de classe CSS t'en casse quinze d'un coup.

5. Ne jamais supprimer de test

Un test qui garde du code disparu, ou qui décrit un comportement qui n'existe plus, est une dette. Le supprimer est une décision saine, à condition de ne jamais le faire parce qu'il est rouge. C'est développé dans cet article.


La checklist de ton premier test

Avant de considérer que c'est bon :

  • J'ai défini le scénario en une phrase, du point de vue de l'utilisateur
  • J'ai écrit le test avant le code de production
  • Je l'ai vu échouer (compilation ou assertion, peu importe)
  • Il suit le format Given / When / Then
  • Il vérifie un seul comportement
  • Il passe par le DOM, pas par componentInstance
  • J'utilise un Page Model pour les interactions
  • Je suis passé au vert avec le minimum de code
  • J'ai refactoré après le passage au vert, suite verte

Ce que ça donne au bout du compte

Ce premier test de l'état vide fait aujourd'hui partie d'une suite de 23 tests qui s'exécutent en quelques secondes, avec tout le code métier à 100 % de couverture et un trou assumé sur l'adaptateur de persistance. Entre les deux, l'application a changé d'architecture deux fois sans qu'une seule ligne de test soit réécrite.

Rien de tout ça n'était prévu le premier soir. C'est ce qui est rassurant : tu n'as pas besoin de connaître l'architecture finale pour commencer. Tu as besoin d'un scénario, d'un test rouge, et de la discipline de ne pas écrire une ligne de plus que nécessaire.

Ce que tu dois retenir :

Pars d'un scénario utilisateur, pas d'une classe

Commence par l'état vide, le plus simple à décrire

Une erreur de compilation est un rouge valide

Le minimum de code pour passer au vert (YAGNI)

Page Model dès le deuxième test

await whenStable() partout en zoneless

L'architecture émerge, elle ne se décide pas d'avance

La règle d'or :

Tu n'écris jamais une ligne de code de production sans avoir vu, de tes yeux, un test rouge qui la réclame.


Pour aller plus loin


Note sur les extraits : les commits d'origine é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.