Pengujian API dengan Playwright: suite yang berjalan, dan apa yang ditambahkan 1.62 sampai 1.64
Kebanyakan orang mengenal Playwright sebagai alat pengujian browser. Padahal ia juga punya klien HTTP lengkap bawaan, yaitu fixture request, dan dengannya Anda bisa menulis tes API tanpa membuka browser sama sekali. Tesnya berjalan di runner yang sama, dengan laporan, retry, dan worker paralel yang sama seperti tes UI Anda.
Tulisan ini membangun suite tes API kecil dengannya, terhadap API uji gratis kami. Setiap contoh dijalankan dengan Playwright 1.64.0, yang rilis 7 Oktober 2026, dan ke-12 tesnya lulus. Bagian terakhir membahas apa yang ditambahkan tiga rilis terakhir untuk pengujian API: response bertipe, lock untuk tes yang berbagi data, cookie di konteks request, waktu response, dan membatalkan request.
Persiapan
Anda butuh Node.js 20 atau lebih baru. Tes API tidak butuh browser, jadi tidak ada langkah npx playwright install:
$ mkdir playwright-api && cd playwright-api
$ npm init -y
$ npm install --save-dev @playwright/test@1.64.0Konfigurasinya menetapkan base URL, agar tes bisa memakai path seperti /api/Products, dan header Accept bawaan untuk setiap request:
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" },
},
});Dengan BASE_URL, tes yang sama bisa diarahkan ke server lain, seperti salinan lokal API atau lingkungan staging, tanpa mengubahnya.
Tes API pertama Anda
Minta fixture request di sebuah tes, dan Anda mendapat APIRequestContext dengan get, post, put, patch, delete, dan fetch. Masing-masing mengembalikan APIResponse berisi status, header, dan body. File ini memeriksa endpoint daftar, body error, error validasi, dan beberapa kode status:
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);
});
}paramsmenyusun query string, jadi Anda tidak perlu meng-encode?category=electronics&limit=2secara manual.response.headers()mengembalikan nama header dalam huruf kecil. API kami mengembalikan jumlah total produk yang cocok diX-Total-Count, jadi tesnya memeriksa paginasi selain body.datapadapostdikirim sebagai JSON, denganContent-Type: application/jsonsudah dipasang.- Loop
formembuat satu tes per kode status./api/status/{code}mengembalikan status apa pun yang Anda minta, berguna untuk memeriksa reaksi kode klien Anda sendiri terhadap 429 atau 503.
404 atau 500 tidak membuat request.get melempar exception. Anda mendapat response-nya dan melakukan asersi terhadapnya, dan memang itu yang Anda mau ketika error-nya justru yang sedang diuji. Bila status error seharusnya langsung menggagalkan tes, berikan failOnStatusCode:
// Throw on any 4xx or 5xx instead of returning the response.
const response = await request.get("/api/Products/1", { failOnStatusCode: true });Memberi tipe pada response
Sejak Playwright 1.63, metode request menerima argumen tipe, dan response.json() mengembalikan tipe itu. Itulah yang dilakukan request.get<Product[]> di atas. Tipe-tipenya ada di satu file:
// 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;
}Ini petunjuk untuk TypeScript, bukan pemeriksaan. Jika API mengirim string di tempat Anda mendeklarasikan angka, tesnya tetap terkompilasi dan berjalan. Pertahankan asersi pada nilai yang penting, seperti yang dilakukan tes di atas dengan typeof product.price. Untuk memvalidasi seluruh body response terhadap schema, lihat pelajaran validasi response kami (dalam bahasa Inggris).
Tes membuat, membaca, mengubah, dan menghapus yang bersih-bersih sesudahnya
Produk di API kami dipakai bersama: produk yang Anda buat terlihat oleh semua orang sampai dihapus. Tes terhadap lingkungan bersama harus membereskan jejaknya sendiri, termasuk saat gagal di tengah jalan:
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);
});- Blok
finallymenghapus produk meskipun membaca atau mengubahnya gagal, jadi run yang rusak tidak meninggalkan data uji. test.stepmemberi nama setiap tahap di laporan dan trace viewer, jadi kegagalan berbunyi "change the price", bukan menunjuk baris 23.- Request terakhir memeriksa apakah produk benar-benar hilang. Delete yang mengembalikan 200 tetapi membiarkan datanya tetap ada adalah bug sungguhan, dan hanya pembacaan lanjutan yang bisa menangkapnya.
test.describe.configure({ lock: "products" })baru di 1.63. Tes yang berbagi nama lock tidak pernah berjalan bersamaan, lintas file maupun worker. Beri lock yang sama ke setiap tes yang menulis produk, dan mereka tidak akan saling mengganggu data, sementara sisa suite tetap berjalan paralel.
Pelajaran pengujian CRUD kami membahas pemeriksaan apa yang dibutuhkan setiap langkah ini, dan mengapa menghapus dua kali juga tes yang berguna.
Apa yang ditambahkan 1.62 sampai 1.64 untuk tes API
| Versi | Tambahan | Kegunaannya |
|---|---|---|
| 1.64 | request.addCookies() / cookies() / clearCookies() | Memasang atau menghapus cookie di konteks request, sama seperti di konteks browser |
| 1.64 | --shuffle [seed] | Menjalankan tes dalam urutan acak untuk menemukan tes yang hanya lulus setelah tes lain |
| 1.63 | request.get<T>() | response.json() bertipe, seperti di atas |
| 1.63 | lock | Mencegah tes yang mengubah data yang sama berjalan bersamaan |
| 1.62 | apiResponse.timing() | Waktu DNS, koneksi, request, dan response untuk satu panggilan |
| 1.62 | signal | Membatalkan request dengan AbortSignal |
| 1.62 | retryStrategy: "isolated" | Menjalankan retry di akhir, satu per satu |
File ini menguji tambahan yang berlaku untuk request. Endpoint /api/echo kami mengembalikan request yang diterimanya, sehingga mudah melihat persis apa yang dikirim Playwright:
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();
});- Cookie. Konteks request sejak dulu menyimpan cookie yang dipasang response, dan bisa dimulai dengan beberapa cookie dari
storageState, tetapi Anda tidak bisa menambah atau menghapus satu cookie secara langsung. Sekarang bisa, di tengah tes: misalnya, hapus cookie sesi lalu periksa apakah request berikutnya ditolak. - Waktu.
timing()mengembalikan field yang sama dengan Resource Timing API browser, dalam milidetik setelahstartTime, dengan -1 untuk fase yang tidak terjadi. Koneksi yang dipakai ulang tidak melakukan lookup DNS, jadi periksaresponseEndalih-alih menjumlahkan semua field. Batas 5 detik di tes sengaja dibuat longgar: asersi waktu yang ketat terhadap server di internet membuat tes tidak stabil. - Membatalkan. Catatan rilis 1.62 menjelaskan
signaluntuk action dan asersi. Ternyata ia juga berfungsi di metode request, yang kami periksa di definisi tipe 1.64 dan di tes di atas:?delay=3000membuat API kami menunggu tiga detik, danAbortSignal.timeout(500)menyerah setelah setengah detik. Opsitimeoutmilik request sendiri melakukan hal serupa; signal berguna ketika ada hal lain yang menentukan kapan berhenti, seperti tes yang berakhir lebih awal.
Konfigurasi di bagian atas sudah memakai retryStrategy: "isolated". Secara bawaan, tes yang gagal diulang begitu ada worker yang kosong, saat sisa suite masih berjalan. Retry terisolasi menunggu sampai akhir dan berjalan satu per satu. Jika sebuah tes gagal di run paralel tetapi lulus di retry terisolasinya, kemungkinan ada tes lain yang mengganggunya, misalnya dua tes yang mengubah data yang sama, dan solusinya adalah lock. --shuffle menemukan masalah sebaliknya: sesekali jalankan suite dalam urutan acak, dan tes yang hanya lulus setelah tes lain akan ketahuan.
$ npx playwright test --shuffleMenjalankan 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)Seluruh suite butuh sekitar dua detik. Unduh sebagai playwright-api-examples.zip, jalankan npm install dan npx playwright test, dan ia akan berjalan terhadap API yang sama. Semua endpoint yang dipakainya gratis, tanpa akun atau key.
Panggilan API di dalam tes UI
Klien yang sama tersedia di tes browser sebagai page.request. Ia berbagi cookie dengan halaman, jadi Anda bisa membuat data uji atau login lewat API, lalu memeriksa hasilnya di UI, jauh lebih cepat daripada mengisi formulir untuk menyiapkan setiap tes:
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.
});Endpoint yang dipanggil tes-tes ini didokumentasikan di dokumentasi API produk dan dokumentasi API kode status, dengan tombol Send untuk mencoba setiap request sebelum Anda menulis tesnya.