---
title: "Payload CMS REST API Testing: Complete Guide & Examples"
slug: "payload-cms-rest-api-testing-guide"
published: "2026-08-28"
updated: "2026-08-31"
validated: "2026-08-31"
categories:
  - "Payload"
tags:
  - "Payload CMS REST API testing"
  - "Payload REST testing"
  - "programmatic REST testing"
  - "node fetch client"
  - "node:test suite"
  - "bulk operation safety"
  - "access control testing"
  - "document-level access"
  - "Lexical rich text"
  - "JWT authentication"
  - "API key authentication"
  - "validation testing"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "payload@3"
  - "node@20"
  - "node:test@20"
  - "playwright@1.40"
  - "cypress@12"
  - "lexical@0.20"
status: "stable"
llm-purpose: "Payload CMS REST API testing: zero-dependency Node fetch client and runnable node:test suite to verify access control, validation, drafts, and bulk-safety."
llm-prereqs:
  - "Access to Payload CMS v3"
  - "Access to Node.js"
  - "Access to fetch"
  - "Access to AbortSignal.timeout"
  - "Access to node:test"
llm-outputs:
  - "Completed outcome: Payload CMS REST API testing: zero-dependency Node fetch client and runnable node:test suite to verify access control, validation, drafts, and bulk-safety."
---

**Summary Triples**
- (zero-dependency fetch client, provides, a minimal Node fetch wrapper to test Payload v3 REST endpoints without browser automation)
- (node:test suite, is, runnable directly in Node to validate access control, validation, drafts, and bulk-safety)
- (REST tests, are faster than, UI/browser tests for verifying permission and validation logic)
- (access control problems, should be tested at, API layer by asserting HTTP status codes and response bodies rather than DOM behavior)
- (document-level access probing, is, a testing use case that verifies per-document ACLs and owner-only operations via REST)
- (bulk-operation safety, requires, tests that assert dangerous bulk endpoints reject unauthorized or unintended requests)
- (AbortSignal.timeout, is recommended for, timing out fetch requests in test suites to avoid hanging tests)
- (file uploads, are supported via, multipart/form-data requests in the zero-dependency client)
- (auth setup, is expected, to be pre-configured (API Key or JWT); the guide links to an auth setup article)
- (example scenario, demonstrates, verifying that editor A cannot delete editor B's posts via REST calls)

### {GOAL}
Payload CMS REST API testing: zero-dependency Node fetch client and runnable node:test suite to verify access control, validation, drafts, and bulk-safety.

### {PREREQS}
- Access to Payload CMS v3
- Access to Node.js
- Access to fetch
- Access to AbortSignal.timeout
- Access to node:test

### {STEPS}
1. Prepare authentication and environment
2. Create zero-dependency REST client
3. Seed fixtures and perform CRUD
4. Run validation and negative tests
5. Type-level access probing (GET /api/access)
6. Document-level access checks
7. Bulk operation safety tests
8. Test drafts, versions, and workflows

<!-- llm:goal="Payload CMS REST API testing: zero-dependency Node fetch client and runnable node:test suite to verify access control, validation, drafts, and bulk-safety." -->
<!-- llm:prereq="Access to Payload CMS v3" -->
<!-- llm:prereq="Access to Node.js" -->
<!-- llm:prereq="Access to fetch" -->
<!-- llm:prereq="Access to AbortSignal.timeout" -->
<!-- llm:prereq="Access to node:test" -->
<!-- llm:output="Completed outcome: Payload CMS REST API testing: zero-dependency Node fetch client and runnable node:test suite to verify access control, validation, drafts, and bulk-safety." -->

# Payload CMS REST API Testing: Complete Guide & Examples
> Payload CMS REST API testing: zero-dependency Node fetch client and runnable node:test suite to verify access control, validation, drafts, and bulk-safety.
Matija Žiberna · 2026-08-28

You do not need a browser to verify that your Payload CMS access control, validation rules, and Admin Panel permissions actually work. Every capability the Admin UI exposes — who can see a collection, who can edit a field, what happens when a required field is missing — is backed by the REST API, and you can assert against all of it with plain `fetch` calls in milliseconds instead of minutes. This guide walks through a zero-dependency REST test client for Payload CMS v3, nine concrete testing use cases (including two REST surfaces most teams never touch: document-level access probing and bulk-operation safety), and a runnable `node:test` suite you can drop into a real project.

I built this after watching a Payload project's Playwright suite balloon past eight minutes just to check that editors couldn't delete other people's posts. The assertion had nothing to do with rendering — it was a permissions question, and permissions questions belong at the API layer, not the DOM.

<a href="https://www.buildwithmatija.com/blog/fetch-payload-cms-data-rest-api">If you haven't set up authenticated REST requests against Payload before, start with the API key and JWT authentication guide</a> — this article assumes you already have a working `Authorization` header and builds from there into full test coverage.

## 1. Why Test Payload CMS via REST API?

When building and testing applications powered by Payload CMS, developers often assume that testing Admin Panel features requires driving a real headless browser (via Playwright, Cypress, or Puppeteer). Browser tests are valuable for visual regression and rich text canvas interactions, but using them for business logic, access rules, and form validation creates significant friction:

- **Execution Speed**: Running 50 browser tests can take several minutes due to browser launches, network idle waits, and React/Next.js hydration. The equivalent REST API suite executes in seconds, typically 20–50ms per request.
- **Comprehensive Negative & Validation Testing**: Testing 20 variations of invalid input (missing required fields, duplicate slugs, invalid email formats, unauthorized role updates) in a browser requires slow form filling and DOM queries. Over REST, it is a loop of JSON payloads asserting `400 Bad Request` responses.
- **Immediate Precision**: REST responses provide exact HTTP status codes (`200`, `201`, `400`, `403`, `500`), typed JSON error arrays, and document IDs immediately, with no need to infer state from what rendered on screen.

REST is not the only alternative to the browser, though. Payload also exposes a Local API — direct function calls (`payload.find`, `payload.create`) that run inside the same Node process as your app, skipping HTTP entirely. Each layer tests a different thing:

<table>
<thead>
<tr><th>Layer</th><th>What it actually verifies</th><th>When to use it</th></tr>
</thead>
<tbody>
<tr><td>Browser (Playwright/Cypress)</td><td>Rendered UI, client-side JS, rich text canvas, visual regressions</td><td>Admin UI interactions a human actually clicks through: drag-and-drop, live preview, upload dropzones</td></tr>
<tr><td>REST API</td><td>HTTP-layer behavior: auth middleware, serialization, access control as the Admin UI itself calls it, status codes, real network round-trip</td><td>Access control, validation, CRUD, anything the Admin UI decides to render/hide based on a REST or GraphQL response</td></tr>
<tr><td>Local API</td><td>Business logic and hooks in isolation, without HTTP or auth middleware in the loop</td><td>Fast unit-style tests of hooks, field-level logic, or seeding large fixture sets in CI setup</td></tr>
</tbody>
</table>

The Admin Panel itself decides what to render by calling the same REST (or GraphQL) endpoints your tests will call — most notably `GET /api/access`. Testing at this layer means you're testing exactly what governs the UI, not a proxy for it.

### What You Can Test via REST

```mermaid
graph TD
    A["Payload CMS REST Testing"] --> B["1. User Access & Permissions"]
    A --> C["2. Fast CRUD & Teardown"]
    A --> D["3. Validation & Negative Testing"]
    A --> E["4. Field & Document-Level Governance"]
    A --> F["5. Bulk Operations & Drafts"]
    A --> G["6. Advanced Lifecycle Workflows"]

    B --> B1["Assert what buttons & collections are visible via GET /api/access"]
    C --> C1["Quickly seed fixtures and tear down test data in finally blocks"]
    D --> D1["Submit missing required fields, invalid types, duplicate unique keys"]
    E --> E1["Verify hidden fields, read-only fields, field-level ABAC, and per-document access"]
    F --> F1["Test bulk PATCH/DELETE safety and draft/version state transitions"]
    G --> G1["Test multi-tier approval states, custom endpoints, and publishing"]
```

## 2. Complete, Standalone REST Client

This zero-dependency HTTP client, built on native Node.js `fetch` and `AbortSignal.timeout`, provides everything needed to interact with Payload CMS programmatically.

```typescript
// File: test/lib/rest-client.ts
export type ClientResponse<T = unknown> = {
  json: T;
  status: number;
};

export type RawHttpClient = {
  delete<T = unknown>(path: string): Promise<ClientResponse<T>>;
  get<T = unknown>(path: string): Promise<ClientResponse<T>>;
  patch<T = unknown>(path: string, body?: unknown): Promise<ClientResponse<T>>;
  post<T = unknown>(path: string, body?: unknown): Promise<ClientResponse<T>>;
  readonly authHeader?: string;
};

const REQUEST_TIMEOUT_MS = 30_000;

async function doFetch<T>(
  baseUrl: string,
  method: string,
  path: string,
  authHeader?: string,
  body?: unknown,
): Promise<ClientResponse<T>> {
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
  };

  if (authHeader) {
    headers["Authorization"] = authHeader;
  }

  const res = await fetch(`${baseUrl}${path}`, {
    method,
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
    signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
  });

  const text = await res.text();
  let json: unknown = null;
  if (text) {
    try {
      json = JSON.parse(text);
    } catch {
      json = text;
    }
  }

  return { json: json as T, status: res.status };
}

/** Creates an HTTP client bound to a specific Authorization header. */
export function createHttpClient(baseUrl: string, authHeader?: string): RawHttpClient {
  return {
    authHeader,
    delete: <T>(path: string) => doFetch<T>(baseUrl, "DELETE", path, authHeader),
    get: <T>(path: string) => doFetch<T>(baseUrl, "GET", path, authHeader),
    patch: <T>(path: string, body?: unknown) => doFetch<T>(baseUrl, "PATCH", path, authHeader, body),
    post: <T>(path: string, body?: unknown) => doFetch<T>(baseUrl, "POST", path, authHeader, body),
  };
}

/** Creates an Admin Client authenticated via API Key (for fixture setup & cleanup). */
export function createAdminClient(baseUrl: string, adminApiKey: string): RawHttpClient {
  return createHttpClient(baseUrl, `users API-Key ${adminApiKey}`);
}

/** Logs in with user credentials and returns a client bound to their JWT. */
export async function loginAsClient(
  baseUrl: string,
  email: string,
  password: string,
): Promise<RawHttpClient> {
  const res = await fetch(`${baseUrl}/api/users/login`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email, password }),
    signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
  });

  if (!res.ok) {
    throw new Error(`Login failed for ${email}: ${res.status} ${await res.text()}`);
  }

  const data = (await res.json()) as { token: string };
  return createHttpClient(baseUrl, `JWT ${data.token}`);
}
```

Two details worth calling out, both drawn from how Payload actually authenticates requests, not assumptions:

The `users API-Key <key>` header format is collection-specific — the prefix must match the slug of whichever `auth`-enabled collection has `useAPIKey: true`, not always `users`. If your API key collection is called `service-accounts`, the header is `Authorization: service-accounts API-Key <key>`. Getting this prefix wrong is the single most common cause of a 403 that looks like a permissions bug but is really a header bug.

An earlier version of this client normalized every path to end with a trailing slash before hitting `fetch`. Don't do that — Payload's REST routes are exact matches (`/api/posts`, `/api/posts/:id`), and forcing a trailing slash on collection or document paths risks a `404` depending on how your framework's router handles it. Send paths exactly as documented.

## 3. Core Testing Use Cases

### Use Case 1: Fast CRUD, Fixture Seeding & Deterministic Teardown

In test suites, you frequently need to create temporary collections (articles, categories, products), update them, verify their presence, and ensure they are reliably deleted after tests finish.

```typescript
// File: test/crud.test.ts
import assert from "node:assert/strict";

const adminClient = createAdminClient(BASE_URL, ADMIN_API_KEY);
const editorClient = await loginAsClient(BASE_URL, "editor@example.com", "Password123!");

const uniqueSlug = `test-post-${crypto.randomUUID()}`;
let createdId: number | null = null;

try {
  const createRes = await editorClient.post<{ doc: { id: number; slug: string } }>(
    "/api/posts?locale=en-US",
    { title: "Temporary Test Post", slug: uniqueSlug, status: "draft" },
  );
  assert.equal(createRes.status, 201);
  createdId = createRes.json.doc.id;

  const readRes = await editorClient.get<{ docs: Array<{ id: number; title: string }> }>(
    `/api/posts?where[slug][equals]=${uniqueSlug}&depth=0&locale=en-US`,
  );
  assert.equal(readRes.status, 200);
  assert.equal(readRes.json.docs.length, 1);
  assert.equal(readRes.json.docs[0].title, "Temporary Test Post");

  const patchRes = await editorClient.patch<{ doc: { title: string } }>(
    `/api/posts/${createdId}?locale=en-US`,
    { title: "Updated Post Title" },
  );
  assert.equal(patchRes.status, 200);
  assert.equal(patchRes.json.doc.title, "Updated Post Title");
} finally {
  if (createdId != null) {
    const deleteRes = await adminClient.delete(`/api/posts/${createdId}`);
    assert.equal(deleteRes.status, 200);
  }
}
```

### Use Case 2: Schema Validation & Negative Testing

One of the most effective uses of REST testing is verifying that Payload's validation rules, custom validators, and required field constraints properly reject bad input with the right errors.

```typescript
// File: test/validation.test.ts
const missingTitleRes = await editorClient.post<{ errors: Array<{ message: string; path: string }> }>(
  "/api/posts?locale=en-US",
  { slug: "post-without-title" },
);
assert.equal(missingTitleRes.status, 400, "Should reject document with missing required field");
assert.ok(
  missingTitleRes.json.errors.some((err) => err.message.includes("title")),
  "Error response should reference the missing title field",
);

const invalidTypeRes = await editorClient.post("/api/products?locale=en-US", {
  name: "Widget",
  price: "not-a-number",
});
assert.equal(invalidTypeRes.status, 400, "Should reject non-numeric price");

const slug = `unique-item-${crypto.randomUUID()}`;
const first = await editorClient.post("/api/posts?locale=en-US", { title: "First", slug });
assert.equal(first.status, 201);
const firstId = (first.json as { doc: { id: number } }).doc.id;

try {
  const duplicateRes = await editorClient.post("/api/posts?locale=en-US", { title: "Second", slug });
  assert.equal(duplicateRes.status, 400, "Should reject duplicate slug");
} finally {
  await adminClient.delete(`/api/posts/${firstId}`);
}
```

Negative tests are only as good as the `where` queries and payloads you construct for them, so it helps to know the full operator set Payload's query language supports:

<table>
<thead>
<tr><th>Operator</th><th>Example</th><th>Matches</th></tr>
</thead>
<tbody>
<tr><td><code>equals</code></td><td><code>where[status][equals]=draft</code></td><td>Exact match</td></tr>
<tr><td><code>not_equals</code></td><td><code>where[status][not_equals]=published</code></td><td>Anything except the value</td></tr>
<tr><td><code>in</code> / <code>not_in</code></td><td><code>where[status][in]=draft,review</code></td><td>Value is/isn't in a comma-separated list</td></tr>
<tr><td><code>greater_than</code> / <code>less_than</code></td><td><code>where[price][greater_than]=100</code></td><td>Numeric or date comparisons</td></tr>
<tr><td><code>like</code></td><td><code>where[title][like]=widget</code></td><td>Fuzzy/partial text match</td></tr>
<tr><td><code>contains</code></td><td><code>where[tags][contains]=sale</code></td><td>Array or substring containment</td></tr>
<tr><td><code>exists</code></td><td><code>where[internalNotes][exists]=true</code></td><td>Field is present and non-null</td></tr>
</tbody>
</table>

These same operators are what you'll use to construct the `where` clauses for the bulk-operation and fixture-scoping tests later in this guide, so it's worth testing that your own custom validators respect them correctly — not just that Payload's built-in required-field checks work.

### Use Case 3: Testing Type-Level Access & Permission Probing (`GET /api/access`)

Payload's Admin UI determines whether to render navigation menus, collection list buttons ("Create New", "Delete"), and editable inputs by querying `GET /api/access`, which executes every Access Control function at the top level, across all collections, globals, and fields, and returns a full permission reflection for the current user. You can verify these permissions without rendering the UI:

```typescript
// File: test/lib/access-probe.ts
export type AccessProbe = {
  canAccessAdmin?: boolean;
  collections: Record<
    string,
    {
      create?: unknown;
      delete?: unknown;
      read?: unknown;
      update?: unknown;
      fields?: Record<string, unknown>;
    }
  >;
};

export function isAllowed(entry: unknown): boolean {
  if (entry === true) return true;
  if (entry && typeof entry === "object" && "permission" in entry) {
    return (entry as { permission?: unknown }).permission === true;
  }
  return false;
}

export function canAccessCollection(
  probe: AccessProbe,
  collection: string,
  operation: "create" | "read" | "update" | "delete",
): boolean {
  return isAllowed(probe.collections?.[collection]?.[operation]);
}

export function canEditField(probe: AccessProbe, collection: string, fieldName: string): boolean {
  const fieldEntry = probe.collections?.[collection]?.fields?.[fieldName];
  if (fieldEntry === true) return true;
  if (fieldEntry && typeof fieldEntry === "object") {
    return isAllowed((fieldEntry as Record<string, unknown>)["update"]);
  }
  return false;
}
```

```typescript
// File: test/access-probe.test.ts
const viewerClient = await loginAsClient(BASE_URL, "viewer@example.com", "Password123!");
const probe = (await viewerClient.get<AccessProbe>("/api/access")).json;

assert.equal(probe.canAccessAdmin, true, "Viewer can access Admin UI");
assert.equal(canAccessCollection(probe, "posts", "read"), true, "Viewer can see posts");
assert.equal(canAccessCollection(probe, "posts", "create"), false, "Viewer cannot create posts");
assert.equal(canAccessCollection(probe, "users", "delete"), false, "Viewer cannot delete users");
```

**Gotcha worth knowing before you write these tests**: when Payload executes your access control functions via the Access Operation (i.e. through `GET /api/access`, not against a real document), the `id`, `data`, `siblingData`, `blockData`, and `doc` arguments are all `undefined`, because there's no specific document in context. If any of your access control functions dereference `data.ownerId` or similar without a defined-check first, they'll throw or behave unpredictably during a probe test even though the same function works fine on a real request. Worse, if your access control function normally returns a `Where` query object (row-level, per-document access) rather than a plain boolean, that query is never evaluated here — Payload assumes "no access" instead. That means a type-level probe can legitimately report `read: false` for a user who actually can read some documents, just not all of them. Don't treat `GET /api/access` as the full picture for row-level access; treat it as what the Admin UI uses to decide navigation and button visibility, and pair it with Use Case 4 below when you need to verify access to a specific document.

### Use Case 4: Document-Level & Global Access Probing

`GET /api/access` only tells you what a user can do at the collection type level. Payload separately exposes per-document and per-global access checks, which is what actually answers "can this specific user read/update/delete this specific document" — the question row-level access control functions are usually written to answer.

```typescript
// File: test/document-access.test.ts
// Collection document access: GET /api/<collection-slug>/access/:id
const ownerClient = await loginAsClient(BASE_URL, "owner@example.com", "Password123!");
const otherEditorClient = await loginAsClient(BASE_URL, "other-editor@example.com", "Password123!");

const draft = await adminClient.post<{ doc: { id: number } }>("/api/posts?locale=en-US", {
  title: "Owner-only draft",
  slug: `owner-draft-${crypto.randomUUID()}`,
  owner: "owner@example.com",
});
const draftId = draft.json.doc.id;

try {
  const ownerAccess = await ownerClient.get<{ update: { permission: boolean } }>(
    `/api/posts/access/${draftId}`,
  );
  assert.equal(ownerAccess.json.update.permission, true, "Owner can update their own draft");

  const otherAccess = await otherEditorClient.get<{ update: { permission: boolean } }>(
    `/api/posts/access/${draftId}`,
  );
  assert.equal(otherAccess.json.update.permission, false, "Other editors cannot update someone else's draft");
} finally {
  await adminClient.delete(`/api/posts/${draftId}`);
}

// Global access: GET /api/<global-slug>/access
const settingsAccess = await editorClient.get<{ update: { permission: boolean } }>(
  "/api/site-settings/access",
);
assert.equal(settingsAccess.json.update.permission, false, "Editors cannot update global site settings");
```

This is the pair that closes the gap left by Use Case 3: use the type-level probe to test what shows up in the Admin UI's navigation and buttons, and use the document-level probe to test the row-level rules your access control functions actually enforce.

### Use Case 5: Field-Level Access & Field Mutation

When certain fields should only be visible or editable by admins (`internalNotes`, `approvedBy`, `commissionRate`), assert both the permission probe and a real field mutation attempt, so a passing probe test can't mask a hook or access function that silently fails to enforce the same rule on write.

```typescript
// File: test/field-access.test.ts
const editorProbe = (await editorClient.get<AccessProbe>("/api/access")).json;
assert.equal(canEditField(editorProbe, "posts", "internalNotes"), false);

const testPost = await adminClient.post<{ doc: { id: number } }>("/api/posts?locale=en-US", {
  title: "Field Test Post",
  slug: `field-test-${crypto.randomUUID()}`,
  internalNotes: "Original admin note",
});
const testId = testPost.json.doc.id;

try {
  await editorClient.patch(`/api/posts/${testId}?locale=en-US`, {
    internalNotes: "Attempted editor override",
  });

  const verify = await adminClient.get<{ doc: { internalNotes: string } }>(
    `/api/posts/${testId}?locale=en-US`,
  );
  assert.equal(
    verify.json.doc.internalNotes,
    "Original admin note",
    "Restricted field must not be updated by editor",
  );
} finally {
  await adminClient.delete(`/api/posts/${testId}`);
}
```

### Use Case 6: Bulk Operation Safety

Payload's REST API supports bulk `PATCH` and `DELETE` directly on a collection endpoint (no `:id`), taking a `where` query in the same shape a `GET` list request would use — for example `PATCH /api/posts?where[status][equals]=draft` updates every matching document in one request. This is genuinely useful for fast fixture cleanup (delete every document your test run created by tagging them with a shared prefix and deleting by `where[slug][like]=test-run-`), but it is also the single riskiest REST surface to get wrong, because a malformed `where` clause historically matched — and mutated or deleted — every document in the collection instead of failing closed. That specific bug was fixed upstream, but the failure mode it represents (an invalid filter silently expanding to "everything") is exactly the kind of thing worth a permanent regression test in your own suite, regardless of which Payload version you're on:

```typescript
// File: test/bulk-operations.test.ts
const prefix = `bulk-test-${crypto.randomUUID()}`;
const seeded: number[] = [];

try {
  for (let i = 0; i < 3; i += 1) {
    const res = await adminClient.post<{ doc: { id: number } }>("/api/posts?locale=en-US", {
      title: `Bulk fixture ${i}`,
      slug: `${prefix}-${i}`,
      status: "draft",
    });
    seeded.push(res.json.doc.id);
  }

  // Valid bulk update: only the seeded documents should be affected
  const bulkPatch = await adminClient.patch<{ docs: Array<{ id: number }> }>(
    `/api/posts?where[slug][like]=${prefix}`,
    { status: "archived" },
  );
  assert.equal(bulkPatch.status, 200);
  assert.equal(bulkPatch.json.docs.length, 3, "Bulk update should only touch the three seeded documents");

  // Regression guard: a malformed where clause must fail closed, not match everything
  const malformedBulk = await adminClient.delete("/api/posts?where[nonexistentField][equals]=x");
  assert.equal(malformedBulk.status, 400, "Invalid where clause on a bulk operation must return 400, never silently match all documents");
} finally {
  for (const id of seeded) {
    await adminClient.delete(`/api/posts/${id}`);
  }
}
```

Treat that malformed-where assertion as non-negotiable in any suite that uses bulk `PATCH`/`DELETE` for teardown — it is the difference between a fast cleanup helper and a script that can silently wipe a collection because a fixture prefix variable was `undefined`.

### Use Case 7: Drafts & Version History

If your collection has `versions: { drafts: true }` enabled, Payload maintains a separate draft state alongside the published document and exposes both a `?draft=true` query param on the standard read endpoint and a dedicated versions endpoint. Testing this is where a lot of "why does the live site show old content" bugs actually live.

```typescript
// File: test/drafts.test.ts
const created = await editorClient.post<{ doc: { id: number } }>("/api/posts?locale=en-US", {
  title: "Published Title",
  slug: `draft-test-${crypto.randomUUID()}`,
  _status: "published",
});
const postId = created.json.doc.id;

try {
  await editorClient.patch(`/api/posts/${postId}?locale=en-US&draft=true`, {
    title: "Draft-only Title",
  });

  const publicRead = await editorClient.get<{ doc: { title: string } }>(
    `/api/posts/${postId}?locale=en-US`,
  );
  assert.equal(publicRead.json.doc.title, "Published Title", "Published read must ignore unpublished draft changes");

  const draftRead = await editorClient.get<{ doc: { title: string } }>(
    `/api/posts/${postId}?locale=en-US&draft=true`,
  );
  assert.equal(draftRead.json.doc.title, "Draft-only Title", "Draft read must reflect the unpublished edit");

  const versions = await editorClient.get<{ docs: Array<{ id: string }> }>(
    `/api/posts/versions?where[parent][equals]=${postId}`,
  );
  assert.ok(versions.json.docs.length >= 2, "Version history should record both the publish and the draft edit");
} finally {
  await adminClient.delete(`/api/posts/${postId}`);
}
```

The assertion that matters most here isn't that the draft saved — it's that a normal (non-draft) read stays pinned to the last published version while the draft edit is in flight. That's the exact guarantee editorial teams depend on, and it's easy to break with a caching layer or a hook that doesn't check `_status` correctly.

### Use Case 8 (Advanced): Multi-Tier Approval & State Workflows

For applications implementing complex editorial workflows (Draft → Submitted → In Review → Approved → Published), you can test state machines and custom action endpoints cleanly over REST. Note that `approval-requests` and its `submit`/`action` endpoints below are not something Payload ships out of the box — they're a custom collection with custom endpoints you'd build for this workflow, shown here to demonstrate that REST testing scales cleanly to app-specific logic, not just Payload's built-in operations.

```mermaid
sequenceDiagram
    autonumber
    actor Editor
    actor Approver
    actor Publisher
    participant API as Payload REST API
    participant DB as Database

    Editor->>API: POST /api/articles (status: draft)
    API->>DB: Save draft
    Editor->>API: POST /api/approval-requests/submit
    API->>DB: Status -> pending_review
    Approver->>API: POST /api/approval-requests/action (action: approve)
    API->>DB: Status -> approved
    Publisher->>API: PATCH /api/articles/:id (_status: published)
    API->>DB: Status -> published
```

```typescript
// File: test/approval-workflow.test.ts
const createRes = await editorClient.post<{ doc: { id: number } }>("/api/articles?locale=en-US", {
  title: "Seasonal Promo",
  slug: `promo-${crypto.randomUUID()}`,
  status: "draft",
});
const articleId = createRes.json.doc.id;

try {
  const submitRes = await editorClient.post("/api/approval-requests/submit", {
    collection: "articles",
    documentId: articleId,
    locale: "en-US",
  });
  assert.equal(submitRes.status, 200);

  const approverClient = await loginAsClient(BASE_URL, "approver@example.com", "Password123!");
  const pendingRequests = await approverClient.get<{ docs: Array<{ id: number }> }>(
    `/api/approval-requests?where[documentId][equals]=${articleId}&where[status][equals]=pending`,
  );
  const requestId = pendingRequests.json.docs[0].id;

  const approveRes = await approverClient.post("/api/approval-requests/action", {
    requestId,
    action: "approve",
  });
  assert.equal(approveRes.status, 200);

  const publisherClient = await loginAsClient(BASE_URL, "publisher@example.com", "Password123!");
  const publishRes = await publisherClient.patch(`/api/articles/${articleId}?locale=en-US`, {
    _status: "published",
  });
  assert.equal(publishRes.status, 200);
} finally {
  await adminClient.delete(`/api/articles/${articleId}`);
}
```

## 4. Helper: Minimal Lexical Rich Text Generator

Payload CMS v3 uses Lexical by default. If your collections contain rich-text fields, use this helper to generate valid Lexical AST payloads:

```typescript
// File: test/lib/rich-text.ts
export function generateMinimalRichText(text: string) {
  return {
    root: {
      type: "root",
      format: "",
      indent: 0,
      version: 1,
      children: [
        {
          type: "paragraph",
          format: "",
          indent: 0,
          version: 1,
          children: [
            { type: "text", detail: 0, format: 0, mode: "normal", style: "", text, version: 1 },
          ],
          direction: "ltr",
        },
      ],
      direction: "ltr",
    },
  };
}
```

## 5. Media & File Uploads

Upload collections accept standard `multipart/form-data`:

```typescript
// File: test/lib/media.ts
export async function uploadTestMedia(
  baseUrl: string,
  authHeader: string,
  filename: string = "sample.png",
): Promise<number> {
  const formData = new FormData();

  const pngBytes = new Uint8Array([
    0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
    0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
    0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
    0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
    0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
    0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
  ]);

  const fileBlob = new Blob([pngBytes], { type: "image/png" });
  formData.append("file", fileBlob, filename);
  formData.append("alt", "Test media item");

  const res = await fetch(`${baseUrl}/api/media?locale=en-US`, {
    method: "POST",
    headers: { Authorization: authHeader },
    body: formData,
    signal: AbortSignal.timeout(30_000),
  });

  if (!res.ok) {
    throw new Error(`Media upload failed: ${res.status} ${await res.text()}`);
  }

  const data = (await res.json()) as { doc: { id: number } };
  return data.doc.id;
}
```

## 6. Complete, Runnable Test Suite (`node:test`)

A self-contained test suite demonstrating validation, CRUD, access permissions (type-level and document-level), and cleanup:

```typescript
// File: test/payload-rest.test.ts
import assert from "node:assert/strict";
import test from "node:test";

import { createAdminClient, loginAsClient, type RawHttpClient } from "./lib/rest-client";
import { canAccessCollection, canEditField, type AccessProbe } from "./lib/access-probe";
import { generateMinimalRichText } from "./lib/rich-text";

const BASE_URL = process.env.TEST_BASE_URL || "http://localhost:3000";
const ADMIN_API_KEY = process.env.PAYLOAD_ADMIN_API_KEY || "test-admin-key";
const DEFAULT_LOCALE = "en-US";

test("Payload REST API Test Suite", async (t) => {
  const adminClient = createAdminClient(BASE_URL, ADMIN_API_KEY);
  let editorClient: RawHttpClient;
  let viewerClient: RawHttpClient;

  await t.test("1. Setup: Authenticate Personas", async () => {
    editorClient = await loginAsClient(BASE_URL, "editor@example.com", "Password123!");
    viewerClient = await loginAsClient(BASE_URL, "viewer@example.com", "Password123!");
    assert.ok(editorClient);
    assert.ok(viewerClient);
  });

  await t.test("2. Access Probing: Verify UI Capabilities", async () => {
    const editorProbe = (await editorClient.get<AccessProbe>("/api/access")).json;
    const viewerProbe = (await viewerClient.get<AccessProbe>("/api/access")).json;

    assert.equal(canAccessCollection(editorProbe, "posts", "create"), true);
    assert.equal(canAccessCollection(viewerProbe, "posts", "create"), false);
    assert.equal(canAccessCollection(viewerProbe, "posts", "read"), true);
  });

  await t.test("3. Validation: Reject Missing Required Fields", async () => {
    const res = await editorClient.post("/api/posts?locale=" + DEFAULT_LOCALE, {
      slug: "no-title",
    });
    assert.equal(res.status, 400, "Missing title must return HTTP 400");
  });

  await t.test("4. Bulk Safety: Malformed Where Must Fail Closed", async () => {
    const res = await adminClient.delete("/api/posts?where[nonexistentField][equals]=x");
    assert.equal(res.status, 400);
  });

  await t.test("5. CRUD & Teardown: Create, Read, Update, Delete", async () => {
    const slug = `post-${crypto.randomUUID()}`;
    let docId: number | null = null;

    try {
      const createRes = await editorClient.post<{ doc: { id: number } }>(
        `/api/posts?locale=${DEFAULT_LOCALE}`,
        { title: "Programmatic Post", slug, body: generateMinimalRichText("Hello world content") },
      );
      assert.equal(createRes.status, 201);
      docId = createRes.json.doc.id;

      const patchRes = await editorClient.patch(`/api/posts/${docId}?locale=${DEFAULT_LOCALE}`, {
        title: "Updated Title",
      });
      assert.equal(patchRes.status, 200);

      const readRes = await editorClient.get<{ doc: { title: string } }>(
        `/api/posts/${docId}?locale=${DEFAULT_LOCALE}`,
      );
      assert.equal(readRes.status, 200);
      assert.equal(readRes.json.doc.title, "Updated Title");
    } finally {
      if (docId != null) {
        const delRes = await adminClient.delete(`/api/posts/${docId}`);
        assert.equal(delRes.status, 200);
      }
    }
  });
});
```

## 7. Best Practices & Rules of Thumb

1. **Pass `?locale=${DEFAULT_LOCALE}` explicitly** on all mutation (`POST`, `PATCH`) and localized read (`GET`) requests when `localization.fallback` is false, to avoid silent field drops.
2. **Use the Admin Client exclusively for setup/cleanup.** Keep assertions strictly tied to `editorClient` or `viewerClient` so a test can't pass because the admin bypassed the rule you meant to check.
3. **Clean up in `finally` blocks.** Wrap test mutations in `try/finally` so test databases stay clean even if an assertion fails midway.
4. **Use random UUIDs for slugs and prefixes**, and prefer `where[slug][like]=<prefix>` bulk deletes for teardown over deleting by ID one at a time — but always pair bulk teardown with the malformed-where regression test from Use Case 6.
5. **Enforce request timeouts.** Wrap HTTP requests in `AbortSignal.timeout(30_000)` so a hanging request fails fast instead of hanging your test run.
6. **Don't treat `GET /api/access` as row-level truth.** It reflects type-level, UI-facing permissions with `id`/`data`/`doc` undefined; use the document-level `/access/:id` endpoint (Use Case 4) whenever you're testing a rule that depends on document ownership or state.

## FAQ

**Do I need a running Next.js/Payload server to run these tests, or can I use the Local API instead?**
These patterns require a running server, since they go over real HTTP and exercise the same auth middleware and serialization the Admin UI depends on. If you only need to test hooks or business logic in isolation without HTTP overhead, Payload's Local API (`payload.find`, `payload.create`) is faster for that narrower purpose, but it skips REST-layer concerns like header parsing and status codes.

**Why does `GET /api/access` return `read: false` for a user who can clearly read some documents in the Admin UI?**
This is the access-operation gotcha from Use Case 3: when your access control function returns a `Where` query (row-level access) instead of a plain boolean, the Access Operation can't evaluate that query without a real document in context, so it defaults to "no access." Test row-level rules against `/api/<collection>/access/:id` instead.

**Can bulk `PATCH`/`DELETE` really wipe an entire collection from one bad request?**
Historically yes, if an invalid `where` clause was silently dropped instead of rejected — a bug that has since been fixed upstream. Regardless of which Payload version you're running, keep a permanent regression test asserting that a malformed `where` clause returns `400` rather than matching every document, especially if you use bulk delete for test teardown.

**How do I test that a field is read-only in the Admin UI without a browser?**
Combine the field-level access probe (`canEditField` in Use Case 3) with a real `PATCH` attempt on that field (Use Case 5). The probe tells you what the Admin UI will render as editable; the mutation attempt tells you whether the same rule is actually enforced server-side, which is the assertion that matters.

**Do I need to test drafts separately from published documents?**
Yes, if your collection has `versions: { drafts: true }` enabled. A standard read and a `?draft=true` read can diverge, and that divergence is exactly what protects your live site from showing unpublished edits — it's worth its own test rather than assuming the draft flag "just works."

## Conclusion

Browser automation earns its keep for the parts of Payload's Admin Panel that are genuinely visual — rich text editing, drag-and-drop, live preview. Everything else the Admin UI does is a reflection of what the REST API returns, from `GET /api/access` deciding which buttons render to a `400` deciding whether a form shows a validation error. Testing at that layer directly, with a plain `fetch` client and `try/finally` teardown, turns a permissions or validation suite that used to take minutes into one that takes seconds, and it lets you cover negative cases and bulk-operation safety that would be painfully slow to click through by hand.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija

## LLM Response Snippet
```json
{
  "goal": "Payload CMS REST API testing: zero-dependency Node fetch client and runnable node:test suite to verify access control, validation, drafts, and bulk-safety.",
  "responses": [
    {
      "question": "What does the article \"Payload CMS REST API Testing: Complete Guide & Examples\" cover?",
      "answer": "Payload CMS REST API testing: zero-dependency Node fetch client and runnable node:test suite to verify access control, validation, drafts, and bulk-safety."
    }
  ]
}
```