BuildWithMatija
  1. Home
  2. Blog
  3. Payload
  4. Payload CMS REST API Testing: Complete Guide & Examples

Payload CMS REST API Testing: Complete Guide & Examples

Zero-dependency Node fetch client, 9 testing use cases, and a runnable node:test suite to validate access, validation…

28th August 2026·Updated on:31st August 2026··
Payload
Payload CMS REST API Testing: Complete Guide & Examples

Evaluating Payload CMS Implementation Costs?

Scope design, content structure, and migration hours to estimate a realistic production timeline and hosting setup.

Try the Cost EstimatorGet a Second Opinion

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • 1. Why Test Payload CMS via REST API?
  • What You Can Test via REST
  • 2. Complete, Standalone REST Client
  • 3. Core Testing Use Cases
  • Use Case 1: Fast CRUD, Fixture Seeding & Deterministic Teardown
  • Use Case 2: Schema Validation & Negative Testing
  • Use Case 3: Testing Type-Level Access & Permission Probing (`GET /api/access`)
  • Use Case 4: Document-Level & Global Access Probing
  • Use Case 5: Field-Level Access & Field Mutation
  • Use Case 6: Bulk Operation Safety
  • Use Case 7: Drafts & Version History
  • Use Case 8 (Advanced): Multi-Tier Approval & State Workflows
  • 4. Helper: Minimal Lexical Rich Text Generator
  • 5. Media & File Uploads
  • 6. Complete, Runnable Test Suite (`node:test`)
  • 7. Best Practices & Rules of Thumb
  • FAQ
  • Conclusion
On this page:
  • 1. Why Test Payload CMS via REST API?
  • 2. Complete, Standalone REST Client
  • 3. Core Testing Use Cases
  • 4. Helper: Minimal Lexical Rich Text Generator
  • 5. Media & File Uploads
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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.

If you haven't set up authenticated REST requests against Payload before, start with the API key and JWT authentication guide — 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:

LayerWhat it actually verifiesWhen to use it
Browser (Playwright/Cypress)Rendered UI, client-side JS, rich text canvas, visual regressionsAdmin UI interactions a human actually clicks through: drag-and-drop, live preview, upload dropzones
REST APIHTTP-layer behavior: auth middleware, serialization, access control as the Admin UI itself calls it, status codes, real network round-tripAccess control, validation, CRUD, anything the Admin UI decides to render/hide based on a REST or GraphQL response
Local APIBusiness logic and hooks in isolation, without HTTP or auth middleware in the loopFast unit-style tests of hooks, field-level logic, or seeding large fixture sets in CI setup

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

Diagram

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:

OperatorExampleMatches
equalswhere[status][equals]=draftExact match
not_equalswhere[status][not_equals]=publishedAnything except the value
in / not_inwhere[status][in]=draft,reviewValue is/isn't in a comma-separated list
greater_than / less_thanwhere[price][greater_than]=100Numeric or date comparisons
likewhere[title][like]=widgetFuzzy/partial text match
containswhere[tags][contains]=saleArray or substring containment
existswhere[internalNotes][exists]=trueField is present and non-null

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.

Diagram
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