# Playwright API Tests

> Add or extend Playwright HTTP API specs under apps/web/playwright/api using the shared api/workspace fixtures and Bearer auth from globalSetup. Use when writing Playwright API tests, e2e API specs, or testing /api routes with the api project (not Vitest under apps/web/tests, not browser UI e2e).

- Skill: `dubinc/playwright-api-tests` (Agent Skill)
- Install (CLI): `npx skillmds@latest add dubinc/playwright-api-tests`
- Raw SKILL.md: https://api.skillmd.com/api/skills/dubinc/playwright-api-tests/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: dubinc (https://skillmd.com/u/dubinc)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/dubinc/playwright-api-tests

---


# 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

1. Find the route handler and response shape (Zod schemas under `apps/web/lib/zod`, types under `@/lib/types` or Prisma).
2. Prefer mirroring an existing nearby resource folder: `tags/`, `folders/`, `customers/`, `workspaces/`.
3. Confirm the endpoint is workspace-scoped Bearer API (most `/api/*` routes). The fixture already sends `Authorization: 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

```ts
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/...`); `baseURL` is `http://localhost:8888`.
- `workspace.id` / `workspace.slug` and `program.id` / `program.defaultGroupId` come from `.auth/api.json`.
- Seeded program domain/url: `TEST_WORKSPACE.program` in `setup-test-workspace.ts`.

### Helpers

- Shared randomizers / sort assertions: `apps/web/playwright/utils.ts`.
- Local `createX` / `deleteX` helpers in the spec file when the same setup repeats.
- Once `createX` exists, 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 inline `api.post` so the asserted body is local.
- Prisma direct seed is allowed for bulk fixtures (see `customers/customers-pagination.spec.ts`); still clean up in `finally` / after hooks. No DELETE route → Prisma/`conn` cleanup is fine (`partners/partners.spec.ts`).

### Error / table-driven cases

```ts
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`:

1. Add or extend `playwright/api/<resource>/<resource>.spec.ts` — do not leave a parallel Vitest HTTP spec.
2. Map `IntegrationHarness` / `http.post({ path })` → `api` fixture (`/api/...` paths).
3. Replace `E2E_*` / `E2E_PARTNER_GROUP` with `{ workspace }`, `{ program }`, and `TEST_WORKSPACE`.
4. Delete the Vitest file after the Playwright spec covers its cases.

## Do not

- Call `setupTestWorkspace` from specs (only `globalSetup`).
- 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 build` after adding tests.
- Poll or `setTimeout` for R2/storage/`waitUntil` side effects (no `STORAGE_*` in CI). Assert the immediate JSON body instead (e.g. foreign `image` URLs stay `null` on create).
- Copy Vitest `IntegrationHarness` or `E2E_*` constants into Playwright specs.

## Run

Dev server on port 8888 (local), then:

```sh
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).

