Test d’API avec Playwright : une suite qui tourne, et ce qu’ont apporté les versions 1.62 à 1.64
La plupart des gens connaissent Playwright comme outil de test de navigateur. Il embarque pourtant un client HTTP complet, la fixture request, avec laquelle on écrit des tests d’API sans lancer le moindre navigateur. Les tests tournent dans le même runner, avec les mêmes rapports, nouvelles tentatives et workers parallèles que vos tests d’interface.
Dans cet article, on monte avec elle une petite suite de tests d’API contre notre API de test gratuite. Chaque exemple a été lancé avec Playwright 1.64.0, sorti le 7 octobre 2026, et les 12 tests passent. La dernière partie présente ce que les trois dernières versions ont apporté au test d’API : réponses typées, verrous pour les tests qui partagent des données, cookies dans le contexte de requêtes, temps de réponse et annulation d’une requête.
Mise en place
Il vous faut Node.js 20 ou plus récent. Les tests d’API n’ont pas besoin de navigateurs, donc pas d’étape npx playwright install :
$ mkdir playwright-api && cd playwright-api
$ npm init -y
$ npm install --save-dev @playwright/test@1.64.0La configuration définit une URL de base, pour que les tests utilisent des chemins comme /api/Products, et un en-tête Accept par défaut pour chaque requête :
import { defineConfig } from "@playwright/test";
// API tests only: no browsers are launched, so `npx playwright install` isn't needed.
export default defineConfig({
testDir: "./tests",
reporter: "list",
retries: 1,
// Playwright 1.62: run retries at the end, one at a time, instead of straight away in parallel.
retryStrategy: "isolated",
use: {
// Point the tests at another server with BASE_URL, e.g. a copy of the API on your machine.
baseURL: process.env.BASE_URL ?? "https://api.testingapis.com",
extraHTTPHeaders: { Accept: "application/json" },
},
});BASE_URL permet de pointer les mêmes tests vers un autre serveur, comme une copie locale de l’API ou un environnement de staging, sans les modifier.
Votre premier test d’API
Demandez la fixture request dans un test et vous obtenez un APIRequestContext avec get, post, put, patch, delete et fetch. Chacune renvoie une APIResponse avec le statut, les en-têtes et le corps. Ce fichier vérifie un point d’accès de liste, un corps d’erreur, une erreur de validation et quelques codes de statut :
import { expect, test } from "@playwright/test";
import type { ApiError, Product } from "./types";
test("lists products filtered by category, with paging headers", async ({ request }) => {
const response = await request.get<Product[]>("/api/Products", {
params: { category: "electronics", limit: 2 },
});
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("application/json");
expect(Number(response.headers()["x-total-count"])).toBeGreaterThan(2);
const products = await response.json();
expect(products).toHaveLength(2);
for (const product of products) {
expect(product.category).toBe("electronics");
expect(typeof product.price).toBe("number");
}
});
test("returns 404 with an error body for a product that doesn't exist", async ({ request }) => {
const response = await request.get<ApiError>("/api/Products/99999");
expect(response.status()).toBe(404);
expect(await response.json()).toEqual({
status: 404,
error: "Not Found",
message: "Product with ID 99999 does not exist.",
});
});
test("rejects a product without a title", async ({ request }) => {
const response = await request.post<ApiError>("/api/Products", { data: { price: 5 } });
expect(response.status()).toBe(400);
expect((await response.json()).message).toBe("Missing required field: title");
});
for (const code of [400, 401, 429, 500, 503]) {
test(`handles a ${code} response`, async ({ request }) => {
const response = await request.get(`/api/status/${code}`);
expect(response.status()).toBe(code);
expect(response.ok()).toBe(false);
});
}paramsconstruit la query string, inutile donc d’encoder?category=electronics&limit=2à la main.response.headers()renvoie les noms d’en-têtes en minuscules. Notre API renvoie le nombre total de produits correspondants dansX-Total-Count, le test vérifie donc la pagination en plus du corps.datasur unpostest envoyé en JSON, avecContent-Type: application/jsonposé pour vous.- La boucle
forcrée un test par code de statut./api/status/{code}renvoie le statut que vous demandez, pratique pour voir comment votre propre code client réagit à un 429 ou à un 503.
Un 404 ou un 500 ne fait pas lever d’exception à request.get. Vous récupérez la réponse et faites vos assertions dessus, ce qui est voulu quand l’erreur est justement ce que vous testez. Quand un statut d’erreur doit simplement faire échouer le test, passez failOnStatusCode :
// Throw on any 4xx or 5xx instead of returning the response.
const response = await request.get("/api/Products/1", { failOnStatusCode: true });Typer les réponses
Depuis Playwright 1.63, les méthodes de requête acceptent un argument de type, et response.json() renvoie ce type. C’est ce que faisait request.get<Product[]> plus haut. Les types tiennent dans un fichier :
// The shapes the tests read. Only the fields they check are listed.
export interface Product {
id: number;
title: string;
price: number;
category: string;
inStock: boolean;
}
/** What POST and PUT on /api/Products return. */
export interface ProductEnvelope {
message: string;
data: Product;
}
/** The API's error body, for 4xx and 5xx responses. */
export interface ApiError {
status: number;
error: string;
message: string;
}C’est une indication pour TypeScript, pas une vérification. Si l’API envoie une chaîne là où vous avez déclaré un nombre, le test compile et tourne quand même. Gardez les assertions sur les valeurs qui comptent, comme le font les tests ci-dessus avec typeof product.price. Pour valider des corps de réponse entiers contre un schéma, voyez notre leçon sur la validation des réponses (en anglais).
Un test de création, lecture, modification et suppression qui fait le ménage
Les produits de notre API sont partagés : un produit que vous créez est visible par tous jusqu’à sa suppression. Les tests contre un environnement partagé doivent nettoyer derrière eux, y compris quand ils échouent en cours de route :
import { expect, test } from "@playwright/test";
import type { Product, ProductEnvelope } from "./types";
// Products are shared by everyone who uses the API. Playwright 1.63's lock keeps any other test that writes
// products from running at the same time as this file, in any worker.
test.describe.configure({ lock: "products" });
test("creates, reads, updates and deletes a product", async ({ request }) => {
const created = await request.post<ProductEnvelope>("/api/Products", {
data: { title: "Playwright test product", price: 12.5, category: "electronics" },
});
expect(created.status()).toBe(201);
const { data: product } = await created.json();
try {
await test.step("read it back", async () => {
const read = await request.get<Product>(`/api/Products/${product.id}`);
expect(read.status()).toBe(200);
expect(await read.json()).toMatchObject({ id: product.id, title: "Playwright test product", price: 12.5 });
});
await test.step("change the price", async () => {
const updated = await request.put<ProductEnvelope>(`/api/Products/${product.id}`, {
data: { title: "Playwright test product", price: 9.99, category: "electronics" },
});
expect(updated.status()).toBe(200);
expect((await updated.json()).data.price).toBe(9.99);
});
} finally {
// Clean up even when a step fails, so a broken run doesn't leave products behind.
const deleted = await request.delete(`/api/Products/${product.id}`);
expect(deleted.status()).toBe(200);
}
const gone = await request.get(`/api/Products/${product.id}`);
expect(gone.status()).toBe(404);
});- Le bloc
finallysupprime le produit même si la lecture ou la modification échoue, une exécution cassée ne laisse donc pas de données de test derrière elle. test.stepnomme chaque étape dans le rapport et le trace viewer, un échec indique donc "change the price" au lieu de pointer la ligne 23.- La dernière requête vérifie que le produit a vraiment disparu. Une suppression qui renvoie 200 mais laisse l’enregistrement en place est un vrai bug, et seule une lecture après coup le détecte.
test.describe.configure({ lock: "products" })est nouveau dans la 1.63. Les tests qui partagent un nom de verrou ne tournent jamais en même temps, entre fichiers et entre workers. Donnez le même verrou à tous les tests qui écrivent des produits, et ils ne se marcheront pas sur les données, pendant que le reste de la suite tourne en parallèle.
Notre leçon sur le test CRUD détaille les vérifications nécessaires à chacune de ces étapes, et pourquoi supprimer deux fois est aussi un test utile.
Ce que les versions 1.62 à 1.64 ont apporté aux tests d’API
| Version | Nouveauté | À quoi ça sert |
|---|---|---|
| 1.64 | request.addCookies() / cookies() / clearCookies() | Poser ou effacer des cookies sur un contexte de requêtes, comme sur un contexte de navigateur |
| 1.64 | --shuffle [seed] | Lancer les tests dans un ordre aléatoire pour trouver ceux qui ne passent qu’après un autre |
| 1.63 | request.get<T>() | response.json() typé, montré plus haut |
| 1.63 | lock | Empêcher les tests qui modifient les mêmes données de tourner en même temps |
| 1.62 | apiResponse.timing() | Temps de DNS, de connexion, de requête et de réponse d’un appel |
| 1.62 | signal | Annuler une requête avec un AbortSignal |
| 1.62 | retryStrategy: "isolated" | Faire les nouvelles tentatives à la fin, une par une |
Ce fichier teste celles qui concernent les requêtes. Notre point d’accès /api/echo renvoie la requête reçue, ce qui permet de voir exactement ce que Playwright a envoyé :
import { expect, test } from "@playwright/test";
// /api/echo returns what it received, which makes it easy to see what the request context sent.
interface Echo {
method: string;
cookies: Record<string, string>;
}
test("1.64: cookies added to the request context are sent with every request", async ({ request, baseURL }) => {
await request.addCookies([{ name: "session-id", value: "42", url: baseURL! }]);
const withCookie = await request.get<Echo>("/api/echo");
expect((await withCookie.json()).cookies).toEqual({ "session-id": "42" });
await request.clearCookies({ name: "session-id" });
const withoutCookie = await request.get<Echo>("/api/echo");
expect((await withoutCookie.json()).cookies).toEqual({});
});
test("1.62: apiResponse.timing() shows where the time went", async ({ request }) => {
const response = await request.get("/api/health");
const timing = response.timing();
// Each value is milliseconds after startTime, or -1 when it doesn't apply (no DNS lookup on a reused
// connection, for example).
expect(timing.startTime).toBeGreaterThan(0);
expect(timing.responseEnd).toBeGreaterThanOrEqual(timing.responseStart);
expect(timing.responseEnd).toBeLessThan(5_000);
});
test("1.62: an AbortSignal cancels a request that takes too long", async ({ request }) => {
// ?delay= makes the API wait before answering. The signal gives up after 500 ms.
const pending = request.get("/api/echo", { params: { delay: 3000 }, signal: AbortSignal.timeout(500) });
await expect(pending).rejects.toThrow();
});- Cookies. Un contexte de requêtes a toujours gardé les cookies posés par les réponses, et pouvait démarrer avec certains tirés de
storageState, mais on ne pouvait pas en ajouter ou en retirer un directement. C’est désormais possible, en plein test : par exemple, effacez le cookie de session et vérifiez que la requête suivante est refusée. - Temps.
timing()renvoie les mêmes champs que la Resource Timing API du navigateur, en millisecondes aprèsstartTime, avec -1 pour les phases qui n’ont pas eu lieu. Une connexion réutilisée ne fait pas de résolution DNS, vérifiez doncresponseEndplutôt que d’additionner tous les champs. La limite de 5 secondes du test est volontairement large : une assertion de temps serrée contre un serveur sur internet rend un test instable. - Annulation. Les notes de version de la 1.62 décrivent
signalpour les actions et les assertions. Il marche aussi sur les méthodes de requête, ce que nous avons vérifié dans les définitions de types de la 1.64 et dans le test ci-dessus :?delay=3000fait attendre notre API trois secondes, etAbortSignal.timeout(500)abandonne au bout d’une demi-seconde. L’optiontimeoutde la requête fait un travail proche ; un signal sert quand quelque chose d’autre décide de l’arrêt, comme un test qui se termine plus tôt.
La configuration du début utilise déjà retryStrategy: "isolated". Par défaut, un test en échec est relancé dès qu’un worker se libère, pendant que le reste de la suite tourne encore. Les nouvelles tentatives isolées attendent la fin et passent une par une. Si un test échoue dans l’exécution parallèle mais passe en tentative isolée, c’est probablement qu’un autre test le gêne, par exemple deux tests qui modifient le même enregistrement, et un lock règle le problème. --shuffle trouve le problème inverse : lancez de temps en temps la suite dans un ordre aléatoire, et tout test qui ne passe qu’après un autre se révélera.
$ npx playwright test --shuffleLancer la suite
$ npx playwright testRunning 12 tests using 3 workers
✓ 1 tests\read.spec.ts:4:5 › lists products filtered by category, with paging headers (285ms)
✓ 2 tests\new-in-1-62-to-1-64.spec.ts:9:5 › 1.64: cookies added to the request context are sent with every request (263ms)
✓ 3 tests\crud.spec.ts:8:5 › creates, reads, updates and deletes a product (334ms)
✓ 4 tests\read.spec.ts:21:5 › returns 404 with an error body for a product that doesn't exist (90ms)
✓ 5 tests\new-in-1-62-to-1-64.spec.ts:20:5 › 1.62: apiResponse.timing() shows where the time went (104ms)
✓ 6 tests\read.spec.ts:32:5 › rejects a product without a title (118ms)
✓ 8 tests\read.spec.ts:40:7 › handles a 400 response (109ms)
✓ 9 tests\read.spec.ts:40:7 › handles a 401 response (107ms)
✓ 10 tests\read.spec.ts:40:7 › handles a 429 response (120ms)
✓ 7 tests\new-in-1-62-to-1-64.spec.ts:31:5 › 1.62: an AbortSignal cancels a request that takes too long (512ms)
✓ 11 tests\read.spec.ts:40:7 › handles a 500 response (107ms)
✓ 12 tests\read.spec.ts:40:7 › handles a 503 response (97ms)
12 passed (2.1s)La suite complète prend environ deux secondes. Téléchargez-la sous la forme playwright-api-examples.zip, lancez npm install puis npx playwright test, et elle tourne contre la même API. Tous les points d’accès qu’elle utilise sont gratuits, sans compte ni clé.
Des appels d’API dans les tests d’interface
Le même client est disponible dans les tests de navigateur sous le nom page.request. Il partage les cookies avec la page : vous pouvez créer des données de test ou vous connecter par l’API, puis vérifier le résultat dans l’interface, bien plus vite qu’en remplissant des formulaires pour préparer chaque test :
test("shows a product created through the API", async ({ page }) => {
// page.request shares cookies with the page, so a session set up here carries over to the browser.
const created = await page.request.post("https://api.testingapis.com/api/Products", {
data: { title: "Seeded", price: 1 },
});
// ...then open the page that lists products and check it appears.
});Les points d’accès appelés par ces tests sont documentés dans la documentation de l’API de produits et celle de l’API des codes de statut, avec un bouton Send pour essayer chaque requête avant d’en écrire le test.