Vérifiez les visuels

Dans les chapitres précédents, vous avez travaillé essentiellement sur des services et façades : la partie "cachée" de l'application frontend.

Dans ce chapitre, vous allez passer "de l'autre côté" et commencer à tester les components de l'application. L'union de ces deux suites de tests garantissent les comportements de toute l'application !

Validez l'affichage

Pour ce chapitre, vous allez écrire des tests pour le component  TableStatusList  : celui qui s'affiche quand l'application charge :

L'écran principal de l'application, avec un encart pour chaque table du restaurant. Chaque encart contient le statut de la table (occupé, disponible, en nettoyage) ainsi que sa capacité.
TableStatusList

Pour démarrer notre aventure dans les components, vérifions ce qui se passe quand il n'y a pas de tables de chargé.

Testez le cas du tableau vide

Commencez par créer la suite de tests dans  table-status-list.spec.ts  :

describe('TableStatusList', () => {

});

Le SUT (System Under Test) ici, c'est le component — on va construire pas-à-pas le module de tests :

describe('TableStatusList', () => {
  let component: TableStatusList;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [TableStatusList]
    });
  });
  
});

Notez déjà une différence importante avec les tests de provider : le component est dans le tableau  

imports  et non  providers, ce qui est plutôt logique !

Pour accéder à l'instance du component, vous aurez besoin de son ComponentFixture :

describe('TableStatusList', () => {

  let fixture: ComponentFixture<TableStatusList>;
  let component: TableStatusList;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [TableStatusList]
    });

    fixture = TestBed.createComponent(TableStatusList);
    component = fixture.componentInstance;
  });

});

N'oubliez pas, cette application est configurée en zoneless, donc il faut ajouter le provider qui va avec :

TestBed.configureTestingModule({
    imports: [TableStatusList],
    providers: [provideZonelessChangeDetection()]
});

Écrivons un smoke test et regardons si on a des erreurs :

describe('TableStatusList', () => {

  let fixture: ComponentFixture<TableStatusList>;
  let component: TableStatusList;

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

    fixture = TestBed.createComponent(TableStatusList);
    component = fixture.componentInstance;
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

});

On a l'erreur prévisible :

ɵNotFound: NG0201: No provider found for `TableFacade`. Source: Standalone[TableStatusList2].

Afin d'avoir un contrôle fin des données fournies au component, vous allez fournir un test double — un objet factice — pour cette façade. Techniquement, le type de test double que vous allez fournir est un stub : un objet qui retourne des valeurs fixes et qui n'a pas vraiment d'implémentation en dehors de ce qui est nécessaire pour le test.

Le premier test a besoin d'un tableau vide, donc fournissons un objet très simple, avec une seule méthode  allTables  , qui retourne un Signal d'un tableau vide !

TestBed.configureTestingModule({
    imports: [TableStatusList],
    providers: [
        provideZonelessChangeDetection(),
        { provide: TableFacade, useValue: { allTables: () => signal([]) } }
    ]
});

Notre component dépend seulement de cette méthode, et la signature de cette méthode dit qu'elle doit retourner un Signal qui émet des tableaux d'objets Table : on n'a pas besoin de plus !

Il reste une dernière ligne à ajouter pour que notre  beforeEach  soit complet :

describe('TableStatusList', () => {

  let fixture: ComponentFixture<TableStatusList>;
  let component: TableStatusList;

  beforeEach(async () => {
    TestBed.configureTestingModule({
      imports: [TableStatusList],
      providers: [
        provideZonelessChangeDetection(),
        { provide: TableFacade, useValue: { allTables: () => signal([]) } }
      ]
    });

    fixture = TestBed.createComponent(TableStatusList);
    component = fixture.componentInstance;
    await fixture.whenStable();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

});

On a :

  • rendu  async  la fonction passée à  beforeEach

  • ajouté  await fixture.whenStable()  — cette méthode "laisse le temps" au component de se stabiliser, notamment ça lui permet de lire ses Signals et mettre à jour le contenu du DOM

Avec ça, notre smoke test passe !

Testez le DOM

Angular nous fournit un outil spécifique pour tester ce qui apparaît dans le DOM d'un component : le  DebugElement  :

describe('TableStatusList', () => {

  let fixture: ComponentFixture<TableStatusList>;
  let component: TableStatusList;
  let debugElement: DebugElement;

  beforeEach(async () => {
    TestBed.configureTestingModule({
      imports: [TableStatusList],
      providers: [
        provideZonelessChangeDetection(),
        { provide: TableFacade, useValue: { allTables: () => signal([]) } }
      ]
    });

    fixture = TestBed.createComponent(TableStatusList);
    component = fixture.componentInstance;
    debugElement = fixture.debugElement;
    await fixture.whenStable();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

});

C'est ce  DebugElement  qui va nous permettre de vérifier ce qui se passe dans le DOM…sans DOM ! Même si vos tests tournent dans un pipeline CI, cet élément contiendra tout ce qui devrait contenir le DOM de votre component.

Quel est le comportement qu'on souhaite tester ? Regardons la partie du template du component qui nous intéresse :

<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4 sm:gap-6">
    @for (table of tables(); track table.id) {
        <app-table [table]="table" data-testid="table" />
    } @empty {
        <div class="col-span-full text-center py-16">
            <app-empty-table data-testid="no-tables-message"/>
        </div>
    }
</div>

On veut vérifier que, lorsque la façade retourne un tableau vide, le component affiche l'élément  <app-empty-table>  .

Comme vous pouvez le constater, j'ai ajouté un attribut  data-testid  sur ce component. Cet attribut permet d'identifier un élément de mon DOM sans me soucier du type, de l'id, d'une classe CSS (toutes ces choses peuvent changer !). Ce  data-testid  décrit à quoi sert cet élément dans un comportement : ici, le comportement c'est "afficher un message comme quoi il n'y a pas de table lorsqu'il n'y a pas de table". La forme du message, le texte, la couleur etc ne font pas partie du comportement : ce qui nous intéresse ici, c'est que l'utilisateur est prévenu qu'il n'y a pas de table à afficher.

Commençons à écrire notre test :

it('should show a "no tables" message when there are no tables', () => {
    
});

Vous allez utiliser le DebugElement pour "trouver" l'élément avec l'attribut data-testid="no-tables-message" :

const noTablesMessage = debugElement.query(By.css('[data-testid="no-tables-message"]'));

Étudions ce qui se passe ici :

  • on appelle  query()  , qui retourne le premier élément qui correspond au matcher (il existe également  queryAll()  pour retourner tous les éléments qui correspondent au matcher)

  • on utilise  By.css()  pour identifier l'élément qu'on recherche — le string qu'on y passe est un sélecteur CSS

On veut simplement vérifier que cet élément existe, qu'il fait bien partie du DOM. On peut donc utiliser :

it('should show a "no tables" message when there are no tables', () => {
    const noTablesMessage = debugElement.query(By.css('[data-testid="no-tables-message"]'));
    expect(noTablesMessage).toBeTruthy();
});

Le test passe !

Et si jamais il y a des tables ?

Ça y est : on va enfin commencer à tester l'affichage des tables ! Pour cela, il faut un nouveau stub qui retourne un Signal qui contient des tables, cette fois.

Pour structurer cela, je vous propose de restructurer légèrement ce que vous avez déjà pour créer des suites imbriquées :

import { TableStatusList } from './table-status-list';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DebugElement, provideZonelessChangeDetection, signal } from '@angular/core';
import { TableFacade } from '../../../facades/table.facade';
import { By } from '@angular/platform-browser';
import { TEST_TABLES } from '../../../../../test-data/test-tables';

describe('TableStatusList', () => {

  let fixture: ComponentFixture<TableStatusList>;
  let component: TableStatusList;
  let debugElement: DebugElement;

  describe('When there are no tables', () => {

    beforeEach(async () => {
      TestBed.configureTestingModule({
        imports: [TableStatusList],
        providers: [
          provideZonelessChangeDetection(),
          { provide: TableFacade, useValue: { allTables: () => signal([]) } }
        ]
      });

      fixture = TestBed.createComponent(TableStatusList);
      component = fixture.componentInstance;
      debugElement = fixture.debugElement;
      await fixture.whenStable();
    });

    it('should create', () => {
      expect(component).toBeTruthy();
    });

    it('should show a "no tables" message when there are no tables', () => {
      const noTablesMessage = debugElement.query(By.css('[data-testid="no-tables-message"]'));
      expect(noTablesMessage).toBeTruthy();
    });

  });

  describe('When there are tables', () => {
    beforeEach(async () => {
      TestBed.configureTestingModule({
        imports: [TableStatusList],
        providers: [
          provideZonelessChangeDetection(),
          { provide: TableFacade, useValue: { allTables: () => signal(TEST_TABLES) } }
        ]
      });

      fixture = TestBed.createComponent(TableStatusList);
      component = fixture.componentInstance;
      debugElement = fixture.debugElement;
      await fixture.whenStable();
    });

    it('should create', () => {
      expect(component).toBeTruthy();
    });
  });

});

Ici, vous avez deux fois presque exactement le même setup, avec la nuance qu'au lieu de retourner un tableau vide, la deuxième suite a un stub qui retourne les  TEST_TABLES  .

Vos intuitions de développeur sont certainement en train de vous pousser à mettre tout ça dans une fonction ; pour l'instant, je vais vous demander de ne pas écouter cette intuition. D'ailleurs, si vous exécutez ce nouveau smoke test, vous verrez pourquoi !

ɵNotFound: NG0201: No provider found for `TableDisplayService2`. Source: Standalone[TableStatusList2].

Pourquoi on voit cette erreur ? Le component qu'on teste n'a pas de dépendance directe sur TableDisplayService… mais TableDisplay si !

Maintenant que votre stub de façade retourne des valeurs, TableStatusList essaye d'instancier des TableDisplay ! Il faut donc ajouter TableDisplayService aux providers de cette nouvelle suite :

TestBed.configureTestingModule({
    imports: [TableStatusList],
    providers: [
        provideZonelessChangeDetection(),
        { provide: TableFacade, useValue: { allTables: () => signal(TEST_TABLES) } },
        TableDisplayService
    ]
});

Cela permet à notre smoke test de passer. Trouvons un vrai comportement maintenant.

Le bon nombre de tables

Regardons de nouveau la partie du template qui nous intéresse :

@for (table of tables(); track table.id) {
    <app-table [table]="table" data-testid="table" />
}

Tous les éléments  <app-table>  ont le même  data-testid  , ce qui nous permet d'utiliser  queryAll  pour les retrouver et les compter !

it('should display the right number of tables', () => {
    const tableElements = debugElement.queryAll(By.css('[data-testid="table"]'));
    expect(tableElements.length).toBe(TEST_TABLES.length);
});

C'est aussi simple que ça !

Les bonnes informations

Regardons le component TableDisplay, notamment la partie qui affiche les informations d'une table :

<div class="space-y-1">
    <div class="text-xl font-bold">{{ table().number }}</div>
    <div class="text-sm font-medium opacity-90">{{ formattedStatus() }}</div>
    <div class="text-xs opacity-70">{{ table().capacity }} seats</div>
</div>

Pour l'instant, il n'y a pas d'attribut  data-testid  . Je vous propose de commencer par un test sur la capacité de la table, et donc d'y ajouter un attribut :

<div class="text-xs opacity-70" data-testid="table-capacity">{{ table().capacity }} seats</div>

L'idée maintenant est de tester que toutes les capacités sont correctes, c'est-à-dire que la capacité affichée correspond à la capacité retournée par la façade.

Voici ma proposition :

it('should display the right capacity for each table', () => {
    const capacityElements = debugElement.queryAll(By.css('[data-testid="table-capacity"]'));
    const capacityTextValues = capacityElements.map(element => element.nativeElement.textContent);
    const expectedTextValues = TEST_TABLES.map(t => `${t.capacity} seats`);
    expect(capacityTextValues).toEqual(expectedTextValues);
});

Regardons de plus près :

  • j'utilise  queryAll  pour récupérer tous les éléments souhaités

  • queryAll  retourne d'autres  DebugElement  , et j'utilise leur propriété  nativeElement  pour accéder à l'élément HTML natif, puis au  textContent  de chaque élément

  • je crée une constante qui contient les "expected values", les valeurs auxquelles on s'attend

  • l'assertion compare les valeurs réelles aux valeurs "expected"

Nous avons maintenant un test qui vérifie que la capacité des tables est bien affichée !

À vous de jouer

Contexte

Il faut vérifier que chaque table affiche correctement son "number", le numéro de la table.

Consigne

  • Implémentez un test qui vérifie que le "numéro" de chaque table correspond bien à la propriété "number" de la table

  • Vous aurez besoin de mettre en place un attribut  data-testid  sur l'élément concerné

En résumé

  • ComponentFixture permet de contrôler le cycle de vie du component

  • DebugElement donne l'accès au "DOM" d'un component, même quand aucun navigateur n'est utilisé

  • les attributs data-testid permettent de tester les comportements métier sans se soucier des détails d'implémentation HTML/CSS

  • la séparation des suites imbriquées par situation métier facilite l'organisation et la lisibilité des tests

Allez plus loin

Dans ce cours, vous avez découvert les tests unitaires et ce qui en fait des "bons", et vous avez appris à utiliser Jasmine et les outils Angular pour écrire des tests basiques pour une application.

Pour aller plus loin, je vous propose deux chemins (et vous pouvez emprunter les deux !) :

  • la documentation Angular concernant les tests peut vous aider avec les différents cas particuliers que vous aurez — surtout que vous savez maintenant identifier le quoi qu'il faut tester.

  • jetez un oeil au test-driven development (TDD), où l'on commence par écrire un test avant d'écrire la moindre ligne de code de production — selon moi, c'est la suite logique lorsqu'on sait écrire de bons tests ; le livre de Kent Beck à ce sujet est un excellent point de départ. Je vous recommande également le talk de conférence "TDD, where did it all go wrong?" de Ian Cooper. Même si vous choisissez de ne pas travailler avec cette méthode, la découvrir et la comprendre enrichira votre pratique.

Je dirais que le plus important, à partir de maintenant, c'est que vous vous poussiez à bien tester vos produits, que ce soit dans votre travail de tous les jours ou dans vos side projects. J'espère vous avoir convaincu de l'utilité de ces tests, et je vous encourage à encourager les autres : malgré les décennies d'existence de ces approches, elles sont encore trop peu pratiquées et souvent très mal comprises.

Go forth and write tests! 

Après ce dernier chapitre, il est temps de valider vos connaissances avec un quiz !

Ever considered an OpenClassrooms diploma?
  • Up to 100% of your training program funded
  • Flexible start date
  • Career-focused projects
  • Individual mentoring
Find the training program and funding option that suits you best