~7 min de lecture

Angular : le Page Model, des tests qui restent lisibles

Tu ouvres un fichier de test écrit il y a trois mois. Tu tombes sur ça :

it('should add an article after article submission', async () => {
  const input = fixture.debugElement.query(By.css('input[name=name]'));
  input.nativeElement.value = 'Lait';
  input.triggerEventHandler('input', { target: input.nativeElement });
  await fixture.whenStable();

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

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

Il te faut dix secondes pour comprendre que ce test dit simplement : « quand j'ajoute Lait, Lait apparaît dans la liste ».

Dix secondes, pour une phrase. Multiplie par vingt tests.

Le Page Model (que la littérature anglophone appelle plus souvent Page Object) règle ça. Et contrairement à ce qu'on croit souvent, ce n'est pas une abstraction qu'on ajoute « quand le projet devient gros ». C'est un pattern qui se met en place dès le premier test, et qui grossit avec la suite.


Le problème : deux niveaux de langage dans le même test

Regarde bien le test ci-dessus. Il mélange deux choses qui n'ont rien à voir :

  1. Le comportement : j'ajoute un article, il s'affiche.
  2. La mécanique : debugElement.query, By.css, triggerEventHandler, whenStable.

Le comportement, c'est ce que ton produit fait. La mécanique, c'est comment Angular te laisse le simuler. Le premier intéresse tout le monde. Le second n'intéresse personne, et il change à chaque refonte du template.

🚨 Ce que ça coûte concrètement

  • Illisibilité : tu lis du DOM, pas des specs.
  • Duplication : le même bloc de 5 lignes pour remplir un champ, répété dans chaque test.
  • Fragilité : tu renommes une classe CSS, tu casses quinze tests d'un coup.
  • Bruit dans les diffs : une revue de PR devient impossible à faire sérieusement.

La solution : une classe, en bas du fichier

Un Page Model, c'est une classe ordinaire qui prend la fixture et expose des méthodes qui parlent le langage de l'utilisateur.

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

Le await this.fixture.whenStable() qui clôt chaque méthode n'est pas décoratif : en zoneless, c'est lui qui laisse Angular propager les signaux et re-rendre le template. On y revient en détail plus bas.

Le test devient :

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

Déjà mieux. Mais on n'est qu'à l'étape 1.

💡 Où le poser ?

Dans le fichier de spec, sous le describe. Pas dans un fichier séparé, pas dans un dossier test-utils/.

Tant qu'un seul fichier l'utilise, le sortir ne fait qu'ajouter un aller-retour à la lecture. Tu le déplaceras le jour où un deuxième fichier en aura besoin, et ce cas se présente moins souvent qu'on le croit.


La montée en puissance : trois étapes, pas une

L'erreur classique est de vouloir écrire le Page Model parfait tout de suite. En pratique, il se construit en trois temps, et chaque temps répond à une douleur précise.

Étape 1 : les actions atomiques

setProductName(), setQuantity(), setCategory(), submit(). Une méthode = une interaction utilisateur.

C'est le minimum vital, et ça suffit pour les premiers tests.

Étape 2 : les actions composites

Au bout de quelques tests, tu remarques que ce bloc revient partout :

await pageModel.setProductName('Beurre');
await pageModel.setCategory('Frais');
await pageModel.submit();

Trois lignes qui disent une seule chose : « j'ajoute Beurre au rayon Frais ». Alors tu fabriques la méthode qui dit exactement ça :

async addProductToList(product: {
  name: string;
  quantity?: number;
  category?: string;
}) {
  await this.setProductName(product.name);
  if (product.quantity) {
    await this.setQuantity(product.quantity);
  }
  if (product.category) {
    await this.setCategory(product.category);
  }
  await this.submit();
}

Note le détail qui compte : les champs optionnels sont optionnels dans la signature. Un test qui ne parle pas de quantité n'a pas à mentionner la quantité. C'est ce qui fait qu'un test se lit comme une phrase et pas comme un formulaire.

Le test devient :

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

  // THEN
  const compiled = fixture.nativeElement.querySelector('[data-testid=category-item]');
  expect(compiled.textContent.trim()).toEqual('Frais');
});

Étape 3 : les assertions

C'est l'étape que la plupart des gens sautent, et c'est la plus rentable.

Ton // THEN est encore plein de querySelector. Or une assertion revient elle aussi en boucle : « il n'y a aucun produit », « ce produit est affiché », « la popup est ouverte ».

assertNoProduct() {
  const compiled = this.fixture.nativeElement.querySelectorAll(
    '[data-testid=product-item]'
  );
  expect(compiled.length).toEqual(0);
}

assertProductExists(product: Pick<Product, 'id' | 'name'>) {
  const compiled = this.fixture.nativeElement.querySelector(
    `[id="product-${product.id}"]`
  );
  expect(compiled.textContent).toMatch(product.name);
}

assertNextListDialogIsOpened() {
  const compiled = this.fixture.nativeElement.querySelector(
    '[data-testid=next-list-dialog][open]'
  );
  expect(compiled).toBeTruthy();
}

Et là, le test atteint sa forme finale :

it('should remove an article from the list', async () => {
  // GIVEN
  await pageModel.addProductToList({ name: 'Lait' });

  // WHEN
  await pageModel.removeProduct(0);

  // THEN
  pageModel.assertNoProduct();
});

Cinq lignes. Zéro DOM. Un développeur qui n'a jamais vu le projet comprend ce test en une seconde.


Un Page Model complet ressemble à quoi ?

Sur une app de liste de courses développée en TDD (Angular 20, Vitest, zoneless), le Page Model final tient en 132 lignes et se range en deux familles :

Famille Méthodes Ce qu'elles font
Actions addProductToList, removeProduct, checkProduct, clickOnCloseListButton, clickOnConfirmNextListDialog simulent une interaction, puis await whenStable()
Assertions assertNoProduct, assertProductExists, assertNextListDialogIsOpened, assertNextListDialogIsClosed encapsulent un expect sur le DOM

Une troisième famille apparaît parfois, les scénarios de mise en situation :

async initAllProductArePickedUp() {
  await this.addProductToList({ name: 'Lait' });
  await this.checkProduct(0);
}

Celle-là est utile quand plusieurs tests partagent le même GIVEN un peu long. Attention quand même : si tu en as beaucoup, c'est que tes tests dépendent trop d'un état initial compliqué.


Le vrai bénéfice : ce que tu ne vois pas dans le diff

Voilà le point que les articles sur le Page Object oublient toujours de dire.

Sur cette app, la méthode submitProduct a été renommée en addProductToList. Le renommage touche 12 lignes du fichier de test : la déclaration de la méthode, un appel interne au Page Model, et 10 appels dans les it(). Ça a l'air d'un coût.

Sauf que ces 10 appels sont 10 lignes uniques, remplaçables par un simple rechercher-remplacer. Sans Page Model, le même renommage aurait été un copier-coller-adapter de 10 blocs de manipulation DOM. Et surtout : aucune assertion n'a bougé. Le comportement testé n'a pas changé, donc les expect n'ont pas changé.

C'est exactement la promesse du pattern :

Quand la mécanique bouge, seul le Page Model bouge. Quand le comportement bouge, seuls les tests bougent.

Deux axes de changement, deux endroits distincts. C'est de la séparation des responsabilités appliquée au code de test.


Les pièges à éviter

❌ Mettre de la logique métier dans le Page Model

// ❌ Non : le Page Model calcule
assertTotalPrice(products: Product[]) {
  const expected = products.reduce((sum, p) => sum + p.price * p.quantity, 0);
  expect(this.readTotal()).toEqual(expected);
}

Si ton Page Model refait le calcul que le code de prod est censé faire, tu ne testes plus rien : tu compares deux implémentations du même bug. L'attendu doit être écrit en dur dans le test.

// ✅ Oui : le test décide, le Page Model lit
pageModel.assertTotalPrice('12,50 €');

❌ Exposer la fixture pour « juste ce cas-là »

Le jour où tu écris pageModel.fixture.nativeElement.querySelector(...) dans un test, l'abstraction est morte. Ajoute une méthode au Page Model. Toujours.

❌ Sélecteurs CSS de présentation

// ❌ Fragile : casse au premier restyling
By.css('.card > div.header span.title')

// ✅ Stable : un attribut dédié au test
'[data-testid=product-item]'

Un data-testid est un contrat explicite entre le template et le test. Il ne bouge que si le comportement bouge.

❌ Le factoriser trop tôt entre plusieurs specs

Un Page Model partagé entre trois composants devient vite un fourre-tout que personne n'ose toucher. Duplique tant que les usages divergent. Factorise seulement quand tu constates trois fois le même besoin identique.


Et l'asynchrone dans tout ça ?

Chaque action du Page Model se termine par un await this.fixture.whenStable(). Ce n'est pas décoratif.

En zoneless (provideZonelessChangeDetection()), c'est whenStable() qui laisse Angular propager les signaux et re-rendre le template. Sans lui, ton assertion lit un DOM qui n'a pas encore été mis à jour, et tu passes vingt minutes à te demander pourquoi ton test échoue alors que l'app marche.

Le fait que ce await soit dans le Page Model et pas dans le test est précisément ce qui te protège : tu ne peux pas l'oublier.


Conclusion : le Page Model n'est pas une option

Ce n'est pas un pattern « pour les gros projets ». C'est ce qui fait la différence entre une suite de tests que tu relis avec plaisir et une suite que tu contournes dès qu'elle t'embête.

Ce que tu dois retenir :

Une classe ordinaire, en bas du fichier de spec

Deux familles : actions et assertions

Trois étapes : atomique, composite, assertions

data-testid plutôt que des sélecteurs de présentation

await whenStable() dans le Page Model, jamais dans le test

Zéro logique métier dans le Page Model

La règle d'or :

Un querySelector dans le corps d'un it() est une méthode manquante de ton Page Model, déguisée en détail technique.


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.