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 articlesfunctions/menu.ts— interactions avec le menufunctions/modal.ts— interactions avec les modalesfunctions/forms.ts— interactions avec les formulairesfunctions/auth.ts— authentificationfunctions/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
navigateToavec des conditions - Splitter
navigateToen 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 principalnavigateToProfilePanel(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 ! 🚀