PayloadCMS API Testing Guide: Collections, Access Control, and Hooks
PayloadCMS exposes a REST and GraphQL API for every collection you define. Testing it means verifying collection CRUD, enforcing access control rules, and confirming hooks fire at the right lifecycle stage. This guide walks through a complete test setup using Jest, supertest, and the Payload local API.
Key Takeaways
- Use the Payload local API in tests to avoid HTTP overhead and get direct access to the database layer.
- Access control functions are pure functions — unit-test them in isolation before wiring them to collections.
- Lifecycle hooks (beforeChange, afterRead, etc.) must be tested by observing side effects, not implementation details.
- Seed your test database before each suite and tear it down after to keep tests deterministic.
- GraphQL and REST endpoints behave differently under access restrictions — test both if you expose both.
PayloadCMS has become one of the most developer-friendly headless CMS options available. It ships with TypeScript-first configuration, a flexible collections API, fine-grained access control, and a lifecycle hook system that lets you intercept data at every stage. But all of that power comes with a testing surface that is easy to neglect. This guide covers everything you need to build a reliable test suite for a PayloadCMS-backed application.
Setting Up Your Test Environment
PayloadCMS provides a local API that bypasses HTTP entirely and calls the database layer directly. This is the right tool for unit and integration tests because it eliminates network latency and gives you access to full Payload context including req, user, and collection config.
Start by installing test dependencies:
npm install --save-dev jest ts-jest @types/jest supertest @types/supertestCreate a jest.config.ts at the root:
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
globalSetup: './tests/setup.ts',
globalTeardown: './tests/teardown.ts',
setupFilesAfterFramework: ['./tests/setupEach.ts'],
};
export default config;Your global setup initializes Payload against a test database:
// tests/setup.ts
import payload from 'payload';
import { mongooseAdapter } from '@payloadcms/db-mongodb';
import config from '../payload.config';
export default async function globalSetup() {
await payload.init({
...config,
db: mongooseAdapter({
url: process.env.MONGO_TEST_URL ?? 'mongodb://localhost:27017/payload-test',
}),
secret: 'test-secret-do-not-use-in-production',
local: true, // skip Express server
});
}Using a dedicated test database (not your development or production database) is non-negotiable. Run MongoDB locally via Docker during CI:
# docker-compose.test.yml
services:
mongo:
image: mongo:7
ports:
- "27017:27017"Testing Collection CRUD
The local API mirrors the REST API surface. Every collection operation returns a typed promise, making assertions straightforward.
// tests/posts.test.ts
import payload from 'payload';
describe('Posts collection', () => {
beforeEach(async () => {
// Clear the collection before each test
const existing = await payload.find({ collection: 'posts', limit: 100 });
for (const doc of existing.docs) {
await payload.delete({ collection: 'posts', id: doc.id });
}
});
it('creates a post and returns it with an id', async () => {
const post = await payload.create({
collection: 'posts',
data: {
title: 'Hello World',
status: 'draft',
content: [{ children: [{ text: 'First paragraph' }] }],
},
});
expect(post.id).toBeDefined();
expect(post.title).toBe('Hello World');
expect(post.status).toBe('draft');
});
it('finds posts by status', async () => {
await payload.create({ collection: 'posts', data: { title: 'Draft', status: 'draft' } });
await payload.create({ collection: 'posts', data: { title: 'Published', status: 'published' } });
const result = await payload.find({
collection: 'posts',
where: { status: { equals: 'published' } },
});
expect(result.totalDocs).toBe(1);
expect(result.docs[0].title).toBe('Published');
});
it('updates a post field', async () => {
const created = await payload.create({
collection: 'posts',
data: { title: 'Original', status: 'draft' },
});
const updated = await payload.update({
collection: 'posts',
id: created.id,
data: { title: 'Updated' },
});
expect(updated.title).toBe('Updated');
});
it('deletes a post', async () => {
const created = await payload.create({
collection: 'posts',
data: { title: 'To Delete', status: 'draft' },
});
await payload.delete({ collection: 'posts', id: created.id });
await expect(
payload.findByID({ collection: 'posts', id: created.id })
).rejects.toThrow();
});
});Testing Access Control
Access control in PayloadCMS is defined as functions on each collection. They receive a req object containing the authenticated user and return a boolean or a Where query to filter results.
Here is a typical access control definition:
// collections/Posts.ts
import type { Access } from 'payload/types';
export const publishedOrOwner: Access = ({ req: { user } }) => {
if (!user) {
return { status: { equals: 'published' } };
}
if (user.role === 'admin') {
return true;
}
return {
or: [
{ status: { equals: 'published' } },
{ author: { equals: user.id } },
],
};
};Because access control functions are pure functions, unit-test them directly:
// tests/access/publishedOrOwner.test.ts
import { publishedOrOwner } from '../../collections/Posts';
describe('publishedOrOwner access control', () => {
it('returns a where clause for unauthenticated requests', () => {
const result = publishedOrOwner({ req: { user: null } } as any);
expect(result).toEqual({ status: { equals: 'published' } });
});
it('returns true for admin users', () => {
const result = publishedOrOwner({ req: { user: { role: 'admin' } } } as any);
expect(result).toBe(true);
});
it('returns an OR clause for authenticated non-admin users', () => {
const result = publishedOrOwner({ req: { user: { id: 'user-1', role: 'editor' } } } as any);
expect(result).toEqual({
or: [
{ status: { equals: 'published' } },
{ author: { equals: 'user-1' } },
],
});
});
});Integration-test access control through the HTTP layer to confirm Payload applies the filter correctly:
// tests/access/posts-http.test.ts
import supertest from 'supertest';
import app from '../../src/server'; // your Express app with Payload
describe('POST /api/posts access control (HTTP)', () => {
let adminToken: string;
let editorToken: string;
beforeAll(async () => {
// Obtain tokens via the /api/users/login endpoint
const adminRes = await supertest(app)
.post('/api/users/login')
.send({ email: 'admin@test.com', password: 'secret' });
adminToken = adminRes.body.token;
const editorRes = await supertest(app)
.post('/api/users/login')
.send({ email: 'editor@test.com', password: 'secret' });
editorToken = editorRes.body.token;
});
it('admin can create a post', async () => {
const res = await supertest(app)
.post('/api/posts')
.set('Authorization', `JWT ${adminToken}`)
.send({ title: 'Admin Post', status: 'draft' });
expect(res.status).toBe(201);
expect(res.body.doc.id).toBeDefined();
});
it('unauthenticated request is rejected', async () => {
const res = await supertest(app)
.post('/api/posts')
.send({ title: 'Sneaky Post', status: 'published' });
expect(res.status).toBe(401);
});
it('unauthenticated GET only returns published posts', async () => {
// Seed a draft and a published post as admin
await supertest(app)
.post('/api/posts')
.set('Authorization', `JWT ${adminToken}`)
.send({ title: 'Draft Post', status: 'draft' });
await supertest(app)
.post('/api/posts')
.set('Authorization', `JWT ${adminToken}`)
.send({ title: 'Live Post', status: 'published' });
const res = await supertest(app).get('/api/posts');
expect(res.status).toBe(200);
expect(res.body.docs.every((d: any) => d.status === 'published')).toBe(true);
});
});Testing Relationships and Uploads
Relational fields and media uploads need extra attention because they involve foreign key resolution and file system operations.
it('creates a post with a related author', async () => {
const author = await payload.create({
collection: 'users',
data: { email: 'author@test.com', password: 'pass', name: 'Test Author' },
});
const post = await payload.create({
collection: 'posts',
data: {
title: 'Authored Post',
status: 'published',
author: author.id, // relationship field
},
});
const fetched = await payload.findByID({
collection: 'posts',
id: post.id,
depth: 1, // populate the author relationship
});
expect((fetched.author as any).email).toBe('author@test.com');
});For media uploads in tests, use a small fixture file and the payload.create call with the filePath option:
it('uploads an image and attaches it to a post', async () => {
const media = await payload.create({
collection: 'media',
data: { alt: 'Test image' },
filePath: './tests/fixtures/test-image.jpg',
});
expect(media.url).toContain('/media/');
const post = await payload.create({
collection: 'posts',
data: { title: 'Post with image', featuredImage: media.id },
});
expect(post.featuredImage).toBe(media.id);
});Testing GraphQL Endpoints
If you expose the GraphQL endpoint, test it with supertest and raw GraphQL queries. This catches schema mismatches that the REST tests may miss.
it('fetches published posts via GraphQL', async () => {
const res = await supertest(app)
.post('/api/graphql')
.send({
query: `
query {
Posts(where: { status: { equals: published } }) {
docs {
id
title
status
}
totalDocs
}
}
`,
});
expect(res.status).toBe(200);
expect(res.body.errors).toBeUndefined();
expect(Array.isArray(res.body.data.Posts.docs)).toBe(true);
});Continuous Integration
Run your tests in CI against a real MongoDB instance. Here is a minimal GitHub Actions workflow:
name: PayloadCMS Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
mongo:
image: mongo:7
ports:
- 27017:27017
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test
env:
MONGO_TEST_URL: mongodb://localhost:27017/payload-test
PAYLOAD_SECRET: ci-test-secretEnd-to-End Testing with HelpMeTest
Unit and integration tests cover your API contract, but they cannot verify that your frontend correctly renders CMS content, that rich text displays properly, or that draft preview links work for editors. For that layer, HelpMeTest lets you write browser-based E2E tests in plain English — no Playwright boilerplate required. Describe the user journey ("editor logs in, creates a post, publishes it, sees it on the homepage") and HelpMeTest runs it on a real browser on every deploy.
Summary
Testing a PayloadCMS application requires three layers: unit tests for access control functions and validators (pure functions, fast feedback), integration tests for collection CRUD and relationship resolution (local API or supertest), and HTTP-level tests for access enforcement and GraphQL queries. Use a dedicated test MongoDB instance, seed before each suite, and run the full suite in CI. With these patterns in place, you can refactor collection schemas and access rules with confidence.