Les tests d'API Playwright avancés couvrent les patterns qui rendent les suites de tests fiables dans de vrais projets. Ils incluent la différence entre la fixture request et page.request, la configuration de l'authentification via des fixtures, et la validation de schémas de réponse avec Ajv. Ils couvrent aussi l'isolation des données de test via des appels API, et des utilitaires de requêtes réutilisables.
La fixture request vs page.request
Deux façons de faire des appels API dans Playwright :
// 1. La fixture request — pas de navigateur, API pure
test('test API', async ({ request }) => {
const response = await request.get('/api/users');
});
// 2. page.request — partage les cookies avec la page du navigateur
test('API après connexion navigateur', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'ValidPass1');
await page.click('[data-testid="submit"]');
// Cette requête utilise la session du navigateur (cookies définis par la connexion)
const response = await page.request.get('/api/user/profile');
});Utilisez la fixture request pour les tests API purs. Utilisez page.request quand vous avez besoin de la session authentifiée du navigateur.
Patterns d'authentification
Auth par token
test.describe('Tests API authentifiés', () => {
let authToken: string;
test.beforeAll(async ({ request }) => {
const response = await request.post('/api/auth/login', {
data: {
email: 'admin@test.com',
password: 'AdminPass1',
},
});
expect(response.ok()).toBeTruthy();
const body = await response.json();
authToken = body.token;
});
test('obtenir la liste des utilisateurs', async ({ request }) => {
const response = await request.get('/api/users', {
headers: { Authorization: `Bearer ${authToken}` },
});
expect(response.status()).toBe(200);
});
test('créer un utilisateur', async ({ request }) => {
const response = await request.post('/api/users', {
data: { email: 'new@test.com', password: 'Pass1', role: 'member' },
headers: { Authorization: `Bearer ${authToken}` },
});
expect(response.status()).toBe(201);
});
});Utiliser des fixtures pour l'authentification
Plus propre que beforeAll : les fixtures gèrent aussi le teardown.
// fixtures/index.ts
export const test = base.extend<{ authToken: string }>({
authToken: async ({ request }, use) => {
const response = await request.post('/api/auth/login', {
data: { email: 'admin@test.com', password: 'AdminPass1' },
});
const { token } = await response.json();
await use(token);
// Pas de teardown nécessaire pour les tokens : ils expirent ou sont révoqués naturellement
},
});Validation de schéma
Vérifiez la structure de la réponse, pas seulement les codes de statut :
import Ajv from 'ajv';
const ajv = new Ajv();
const userSchema = {
type: 'object',
required: ['id', 'email', 'role', 'createdAt'],
properties: {
id: { type: 'number' },
email: { type: 'string', format: 'email' },
role: { type: 'string', enum: ['admin', 'member', 'viewer'] },
createdAt: { type: 'string' },
name: { type: 'string' },
},
additionalProperties: false,
};
test('la réponse utilisateur correspond au schéma', async ({ request, authToken }) => {
const response = await request.get('/api/users/1', {
headers: { Authorization: `Bearer ${authToken}` },
});
const body = await response.json();
const validate = ajv.compile(userSchema);
const valid = validate(body);
if (!valid) {
throw new Error(`Échec de validation du schéma : ${JSON.stringify(validate.errors)}`);
}
expect(valid).toBe(true);
});Sans bibliothèque, utilisez des vérifications manuelles :
function validateUserSchema(body: unknown) {
const user = body as Record<string, unknown>;
expect(typeof user.id).toBe('number');
expect(typeof user.email).toBe('string');
expect(user.email).toMatch(/@/);
expect(['admin', 'member', 'viewer']).toContain(user.role);
expect(typeof user.createdAt).toBe('string');
expect(() => new Date(user.createdAt as string)).not.toThrow();
}
test('validation du schéma utilisateur', async ({ request, authToken }) => {
const response = await request.get('/api/users/1', {
headers: { Authorization: `Bearer ${authToken}` },
});
const body = await response.json();
validateUserSchema(body);
});Cycle de vie des tests CRUD
Testez le cycle complet créer → lire → mettre à jour → supprimer :
test.describe('CRUD utilisateur', () => {
const authHeaders = () => ({
Authorization: `Bearer ${authToken}`,
});
let createdUserId: number;
const userData = {
email: `crud_test_${Date.now()}@test.com`,
password: 'ValidPass1',
name: 'Utilisateur Test CRUD',
role: 'member',
};
test('CREATE — POST /api/users', async ({ request }) => {
const response = await request.post('/api/users', {
data: userData,
headers: authHeaders(),
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body.email).toBe(userData.email);
expect(body.id).toBeDefined();
expect(body.password).toBeUndefined(); // Ne jamais renvoyer le mot de passe
createdUserId = body.id;
});
test('READ — GET /api/users/:id', async ({ request }) => {
const response = await request.get(`/api/users/${createdUserId}`, {
headers: authHeaders(),
});
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.id).toBe(createdUserId);
expect(body.email).toBe(userData.email);
});
test('UPDATE — PUT /api/users/:id', async ({ request }) => {
const response = await request.put(`/api/users/${createdUserId}`, {
data: { name: 'Nom mis à jour' },
headers: authHeaders(),
});
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.name).toBe('Nom mis à jour');
expect(body.email).toBe(userData.email); // Email inchangé
});
test('DELETE — DELETE /api/users/:id', async ({ request }) => {
const response = await request.delete(`/api/users/${createdUserId}`, {
headers: authHeaders(),
});
expect(response.status()).toBe(204); // Pas de contenu
});
test('VÉRIFICATION SUPPRESSION — GET renvoie 404', async ({ request }) => {
const response = await request.get(`/api/users/${createdUserId}`, {
headers: authHeaders(),
});
expect(response.status()).toBe(404);
});
});Tester les cas d'erreur
Ne testez pas uniquement le chemin nominal :
test.describe('Gestion des erreurs', () => {
test('401 — token d\'authentification manquant', async ({ request }) => {
const response = await request.get('/api/users');
expect(response.status()).toBe(401);
const body = await response.json();
expect(body.error).toBeDefined();
});
test('403 — permissions insuffisantes', async ({ request, memberToken }) => {
// Un membre tente d'accéder à un endpoint admin
const response = await request.get('/api/admin/logs', {
headers: { Authorization: `Bearer ${memberToken}` },
});
expect(response.status()).toBe(403);
});
test('404 — ressource inexistante', async ({ request, authToken }) => {
const response = await request.get('/api/users/999999', {
headers: { Authorization: `Bearer ${authToken}` },
});
expect(response.status()).toBe(404);
const body = await response.json();
expect(body.message).toContain('not found');
});
test('400 — corps de requête invalide', async ({ request, authToken }) => {
const response = await request.post('/api/users', {
data: { email: 'pas-un-email', password: '123' }, // Invalide
headers: { Authorization: `Bearer ${authToken}` },
});
expect(response.status()).toBe(400);
const body = await response.json();
expect(body.errors).toBeDefined();
expect(body.errors).toBeInstanceOf(Array);
});
test('409 — ressource en double', async ({ request, authToken }) => {
const userData = { email: 'duplicate@test.com', password: 'ValidPass1' };
// Créer une première fois
await request.post('/api/users', {
data: userData,
headers: { Authorization: `Bearer ${authToken}` },
});
// Créer en double
const response = await request.post('/api/users', {
data: userData,
headers: { Authorization: `Bearer ${authToken}` },
});
expect(response.status()).toBe(409);
});
});Tester la pagination
test.describe('Pagination', () => {
test('renvoie la bonne taille de page', async ({ request, authToken }) => {
const response = await request.get('/api/users?page=1&limit=10', {
headers: { Authorization: `Bearer ${authToken}` },
});
const body = await response.json();
expect(body.data).toHaveLength(10);
expect(body.page).toBe(1);
expect(body.limit).toBe(10);
expect(typeof body.total).toBe('number');
expect(typeof body.totalPages).toBe('number');
});
test('la dernière page a moins d\'éléments', async ({ request, authToken }) => {
// Obtenir le total d'abord
const firstResp = await request.get('/api/users?page=1&limit=10', {
headers: { Authorization: `Bearer ${authToken}` },
});
const { total, totalPages } = await firstResp.json();
// Obtenir la dernière page
const lastResp = await request.get(`/api/users?page=${totalPages}&limit=10`, {
headers: { Authorization: `Bearer ${authToken}` },
});
const lastPage = await lastResp.json();
const expectedLastPageCount = total % 10 || 10;
expect(lastPage.data).toHaveLength(expectedLastPageCount);
});
test('une page au-delà du total renvoie un résultat vide', async ({ request, authToken }) => {
const response = await request.get('/api/users?page=9999&limit=10', {
headers: { Authorization: `Bearer ${authToken}` },
});
const body = await response.json();
expect(body.data).toHaveLength(0);
});
});Combiner la préparation API avec les tests UI
Le pattern le plus puissant : utiliser l'API pour préparer l'état, puis vérifier via l'UI.
test('le nouvel utilisateur créé apparaît dans le panneau admin', async ({ page, request, authToken }) => {
// 1. Créer l'utilisateur via API (rapide, fiable)
const userData = {
email: `new_${Date.now()}@test.com`,
password: 'ValidPass1',
name: 'Nouvel utilisateur API',
role: 'member',
};
const createResp = await request.post('/api/users', {
data: userData,
headers: { Authorization: `Bearer ${authToken}` },
});
const { id } = await createResp.json();
// 2. Vérifier via UI (ce que les utilisateurs voient réellement)
await page.goto('/login');
await page.fill('[data-testid="email"]', 'admin@test.com');
await page.fill('[data-testid="password"]', 'AdminPass1');
await page.click('[data-testid="submit"]');
await page.goto('/admin/users');
await page.fill('[data-testid="search"]', userData.email);
await expect(page.getByTestId('user-row').first()).toContainText(userData.name);
// 3. Nettoyage via API (rapide, fiable)
await request.delete(`/api/users/${id}`, {
headers: { Authorization: `Bearer ${authToken}` },
});
});Assertions sur le temps de réponse
test('l\'endpoint liste répond dans le délai SLA', async ({ request, authToken }) => {
const startTime = Date.now();
const response = await request.get('/api/users?limit=100', {
headers: { Authorization: `Bearer ${authToken}` },
});
const duration = Date.now() - startTime;
expect(response.status()).toBe(200);
expect(duration).toBeLessThan(500); // SLA 500ms
});Récapitulatif
Les patterns qui tiennent à l'échelle :
1. Utilisez des fixtures pour les tokens d'authentification : injectez-les, ne répétez pas le code de connexion
2. Validez les schémas : un statut 200 avec de mauvais champs reste un bug
3. Testez le CRUD dans l'ordre : créer, lire, mettre à jour, supprimer comme un cycle de vie
4. Testez les cas d'erreur : 401, 403, 404, 400, 409 sont aussi importants que 200
5. Utilisez l'API pour le setup/teardown dans les tests UI : plus rapide et plus fiable que la préparation via UI
6. Isolez les données : email unique par test, suppression après
La combinaison de tests API (rapides, complets) et de tests UI (parcours utilisateurs réalistes) vous donne la meilleure couverture avec le plus de confiance.
→ See also: Tests d'API avec l'APIRequestContext de Playwright (Sans Postman) | Authentification dans les Tests d'API: Clés API, Tokens Bearer, OAuth2, JWT | Tests d'API GraphQL avec Playwright: Requêtes, Mutations et Gestion des Erreurs