Les factories de données de test combinées avec Faker.js génèrent des données uniques et réalistes à chaque exécution. Elles éliminent les erreurs d'email en doublon et les valeurs codées en dur périmées qui causent la plupart de l'instabilité liée aux données.
Le problème des données codées en dur
Les données codées en dur cassent de trois façons, et chacune amplifie les autres.
La première, ce sont les conflits d'unicité. La plupart des vraies applications imposent des emails uniques, des noms d'utilisateur uniques, des numéros de commande uniques. Si votre test utilise alice@example.com et que le test a tourné hier en laissant cette ligne dans la base de données, l'exécution d'aujourd'hui échoue à la création. Non pas parce que la fonctionnalité est cassée, mais parce que le nettoyage n'a jamais eu lieu.
La deuxième, c'est l'état partagé. Cinq tests qui opèrent tous sur item-id-42 sont cinq tests qui attendent de s'interférer mutuellement. En exécution parallèle, les collisions se produisent en permanence. En exécution séquentielle, elles se produisent juste assez souvent pour sembler aléatoires.
La troisième, c'est la fragilité des données elles-mêmes. Une date codée en dur 2024-01-15 qui était "dans le futur" quand vous avez écrit le test est maintenant deux ans dans le passé. Un statut "pending" qui avait du sens pour un workflow a été renommé "awaiting_approval". Chaque valeur codée en dur est un fardeau de maintenance futur.
// Ce qu'il faut arrêter de faire
test('l\'utilisateur peut mettre à jour son profil', async ({ page }) => {
// Ces valeurs finiront par entrer en conflit ou devenir obsolètes
await loginAs(page, 'alice@example.com', 'password123');
await page.goto('/profile');
await page.getByLabel('Nom d\'affichage').fill('Alice Martin');
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Profil mis à jour')).toBeVisible();
});La solution n'est pas d'être plus prudent avec les valeurs codées en dur. C'est d'arrêter de les coder en dur.
Faker.js : installation et bases
Faker.js est une bibliothèque pour générer des données fictives réalistes. Noms, emails, adresses, UUIDs, dates, numéros de téléphone, noms de produits : tout est aléatoire, tout est plausible.
npm install --save-dev @faker-js/fakerL'API regroupe les générateurs par catégorie. Voici ceux que vous utiliserez le plus dans les suites de tests :
import { faker } from '@faker-js/faker';
// Identité
faker.person.firstName() // 'Marcus'
faker.person.lastName() // 'Holloway'
faker.internet.email() // 'marcus.holloway@gmail.com'
faker.internet.username() // 'marcus_holloway42'
faker.internet.password({ length: 12 }) // 'Kd9$mXp2vLqR'
// IDs et références
faker.string.uuid() // 'e2d4f6a8-...'
faker.number.int({ min: 1, max: 9999 }) // 7342
// Dates
faker.date.future() // Objet Date dans le futur
faker.date.past({ years: 2 }) // Objet Date dans les 2 dernières années
faker.date.between({ from: '2025-01-01', to: '2025-12-31' }) // Date dans la plage
// Contenu
faker.lorem.sentence() // 'Voluptas et dolorem rerum.'
faker.commerce.productName() // 'Sleek Rubber Shoes'
faker.commerce.price() // '42.99'faker.seed(12345) en haut d'un fichier de test pour rendre la génération déterministe. Le même seed produit la même séquence de valeurs à chaque fois. Utile pour déboguer un test instable qui dépend de valeurs générées spécifiques : lancez-le avec un seed, capturez les données, reproduisez l'échec de façon fiable.Une habitude importante : générez la valeur une seule fois et stockez-la dans une variable. N'appelez pas faker.internet.email() à deux endroits en vous attendant au même résultat.
// Faux — deux emails différents
await page.getByLabel('Email').fill(faker.internet.email());
await expect(page.getByText(faker.internet.email())).toBeVisible(); // valeur différente !
// Correct — générer une fois, utiliser partout
const email = faker.internet.email();
await page.getByLabel('Email').fill(email);
await expect(page.getByText(email)).toBeVisible();Fonctions factory : buildUser() et buildOrder()
Une fonction factory est une simple fonction TypeScript qui retourne un objet de données complet. Elle utilise Faker pour les valeurs par défaut mais accepte des surcharges pour que les tests puissent spécifier ce qui compte réellement pour eux.
// factories/user.factory.ts
import { faker } from '@faker-js/faker';
export interface User {
firstName: string;
lastName: string;
email: string;
password: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: Date;
}
export function buildUser(overrides: Partial<User> = {}): User {
return {
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
email: faker.internet.email(),
password: faker.internet.password({ length: 12 }),
role: 'viewer',
createdAt: faker.date.past({ years: 1 }),
...overrides,
};
}// factories/order.factory.ts
import { faker } from '@faker-js/faker';
export interface Order {
id: string;
customerId: string;
items: { productId: string; quantity: number; price: number }[];
status: 'pending' | 'processing' | 'shipped' | 'delivered';
total: number;
placedAt: Date;
}
export function buildOrder(overrides: Partial<Order> = {}): Order {
const items = Array.from({ length: faker.number.int({ min: 1, max: 4 }) }, () => ({
productId: faker.string.uuid(),
quantity: faker.number.int({ min: 1, max: 5 }),
price: parseFloat(faker.commerce.price({ min: 5, max: 200 })),
}));
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return {
id: faker.string.uuid(),
customerId: faker.string.uuid(),
items,
status: 'pending',
total: parseFloat(total.toFixed(2)),
placedAt: new Date(),
...overrides,
};
}Dans les tests, vous ne spécifiez que ce qui compte pour le test. Tout le reste se remplit automatiquement :
// Un test qui ne s'intéresse qu'au rôle de l'utilisateur
const adminUser = buildUser({ role: 'admin' });
// Un test qui ne s'intéresse qu'au statut de commande
const shippedOrder = buildOrder({ status: 'shipped' });
// Un test qui nécessite un email spécifique (pour la connexion)
const user = buildUser({ email: 'known-test-user@example.com' });Ça rend les tests expressifs. Quand vous voyez buildUser({ role: 'admin' }), vous savez immédiatement que le rôle est ce qui compte pour ce test. Les 10 autres champs sont du bruit non pertinent que Faker a géré pour vous.
Schéma Builder pour les objets de test complexes
Pour les objets avec de nombreux champs interdépendants (où définir une propriété devrait probablement en changer une autre), une classe builder est plus lisible. Son API fluente remplace le grand objet de surcharges d'une fonction factory.
// builders/UserBuilder.ts
import { faker } from '@faker-js/faker';
import { User } from '../factories/user.factory';
export class UserBuilder {
private user: User;
constructor() {
this.user = {
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
email: faker.internet.email(),
password: faker.internet.password({ length: 12 }),
role: 'viewer',
createdAt: new Date(),
};
}
withRole(role: User['role']): UserBuilder {
this.user.role = role;
return this;
}
withEmail(email: string): UserBuilder {
this.user.email = email;
return this;
}
withName(firstName: string, lastName: string): UserBuilder {
this.user.firstName = firstName;
this.user.lastName = lastName;
return this;
}
asAdmin(): UserBuilder {
this.user.role = 'admin';
this.user.email = `admin-${faker.string.uuid().slice(0, 8)}@company.com`;
return this;
}
createdDaysAgo(days: number): UserBuilder {
const date = new Date();
date.setDate(date.getDate() - days);
this.user.createdAt = date;
return this;
}
build(): User {
return { ...this.user };
}
}Le site d'appel se lit presque comme de l'anglais courant :
const admin = new UserBuilder().asAdmin().build();
const recentUser = new UserBuilder()
.withRole('editor')
.createdDaysAgo(3)
.build();
const namedUser = new UserBuilder()
.withName('Jordan', 'Reeves')
.withRole('viewer')
.build();Le builder brille quand asAdmin() doit définir plusieurs champs ensemble (rôle, domaine d'email, et peut-être un flag isVerified). Il évite de disperser cette logique dans chaque test qui crée un utilisateur admin.
Utilisez les fonctions factory pour les objets simples et les classes builder pour tout ce où des combinaisons prédéfinies de champs sont courantes. Ils se composent bien aussi : une fonction factory peut utiliser un builder en interne si l'objet est complexe.
Alimentation des données via API
Créer des données de test via l'UI est lent et fragile. Un flux d'inscription qui prend 8 secondes dans le navigateur en prend 80 millisecondes via l'API. Plus important encore, un setup basé sur l'UI couple votre test à deux fonctionnalités à la fois. Si le formulaire d'inscription est cassé, chaque test qui l'utilise comme setup casse aussi, même si ces tests portent sur autre chose.
La fixture request de Playwright vous donne un contexte API que vous pouvez utiliser directement dans le code de setup.
// helpers/api.helpers.ts
import { APIRequestContext } from '@playwright/test';
import { buildUser, User } from '../factories/user.factory';
const BASE_URL = process.env.API_BASE_URL ?? 'https://lab.becomeqa.com/api';
export async function createUserViaApi(
request: APIRequestContext,
overrides: Partial<User> = {}
): Promise<User & { id: string }> {
const userData = buildUser(overrides);
const response = await request.post(`${BASE_URL}/users`, {
data: userData,
headers: {
Authorization: `Bearer ${process.env.API_SEED_TOKEN}`,
'Content-Type': 'application/json',
},
});
if (!response.ok()) {
throw new Error(`Échec de la création d'utilisateur : ${response.status()} ${await response.text()}`);
}
const created = await response.json();
return { ...userData, id: created.id };
}
export async function deleteUserViaApi(
request: APIRequestContext,
userId: string
): Promise<void> {
await request.delete(`${BASE_URL}/users/${userId}`, {
headers: { Authorization: `Bearer ${process.env.API_SEED_TOKEN}` },
});
}Les tests qui ont besoin d'un utilisateur appellent le helper et obtiennent un vrai enregistrement de base de données instantanément :
test('l\'admin peut désactiver un compte utilisateur', async ({ page, request }) => {
const user = await createUserViaApi(request, { role: 'editor' });
await page.goto('/admin/users');
await page.getByTestId(`user-row-${user.id}`).getByRole('button', { name: 'Désactiver' }).click();
await page.getByRole('button', { name: 'Confirmer' }).click();
await expect(page.getByTestId(`user-row-${user.id}`).getByText('Inactif')).toBeVisible();
await deleteUserViaApi(request, user.id); // nettoyage explicite
});Ce test vérifie l'UI de désactivation admin sans dépendre de l'UI d'inscription, de l'UI de connexion, ou de tout autre chemin qui pourrait échouer pour des raisons sans rapport.
Fixture de données avec nettoyage automatique
Le test ci-dessus gère le nettoyage manuellement. Ça fonctionne, mais si le test lève une exception avant d'atteindre deleteUserViaApi, le nettoyage ne s'exécute jamais et l'utilisateur reste dans la base de données. Une fixture Playwright résout ça en rendant le nettoyage inconditionnel.
// fixtures/data.fixture.ts
import { test as base, APIRequestContext } from '@playwright/test';
import { buildUser, User } from '../factories/user.factory';
import { createUserViaApi, deleteUserViaApi } from '../helpers/api.helpers';
type DataFixtures = {
testUser: User & { id: string };
testAdminUser: User & { id: string };
};
export const test = base.extend<DataFixtures>({
testUser: async ({ request }, use) => {
const user = await createUserViaApi(request);
await use(user);
// S'exécute après le test — qu'il réussisse ou échoue
try {
await deleteUserViaApi(request, user.id);
} catch (error) {
console.warn(`Nettoyage échoué pour l'utilisateur ${user.id} :`, error);
}
},
testAdminUser: async ({ request }, use) => {
const user = await createUserViaApi(request, { role: 'admin' });
await use(user);
try {
await deleteUserViaApi(request, user.id);
} catch (error) {
console.warn(`Nettoyage échoué pour l'utilisateur admin ${user.id} :`, error);
}
},
});
export { expect } from '@playwright/test';Les tests reçoivent maintenant un utilisateur entièrement créé et ne gèrent jamais le nettoyage eux-mêmes :
// tests/profile.spec.ts
import { test, expect } from '../fixtures/data.fixture';
test('l\'utilisateur peut mettre à jour son nom d\'affichage', async ({ page, testUser }) => {
await loginAs(page, testUser.email, testUser.password);
await page.goto('/profile');
await page.getByLabel('Nom d\'affichage').fill('Nouveau nom d\'affichage');
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Profil mis à jour')).toBeVisible();
// testUser est supprimé après cette ligne — automatiquement
});
test('l\'admin peut voir les détails d\'un utilisateur', async ({ page, testAdminUser, testUser }) => {
await loginAs(page, testAdminUser.email, testAdminUser.password);
await page.goto(`/admin/users/${testUser.id}`);
await expect(page.getByText(testUser.email)).toBeVisible();
// Les deux utilisateurs supprimés après ce test
});Le try/catch dans le bloc de teardown est intentionnel. Si le nettoyage lève une exception et que vous ne l'interceptez pas, Playwright peut afficher un échec secondaire déconcertant qui masque ce qui s'est réellement passé dans le test. Loggez l'avertissement, ne relancez pas.
Données avec portée worker pour les données de référence en lecture seule
Certaines données ne changent pas entre les tests : un catalogue de catégories de produits, une liste de pays, un ensemble de définitions de permissions. Créer et supprimer ces données pour chaque test est du gaspillage quand elles ne sont jamais modifiées.
Les fixtures avec portée worker créent les données une fois par processus worker et les partagent entre tous les tests de ce worker. Le type de fixture passe au deuxième paramètre générique de extend.
// fixtures/worker-data.fixture.ts
import { test as base } from '@playwright/test';
interface Category {
id: string;
name: string;
slug: string;
}
type WorkerFixtures = {
productCategories: Category[];
};
export const test = base.extend<{}, WorkerFixtures>({
productCategories: [
async ({ request }, use) => {
// Alimenter les catégories une fois par worker
const created: Category[] = [];
for (const name of ['Électronique', 'Livres', 'Vêtements']) {
const response = await request.post('/api/categories', {
data: { name, slug: name.toLowerCase() },
headers: { Authorization: `Bearer ${process.env.API_SEED_TOKEN}` },
});
const category = await response.json();
created.push(category);
}
await use(created);
// Nettoyage après la fin de tous les tests du worker
for (const category of created) {
try {
await request.delete(`/api/categories/${category.id}`, {
headers: { Authorization: `Bearer ${process.env.API_SEED_TOKEN}` },
});
} catch (error) {
console.warn(`Nettoyage de catégorie échoué pour ${category.id} :`, error);
}
}
},
{ scope: 'worker' },
],
});
export { expect } from '@playwright/test';Les tests du même worker reçoivent le même tableau productCategories. Comme ils ne font que le lire, il n'y a pas d'interférence entre tests. Si des tests tournent en parallèle sur plusieurs workers, chaque worker crée son propre ensemble, ce qui est acceptable pour des données de référence.
N'utilisez jamais la portée worker pour des données que les tests modifient. Si un test change un état partagé, les tests suivants dans le même worker verront la version modifiée. Les échecs qui en résultent sont subtils, dépendants de l'ordre, et difficiles à déboguer.
Gérer l'unicité : emails basés sur UUID et IDs à l'épreuve des collisions
Même avec Faker, des collisions d'unicité peuvent se produire. internet.email() de Faker puise dans un pool de noms et de domaines communs, donc marcus.holloway@gmail.com pourrait apparaître deux fois dans une longue exécution de tests. Pour tout champ que la base de données impose comme unique, vous avez besoin d'une stratégie qui garantit l'absence de répétitions.
L'approche la plus fiable est d'intégrer un UUID dans la valeur :
// helpers/unique.ts
import { faker } from '@faker-js/faker';
export function uniqueEmail(prefix = 'test'): string {
const id = faker.string.uuid().slice(0, 8);
return `${prefix}+${id}@test-suite.local`;
}
export function uniqueUsername(): string {
const id = faker.string.uuid().slice(0, 8);
return `user_${id}`;
}
export function uniqueSlug(base: string): string {
const id = faker.string.uuid().slice(0, 6);
return `${base}-${id}`.toLowerCase().replace(/\s+/g, '-');
}Ça produit des valeurs comme test+a3f9c2b1@test-suite.local et user_a3f9c2b1. Huit caractères d'UUID, c'est 16^8 = 4,3 milliards de valeurs possibles, donc la probabilité de collision dans une suite de tests est pratiquement nulle. Utiliser un domaine test-suite.local rend aussi triviale l'identification et la suppression en masse des données de test si le nettoyage prend du retard.
Mettez à jour votre factory pour utiliser ces helpers :
// factories/user.factory.ts (mis à jour)
import { faker } from '@faker-js/faker';
import { uniqueEmail, uniqueUsername } from '../helpers/unique';
export function buildUser(overrides: Partial<User> = {}): User {
return {
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
email: uniqueEmail(), // garanti unique
username: uniqueUsername(), // garanti unique
password: faker.internet.password({ length: 12 }),
role: 'viewer',
createdAt: faker.date.past({ years: 1 }),
...overrides,
};
}test-suite.local par un vrai domaine que vous contrôlez, ou configurez un domaine de test spécial dans la liste autorisée de votre environnement de staging. Certaines applications rejettent aussi le + dans les adresses email, auquel cas utilisez un format avec UUID en préfixe de sous-domaine : a3f9c2b1.test@votredomaine.com.Pour les IDs numériques générés par une séquence de base de données, la séquence gère l'unicité pour vous, donc vous n'avez pas besoin de pré-générer des IDs du tout. Laissez l'API retourner l'ID créé et utilisez-le dans votre test. Générez des IDs côté client uniquement quand vous testez des systèmes qui acceptent des IDs fournis par le client, comme des UUIDs stockés directement dans la base de données.
FAQ
Dois-je utiliser une fonction factory ou une classe builder ?Les fonctions factory sont plus simples et fonctionnent dans la plupart des cas. Utilisez des builders quand vous avez plusieurs presets significatifs (comme asAdmin(), asUnverified(), asSuspended()) qui combinent plusieurs valeurs de champs. Si vous vous retrouvez à passer le même objet de surcharges à répétition, c'est un signe qu'une méthode builder nommée serait plus propre.
Stockez le token d'alimentation dans une variable d'environnement et chargez-la avec process.env. Pour la CI, injectez la variable via les secrets de votre pipeline (GitHub Actions : secrets.API_SEED_TOKEN). Ne codez jamais des credentials en dur dans les fichiers sources.
Oui. Demandez autant de fixtures que le test en a besoin : async ({ testUser, testAdminUser, productCategories }). Playwright les résout et les crée toutes avant que le test s'exécute, puis les démonte toutes ensuite, dans l'ordre inverse de création.
Le teardown de la fixture s'exécute quel que soit le résultat du test. Le schéma try/catch dans le bloc de teardown assure que si la suppression échoue, l'erreur est loggée mais ne produit pas un faux échec. C'est utile quand le test a déjà supprimé la ressource dans le cadre du flux testé. Si une ressource a été intentionnellement supprimée par le test, vérifiez avant de supprimer : if (response.status() !== 404) await deleteViaApi(id).
Oui. Installez avec --save-dev et importez-le uniquement dans les fichiers de test et les fichiers factory/helper. Il n'est jamais livré en production. Si vous utilisez une factory dans du code de production par erreur, les frontières de module TypeScript et le tree-shaking le détecteront, ou vous pouvez l'imposer avec une règle ESLint.