Introduction : les limites de la programmation fonctionnelle pure

Dans l’article précédent, j’ai montré comment organiser les tests Playwright avec la programmation fonctionnelle : des locators réutilisables, des fonctions pures, une structure claire. C’est une bonne fondation.

Mais en pratique, sur une base de code qui grandit, j’ai rapidement découvert les limites de cette approche. Les fonctions se multiplient. Les fichiers explosent. Le découpage devient flou. Et petit à petit, c’est le bazar.

J’ai dû trouver une solution pour garder l’organisation à l’échelle, sans revenir à du code spaghetti ou à de la duplication. C’est là que le Factory Function Pattern m’a sauvé.

Cet article montre exactement les problèmes que j’ai rencontrés, et comment ce pattern les résout.


Le problème : la croissance incontrôlée

Imaginons un projet qui a grandi. On a maintenant plusieurs fichiers :

  • functions/articles.ts — interactions avec les articles
  • functions/menu.ts — interactions avec le menu
  • functions/modal.ts — interactions avec les modales
  • functions/forms.ts — interactions avec les formulaires
  • functions/auth.ts — authentification
  • functions/search.ts — recherche
  • … et ça continue

Le problème n°1 : les fonctions qui se ressemblent

Avec la programmation fonctionnelle pure, chaque action demande une fonction.

Pour le menu, on a besoin de :

  • Vérifier que le menu est visible
  • Ouvrir le menu
  • Fermer le menu
  • Naviguer vers un élément
  • Naviguer vers un sous-élément (conditionnel selon la page)

On écrit donc :

// functions/menu.ts
export async function isMenuVisible(page: Page): Promise<boolean> {
  return page.getByTestId('menu-container').isVisible();
}

export async function openMenu(page: Page): Promise<void> {
  await page.getByTestId('menu-button').click();
}

export async function closeMenu(page: Page): Promise<void> {
  await page.getByTestId('menu-button').click();
}

export async function navigateTo(page: Page, item: string): Promise<void> {
  await page.getByTestId('menu-button').click();
  await page.getByTestId(`menu-item-${item}`).click();
}

export async function navigateToSubMenu(
  page: Page,
  item: string,
  subItem: string
): Promise<void> {
  await page.getByTestId('menu-button').click();
  await page.getByTestId(`menu-item-${item}`).hover();
  await page.getByTestId(`submenu-item-${subItem}`).click();
}

Ça marche. Mais ça pose plusieurs problèmes :

Répétition de page : Chaque fonction prend page en argument. À l’usage, c’est lourd :

await openMenu(page);
await navigateTo(page, 'profile');
await isMenuVisible(page);  // ❌ Je dois passer `page` à chaque fois

Locators éparpillés : Les sélecteurs menu-container, menu-item-{item}, etc. sont hardcodés dans chaque fonction. Si un jour on change le data-testid, il faut chercher partout.

Fonctions interdépendantes : navigateTo utilise openMenu indirectement. Si je veux créer une variante (ex: navigateToSpecialSection avec un comportement légèrement différent), je dois soit :

  • Créer une nouvelle fonction qui duplique du code
  • Modifier navigateTo avec des conditions
  • Splitter navigateTo en sous-fonctions privées

Aucune option n’est vraiment bonne.

Pas d’API claire : Si j’exécute import * as menu from "@/functions/menu" et que je tape menu., l’IDE me propose 20 fonctions. Lesquelles sont vraiment utiles ? Lesquelles sont des helpers internes ? C’est flou.

Le problème n°2 : l’ergonomie de l’IDE

Quand on a beaucoup de fonctions, l’autocomplétion devient moins utile. L’IDE vous submerge de propositions, et vous ne savez pas par où commencer.

De plus, il faut connaître la signature exacte de chaque fonction : les paramètres, leur ordre, leur type. C’est de la surcharge cognitive inutile.

Le problème n°3 : le code dupliqué grandit

Avec plusieurs fichiers, il arrive qu’on crée une fonction qui existe déjà, mais légèrement différente. Par exemple :

  • navigateTo(page, item) pour naviguer dans le menu principal
  • navigateToProfilePanel(page, item) pour naviguer dans un sous-menu caché
  • navigateToMobileMenu(page, item) pour naviguer sur mobile (layout différent)

Trois variantes du même concept. Trois fonctions à maintenir. Et si une logique change dans deux d’entre elles ? Vous devez vous souvenir où elles sont, et les modifier aux deux endroits.


La solution : le Factory Function Pattern

Le Factory Function Pattern résout tout ça élégamment. Au lieu de déclarer une classe (qui apporte son lot de complexité), on crée une fonction qui retourne un objet littéral composé de méthodes.

Voici l’exemple avec le menu :

import { Page, Locator } from '@playwright/test';

// Les enums répertorient les locators de notre composant
export enum MenuLocator {
  container = 'menu-container',
  button = 'menu-button',
  homeItem = 'menu-item-home',
  profileItem = 'menu-item-profile',
}

export enum MenuSubItem {
  myProfile = 'submenu-my-profile',
  settings = 'submenu-settings',
}

// La factory function
export const menu = (page: Page) => {
  // Étape 1 : définir les locators (toujours relatifs à la page)
  const locators = {
    container: page.getByTestId(MenuLocator.container),
    button: page.getByTestId(MenuLocator.button),
    homeItem: page.getByTestId(MenuLocator.homeItem),
    profileItem: page.getByTestId(MenuLocator.profileItem),
    subMenu: (parent: Locator, subItemId: string) =>
      parent.getByTestId(subItemId),
  };

  // Étape 2 : définir les méthodes privées (helpers internes)
  const isMenuVisible = async (): Promise<boolean> => {
    return locators.container.isVisible();
  };

  const openMenuIfClosed = async (): Promise<void> => {
    const visible = await isMenuVisible();
    if (!visible) {
      await locators.button.click();
    }
  };

  // Étape 3 : l'API publique retournée, organisée par intention
  const self = {
    locators,

    // Tout ce qui concerne la navigation est regroupé ici
    navigate: {
      to: async (itemId: string) => {
        await openMenuIfClosed();
        await locators[itemId as keyof typeof locators]?.click();
      },

      toSubMenu: async (itemId: string, subItemId: string) => {
        await openMenuIfClosed();
        const itemLocator = locators[itemId as keyof typeof locators] as Locator;
        await itemLocator.hover();
        await locators.subMenu(locators.container, subItemId).click();
      },
    },

    // Tout ce qui concerne l'état du menu (ouvert / fermé) est regroupé là
    state: {
      isVisible: isMenuVisible,

      closeIfOpen: async () => {
        const visible = await isMenuVisible();
        if (visible) {
          await locators.button.click();
        }
      },
    },
  };

  return self;
};

Comment ça marche ?

Les enums (MenuLocator, MenuSubItem) centralisent les identifiants des éléments. Si un jour le data-testid change, une seule place à modifier.

Les locators (dans locators) encapsulent tous les sélecteurs Playwright. Ils sont définis une seule fois, et réutilisés partout dans les méthodes.

Les fonctions privées (isMenuVisible, openMenuIfClosed) sont des helpers internes. Elles ne sont pas exposées dans self, donc l’utilisateur de l’objet ne les voit pas. Elles résolvent le problème de code dupliqué en centralisant la logique complexe.

La variable self est l’API publique. Tout ce qui y est ajouté devient accessible à l’utilisateur. C’est comme définir une “interface” de l’objet : voici ce qu’on expose, voici les règles d’utilisation.

La closure : page est capturé une seule fois à l’instanciation. On n’a jamais besoin de le passer en paramètre. Magique !

Les méthodes imbriquées : remarquez que je n’expose pas une liste plate de méthodes (navigateTo, navigateToSubMenu, isVisible, closeIfOpen…). Je les regroupe par intention dans des sous-objets : navigate pour tout ce qui est navigation, state pour tout ce qui concerne l’état du menu. On y reviendra plus en détail, mais c’est ce qui rend l’API vraiment parlante à l’usage.

Utilisation dans les tests

Comparons avec l’approche fonctionnelle pure :

Avant (programmation fonctionnelle) :

import * as menuFunctions from '@/functions/menu';

test('navigate to profile', async ({ page }) => {
  await menuFunctions.openMenu(page);
  await menuFunctions.navigateTo(page, 'profile');
  expect(await menuFunctions.isMenuVisible(page)).toBe(true);
});

Après (Factory Function Pattern) :

import { menu } from '@/functions/menu';

test('navigate to profile', async ({ page }) => {
  const menuObject = menu(page);

  await menuObject.navigate.to('profileItem');
  expect(await menuObject.state.isVisible()).toBe(true);
});

C’est bien plus lisible et fluide. menuObject.navigate.to(...) se lit presque comme une phrase. L’IDE propose directement les méthodes de menuObject, sans vous submerger. Et pas besoin de passer page à chaque fois.


Comment ce pattern résout les 3 problèmes

Problème n°1 : répétition de page ✅

Avec la factory function, page est capturé une seule fois en closure. Toutes les méthodes y ont accès sans qu’il soit passé en paramètre.

const menuObject = menu(page);
await menuObject.navigate.to('profileItem');           // page implicite
await menuObject.navigate.toSubMenu('profile', '...'); // page implicite

Bien plus ergonomique.

Problème n°2 : ergonomie de l’IDE ✅

Au lieu de voir 20 fonctions, vous voyez une seule variable menuObject. Vous tapez menuObject. et l’IDE propose uniquement les catégories de ce composant : navigate, state, locators. Vous tapez ensuite menuObject.navigate. et l’IDE affiche seulement les actions de navigation.

C’est une API claire et hiérarchisée : vous naviguez dans les possibilités du composant comme dans une arborescence, au lieu de fouiller une liste plate de 20 fonctions.

Problème n°3 : code dupliqué et variantes ✅

Au lieu de créer plusieurs fonctions navigateTo, navigateToSpecialSection, etc., vous encapsulez les variantes à l’intérieur de la factory function.

Exemple : le menu a une section conditionnelle qui n’apparaît que sur mobile. Au lieu de créer navigateToMobileSection() en dehors, vous pouvez ajouter une méthode dans self :

export const menu = (page: Page) => {
  const locators = { /* ... */ };

  // Les fonctions privées décident comment naviguer selon le contexte
  const navigateToRegularSection = async (itemId: string) => {
    await openMenuIfClosed();
    await locators[itemId].click();
  };

  const navigateToMobileSection = async (itemId: string) => {
    // Logique différente : le menu mobile a une structure différente
    await openMenuIfClosed();
    await locators.mobileOverlay.click(); // déclencher overlay
    await locators[itemId].click();
  };

  const self = {
    locators,
    navigate: {
      to: navigateToRegularSection,
      toMobileSection: navigateToMobileSection,
    },
    // ... autres méthodes
  };

  return self;
};

Tout est logiquement groupé dans une seule factory function. Pas de doublons dispersés dans plusieurs fichiers. Et la variante mobile vit juste à côté de la navigation classique, sous le même navigate.


Donner du sens au code : les méthodes imbriquées

C’est selon moi le point qui change tout au quotidien. Une factory function ne retourne pas forcément une liste plate de méthodes : on peut imbriquer des sous-objets pour regrouper les méthodes par intention.

Comparez ces deux APIs pour le même composant.

API plate — tout au même niveau :

const m = menu(page);

await m.navigateTo('profile');
await m.navigateToSubMenu('profile', 'settings');
await m.openMenu();
await m.closeMenu();
await m.isMenuVisible();
await m.isMobileLayout();

API imbriquée — regroupée par intention :

const m = menu(page);

await m.navigate.to('profile');
await m.navigate.toSubMenu('profile', 'settings');
await m.state.open();
await m.state.close();
await m.state.isVisible();
await m.layout.isMobile();

La deuxième version se lit comme une phrase : « menu, navigate, to profile ». La hiérarchie raconte ce que fait chaque appel et où le ranger. On distingue immédiatement l’action (navigate) de l’interrogation d’état (state) ou du contexte d’affichage (layout).

Pourquoi c’est puissant

L’autocomplétion devient un guide. Quand vous tapez m., l’IDE propose navigate, state, layout, locators — quelques catégories au lieu de quinze méthodes. Vous choisissez l’intention, puis l’IDE vous montre uniquement les actions de cette catégorie. C’est de la découverte progressive plutôt qu’un mur de propositions.

Le rangement devient évident. Une nouvelle méthode de navigation ? Elle va dans navigate. Un nouveau check d’état ? Dans state. Plus de débat sur le nommage (navigateToMobile vs mobileNavigate vs goMobile) : le préfixe est porté par le sous-objet, pas par le nom de la méthode.

Le code de test devient une narration. C’est l’objectif final : un test doit raconter un scénario métier, pas une suite d’appels techniques.

test('un utilisateur mobile accède à ses préférences', async ({ page }) => {
  await page.goto('/app');
  const m = menu(page);

  // On lit le scénario, pas l'implémentation
  if (await m.layout.isMobile()) {
    await m.state.open();
  }
  await m.navigate.toSubMenu(MenuLocator.profileItem, MenuSubItem.preferences);

  await expect(page).toHaveURL(/.*\/preferences/);
});

En interne, c’est trivial

Côté factory, on imbrique simplement des objets littéraux dans self. Les helpers privés restent partagés entre toutes les branches grâce à la closure :

export const menu = (page: Page) => {
  const locators = { /* ... */ };

  // Helpers privés, partagés par toutes les branches de l'API
  const isMenuVisible = async () => locators.container.isVisible();
  const ensureOpen = async () => {
    if (!(await isMenuVisible())) await locators.button.click();
  };

  return {
    locators,

    navigate: {
      to: async (itemId: string) => {
        await ensureOpen();
        await locators[itemId as keyof typeof locators]?.click();
      },
      toSubMenu: async (itemId: string, subItemId: string) => {
        await ensureOpen();
        await locators[itemId as keyof typeof locators]?.hover();
        await locators.subMenu(locators.container, subItemId).click();
      },
    },

    state: {
      isVisible: isMenuVisible,
      open: ensureOpen,
      close: async () => {
        if (await isMenuVisible()) await locators.button.click();
      },
    },

    layout: {
      isMobile: async () => locators.mobileOverlay.isVisible(),
    },
  };
};

⚠️ Attention à ne pas sur-imbriquer. Deux niveaux (m.navigate.to) restent lisibles. Au-delà (m.navigate.main.items.to), on retombe dans la complexité qu’on cherchait à fuir. Regroupez par intention, pas pour le plaisir de créer des branches.


Factory Function vs Classe : pourquoi pas une classe ?

Vous vous posez peut-être la question : pourquoi ne pas utiliser une vraie classe TypeScript ?

// Approche classe
class Menu {
  constructor(private page: Page) {}

  async navigateTo(itemId: string) {
    // ...
  }
}

// Utilisation
const menuObject = new Menu(page);
await menuObject.navigateTo('profileItem');

C’est tentant, et ça marche. Mais il y a des différences importantes :

1. Pas de this piégeux

Avec les classes, this peut être tricky :

class Menu {
  constructor(private page: Page) {}

  async navigateTo(itemId: string) {
    // this.page fonctionne ici
  }

  registerHandler() {
    // ❌ `this` peut être undefined si la méthode est utilisée comme callback
    page.on('response', this.navigateTo);
  }
}

Avec la factory function, il n’y a pas de this. Les variables sont capturées par closure, point.

const menu = (page: Page) => {
  const navigateTo = async (itemId: string) => {
    // page est toujours accessible, pas de risque
  };

  return { navigateTo };
};

2. Pas de surcharge OOP

Pas de constructor(), pas de super(), pas d’héritage à gérer. Juste une fonction qui retourne un objet. C’est plus simple.

3. Encapsulation naturelle

Avec une classe, tout ce qui n’est pas marqué private est public. C’est facile d’oublier.

Avec la factory function, tout ce qui n’est pas ajouté à self est automatiquement privé. C’est plus sûr.

export const menu = (page: Page) => {
  const isMenuVisible = async () => { /* ... */ };  // ✅ Privé par défaut

  const self = {
    navigateTo: async () => { /* ... */ },  // ✅ Public
  };

  return self;  // ✅ Tout ce qui n'est pas dans self est caché
};

Quand appliquer ce pattern ?

Ce pattern est idéal pour les composants stateful ou les workflows complexes :

Utilisez la factory function pour :

  • Les éléments UI avec état (menu, modal, dropdown, etc.)
  • Les workflows multi-étapes (création d’utilisateur, checkout, etc.)
  • Les composants réutilisables avec plusieurs méthodes

Pas besoin de factory function pour :

  • Les simples locators (un sélecteur = une fonction)
  • Les fonctions isolées sans dépendance à l’état
  • Les calculs purs ou transformations de données

Par exemple, une simple fonction pour obtenir un locator :

// ✅ Une simple fonction suffit
export const articleTitle = (page: Page, articleId: string) =>
  page.getByTestId(`article-title-${articleId}`);

Pas besoin de la wrapper dans une factory function. Gardez la simplicité quand c’est possible.


L’évolution : de la PF pure au pattern factory

Rétrospectivement, voici comment mon approche a évolué :

Phase 1 : le chaos (articles 1-2)

  • Locators hardcodés dans les tests
  • Duplication partout
  • Tests fragiles

Phase 2 : programmation fonctionnelle pure (article précédent)

  • Fonctions réutilisables
  • Pas d’effets de bord
  • Clarté des signatures

Phase 3 : factory functions (cet article)

  • Encapsulation et API claire
  • Gestion efficace de la complexité
  • Ergonomie améliorée

C’est une progression naturelle, pas un remplacement radical. La PF reste la fondation ; la factory function est la couche d’organisation au-dessus.

À mesure que votre suite de tests grandit, vous sentirez naturellement le besoin de cette structure. Ne la forcez pas d’emblée sur des petits projets. Commencez simple, et évoluez quand la complexité l’exige.


Conclusion

Le Factory Function Pattern n’est pas magique. C’est une façon intelligente d’organiser et d’exposer une API pour vos composants de test.

Avec ce pattern, vous gagnez :

  • Clarté : une API publique bien définie
  • Ergonomie : moins de paramètres répétitifs, meilleure autocomplétion
  • Maintenabilité : la logique privée est isolée, facile à modifier
  • Scalabilité : ajouter de nouvelles fonctionnalités sans dupliquer du code

Sur une base de code qui grandit, c’est une vraie respiration. Vous maintenez votre suite de tests sereinement, même avec des centaines de cas de test.

À vous de jouer ! 🚀

Playwright et Factory Function Pattern

Author

Durondil

Publish Date

10 - 06 - 2026