Playwright API tests
HTTP API coverage lives in apps/web/playwright/api/<resource>/*.spec.ts. Auth is seeded once by Playwright globalSetup (apps/web/global-setup.ts → setup-test-workspace.ts) into playwright/.auth/api.json. Specs use the custom test from ../fixtures — not @playwright/test directly for test.
Do not put these under apps/web/tests/ (Vitest) or under playwright/partners / playwright/workspaces (browser e2e).
Before writing
- Find the route handler and response shape (Zod schemas under
apps/web/lib/zod, types under@/lib/typesor Prisma). - Prefer mirroring an existing nearby resource folder:
tags/,folders/,customers/,workspaces/. - Confirm the endpoint is workspace-scoped Bearer API (most
/api/*routes). The fixture already sendsAuthorization: Bearer ….
What to cover
Before considering an endpoint covered, ask:
- What happens with valid input?
- What happens with invalid input?
- Who is allowed to call it?
- Can another workspace access the resource?
- What happens when the resource does not exist?
- What happens with duplicate data?
- What happens at boundary values?
- What state does the operation leave behind?
- Can the operation be safely retried?
- Does pagination/filtering/sorting behave correctly?
- What happens when multiple requests happen concurrently?
- Are errors returned in the documented format?
Not every endpoint needs a test for every item. The goal is to cover the actual failure modes and API contract, not to maximize the number of tests.
File layout
apps/web/playwright/api/
├── fixtures.ts # api + workspace fixtures (reuse; don't duplicate)
├── setup-test-workspace.ts # globalSetup seed only — extend only if auth/workspace setup must change
└── <resource>/
├── <resource>.spec.ts
└── <resource>-pagination.spec.ts # optional split for heavy pagination suites
One folder per resource. Name files kebab-case.spec.ts (tags/tags.spec.ts, customers/customers-pagination.spec.ts).
Spec template
import { expect } from "@playwright/test";
import { randomName } from "../../utils";
import { test, type ApiClient } from "../fixtures";
async function createThing(
api: ApiClient,
overrides: Record<string, unknown> = {},
) {
return api.post<YourType>("/api/things", {
name: randomName("thing"),
...overrides,
});
}
async function deleteThing(api: ApiClient, id: string | undefined) {
if (!id) return;
await api.delete(`/api/things/${id}`);
}
test("POST /things", async ({ api }) => {
let id: string | undefined;
try {
const body = { name: randomName("thing") };
const { status, data } = await api.post<YourType>("/api/things", body);
id = data.id;
expect(status).toEqual(201);
expect(data).toStrictEqual({
id: expect.any(String),
...body,
// stable nulls / defaults from the API
});
} finally {
await deleteThing(api, id);
}
});
Required conventions
| Rule | Detail |
|---|---|
Import test from ../fixtures |
Provides api, workspace, and program |
| Serial only when tests share state | API project is fullyParallel: true. Do not add mode: "parallel". Use test.describe.configure({ mode: "serial" }) only when tests in a file/describe share state (e.g. domains, seeded pagination) |
Cleanup in finally |
Create → assert → always delete created rows |
| Unique names/ids | Use randomName / randomCustomer / randomPartnerEmail from ../../utils — never fixed colliding names |
| Assert status + body | Prefer toStrictEqual / toEqual on full shapes; use expect.any(String) for ids/timestamps |
| Default shape once | Happy-path POST owns the full default resource (and nested) shape. Variant tests assert only what they change |
| HTTP contract only | Assert status + JSON. Do not poll/sleep for waitUntil, R2, or other background jobs — CI has no STORAGE_* |
| Error responses | Match { error: { code, message, doc_url } } exactly |
| Typed generics | api.get<T>, api.post<T>, etc. |
| Seeded fixtures | { workspace }, { program } (id, defaultGroupId), TEST_WORKSPACE — not Vitest E2E_* constants |
Fixtures (api / workspace / program)
api.get/post/patch/delete(url, data?)→{ status, data }(JSON parsed).- Paths are app-relative (
/api/...);baseURLishttp://localhost:8888. workspace.id/workspace.slugandprogram.id/program.defaultGroupIdcome from.auth/api.json.- Seeded program domain/url:
TEST_WORKSPACE.programinsetup-test-workspace.ts.
Helpers
- Shared randomizers / sort assertions:
apps/web/playwright/utils.ts. - Local
createX/deleteXhelpers in the spec file when the same setup repeats. - Once
createXexists, use it for setup and follow-up creates (pass overrides for identity fields that must stay stable, e.g.email). Happy-path tests may still inlineapi.postso the asserted body is local. - Prisma direct seed is allowed for bulk fixtures (see
customers/customers-pagination.spec.ts); still clean up infinally/ after hooks. No DELETE route → Prisma/conncleanup is fine (partners/partners.spec.ts).
Error / table-driven cases
const errorCases = [
{
name: "POST /things – missing name",
body: {},
expected: {
status: 422,
data: {
error: {
code: "unprocessable_entity",
message: "…",
doc_url:
"https://dub.co/docs/api-reference/errors#unprocessable-entity",
},
},
},
},
];
for (const { name, body, expected } of errorCases) {
test(name, async ({ api }) => {
expect(await api.post("/api/things", body)).toEqual(expected);
});
}
Optional: validate richer payloads with Zod (.parse(...)) like workspaces/workspaces.spec.ts / enriched customer fields.
Migrating Vitest API tests
When converting apps/web/tests/<resource>/*.test.ts:
- Add or extend
playwright/api/<resource>/<resource>.spec.ts— do not leave a parallel Vitest HTTP spec. - Map
IntegrationHarness/http.post({ path })→apifixture (/api/...paths). - Replace
E2E_*/E2E_PARTNER_GROUPwith{ workspace },{ program }, andTEST_WORKSPACE. - Delete the Vitest file after the Playwright spec covers its cases.
Do not
- Call
setupTestWorkspacefrom specs (onlyglobalSetup). - Import browser
page/ storage-state auth for API specs. - Commit secrets or change the fixed Playwright token unless intentionally rotating local/CI test auth.
- Skip cleanup (parallel API project will leak / flake).
- Run
pnpm buildafter adding tests. - Poll or
setTimeoutfor R2/storage/waitUntilside effects (noSTORAGE_*in CI). Assert the immediate JSON body instead (e.g. foreignimageURLs staynullon create). - Copy Vitest
IntegrationHarnessorE2E_*constants into Playwright specs.
Run
Dev server on port 8888 (local), then:
pnpm --filter web test:e2e --project=api
pnpm --filter web test:e2e --project=api playwright/api/tags/tags.spec.ts
pnpm --filter web test:e2e --project=api playwright/api/tags/tags.spec.ts -g "POST /tags"
-g / --grep matches the test title (regex). Combine with a file path to narrow further.
In CI, playwright.config.ts starts pnpm start -p 8888 via webServer.
More context: apps/web/playwright/README.md (API section).