Teste de API com Playwright: uma suíte que funciona, e o que as versões 1.62 a 1.64 trouxeram
A maioria das pessoas conhece o Playwright como ferramenta de teste de navegador. Mas ele também tem um cliente HTTP completo embutido, o fixture request, e com ele dá para escrever testes de API sem abrir navegador nenhum. Os testes rodam no mesmo runner, com os mesmos relatórios, retries e workers em paralelo dos seus testes de interface.
Neste post montamos com ele uma pequena suíte de testes de API contra a nossa API de teste gratuita. Cada exemplo foi rodado com o Playwright 1.64.0, lançado em 7 de outubro de 2026, e os 12 testes passam. A última parte cobre o que as três últimas versões trouxeram para teste de API: respostas tipadas, locks para testes que compartilham dados, cookies no contexto de requisições, tempos de resposta e cancelamento de requisição.
Preparando
Você precisa do Node.js 20 ou mais recente. Testes de API não precisam de navegadores, então não tem o passo npx playwright install:
$ mkdir playwright-api && cd playwright-api
$ npm init -y
$ npm install --save-dev @playwright/test@1.64.0A configuração define uma URL base, para que os testes usem caminhos como /api/Products, e um cabeçalho Accept padrão para cada requisição:
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 permite apontar os mesmos testes para outro servidor, como uma cópia local da API ou um ambiente de staging, sem editar nada.
Seu primeiro teste de API
Peça o fixture request em um teste e você recebe um APIRequestContext com get, post, put, patch, delete e fetch. Cada um devolve um APIResponse com o status, os cabeçalhos e o corpo. Este arquivo verifica um endpoint de lista, um corpo de erro, um erro de validação e alguns códigos de 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);
});
}paramsmonta a query string, então você não precisa codificar?category=electronics&limit=2na mão.response.headers()devolve os nomes dos cabeçalhos em minúsculas. Nossa API devolve o total de produtos encontrados emX-Total-Count, então o teste verifica a paginação além do corpo.dataem umposté enviado como JSON, comContent-Type: application/jsonjá definido.- O loop
forcria um teste por código de status./api/status/{code}devolve o status que você pedir, o que ajuda a ver como o seu próprio código cliente reage a um 429 ou a um 503.
Um 404 ou um 500 não faz o request.get lançar exceção. Você recebe a resposta e faz asserções sobre ela, que é o que você quer quando o erro é justamente o que está testando. Quando um status de erro deve simplesmente reprovar o teste, passe failOnStatusCode:
// Throw on any 4xx or 5xx instead of returning the response.
const response = await request.get("/api/Products/1", { failOnStatusCode: true });Tipando as respostas
Desde o Playwright 1.63, os métodos de requisição aceitam um argumento de tipo, e response.json() devolve esse tipo. Foi o que request.get<Product[]> fez acima. Os tipos ficam em um arquivo:
// 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;
}Isso é uma dica para o TypeScript, não uma verificação. Se a API mandar uma string onde você declarou um número, o teste compila e roda do mesmo jeito. Mantenha as asserções nos valores que importam, como os testes acima fazem com typeof product.price. Para validar corpos de resposta inteiros contra um schema, veja nossa lição de validação de respostas (em inglês).
Um teste de criar, ler, atualizar e apagar que limpa depois
Os produtos da nossa API são compartilhados: um produto que você cria fica visível para todo mundo até ser apagado. Testes contra um ambiente compartilhado precisam limpar o que deixam, inclusive quando falham no meio:
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);
});- O bloco
finallyapaga o produto mesmo se a leitura ou a atualização falhar, então uma execução quebrada não deixa dados de teste para trás. test.stepdá nome a cada etapa no relatório e no trace viewer, então uma falha diz "change the price" em vez de apontar para a linha 23.- A última requisição verifica se o produto sumiu mesmo. Um delete que devolve 200 mas deixa o registro no lugar é um bug real, e só uma leitura depois pega isso.
test.describe.configure({ lock: "products" })é novo na 1.63. Testes que compartilham um nome de lock nunca rodam ao mesmo tempo, entre arquivos e workers. Dê o mesmo lock a todo teste que escreve produtos e eles não vão atrapalhar os dados uns dos outros, enquanto o resto da suíte continua em paralelo.
Nossa lição de teste CRUD mostra quais verificações cada uma dessas etapas precisa, e por que apagar duas vezes também é um bom teste.
O que as versões 1.62 a 1.64 trouxeram para testes de API
| Versão | Novidade | Para que serve |
|---|---|---|
| 1.64 | request.addCookies() / cookies() / clearCookies() | Definir ou limpar cookies em um contexto de requisições, do mesmo jeito que num contexto de navegador |
| 1.64 | --shuffle [seed] | Rodar os testes em ordem aleatória para achar testes que só passam depois de outro |
| 1.63 | request.get<T>() | response.json() tipado, mostrado acima |
| 1.63 | lock | Impedir que testes que mudam os mesmos dados rodem ao mesmo tempo |
| 1.62 | apiResponse.timing() | Tempos de DNS, conexão, requisição e resposta de uma chamada |
| 1.62 | signal | Cancelar uma requisição com um AbortSignal |
| 1.62 | retryStrategy: "isolated" | Rodar os retries no final, um de cada vez |
Este arquivo testa as que valem para requisições. Nosso endpoint /api/echo devolve a requisição que recebeu, o que facilita ver exatamente o que o Playwright enviou:
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. Um contexto de requisições sempre guardou os cookies que as respostas definem, e podia começar com alguns vindos de
storageState, mas não dava para adicionar ou remover um diretamente. Agora dá, no meio do teste: por exemplo, limpe o cookie de sessão e verifique se a próxima requisição é recusada. - Tempos.
timing()devolve os mesmos campos da Resource Timing API do navegador, em milissegundos depois destartTime, com -1 para as fases que não aconteceram. Uma conexão reaproveitada não faz consulta DNS, então verifiqueresponseEndem vez de somar todos os campos. O limite de 5 segundos no teste é folgado de propósito: uma asserção de tempo apertada contra um servidor na internet deixa o teste instável. - Cancelamento. As notas da versão 1.62 descrevem
signalpara ações e asserções. Ele também funciona nos métodos de requisição, o que conferimos nas definições de tipos da 1.64 e no teste acima:?delay=3000faz nossa API esperar três segundos, eAbortSignal.timeout(500)desiste depois de meio segundo. A opçãotimeoutda própria requisição faz algo parecido; um signal é útil quando outra coisa decide quando parar, como um teste que termina antes.
A configuração lá em cima já usa retryStrategy: "isolated". Por padrão, um teste que falha é repetido assim que um worker fica livre, enquanto o resto da suíte ainda está rodando. Retries isolados esperam o fim e rodam um de cada vez. Se um teste falha na execução em paralelo mas passa no retry isolado, a causa provável é outro teste atrapalhando, como dois testes mudando o mesmo registro, e a solução é um lock. --shuffle encontra o problema oposto: rode a suíte em ordem aleatória de vez em quando e aparece todo teste que só passa depois de outro.
$ npx playwright test --shuffleRodando a suíte
$ 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)A suíte inteira leva uns dois segundos. Baixe como playwright-api-examples.zip, rode npm install e npx playwright test, e ela roda contra a mesma API. Todos os endpoints que ela usa são gratuitos, sem conta nem chave.
Chamadas de API dentro de testes de interface
O mesmo cliente está disponível nos testes de navegador como page.request. Ele compartilha cookies com a página, então você pode criar dados de teste ou fazer login pela API e depois conferir o resultado na interface, bem mais rápido do que preencher formulários para preparar cada teste:
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.
});Os endpoints que esses testes chamam estão documentados na documentação da API de produtos e na da API de códigos de status, com um botão Send para testar cada requisição antes de escrever o teste.