Testing Strapi v5 REST and GraphQL APIs with Jest and Supertest
Strapi v5 exposes content via REST and GraphQL APIs. Testing these APIs involves three layers: unit tests for custom service logic, integration tests against a running Strapi instance with a test database, and permission tests that verify role-based access. This guide covers all three using Jest and Supertest.
Key Takeaways
Use Strapi's test helper to boot a real instance for integration tests. Strapi provides createStrapi to boot a test instance in-process — no Docker, no separate process.
Reset the database between tests. Strapi test instances use SQLite by default; clear tables between tests to avoid cross-test state pollution.
Test permissions explicitly. Strapi's role-based access is a core feature. Write separate test cases for authenticated, unauthenticated, and different role levels.
Unit test custom services in isolation. Business logic in custom services doesn't need a Strapi instance — test it with Jest mocks.
GraphQL queries require the same auth testing as REST. The GraphQL endpoint applies Strapi's permission system identically; don't skip permission tests for GraphQL.
The Strapi Testing Landscape
Strapi v5 is a Node.js headless CMS. Testing a Strapi application covers:
- Custom services — business logic in
src/api/*/services/*.js - REST endpoints — GET/POST/PUT/DELETE on
/api/{collection-name} - GraphQL queries and mutations — via the
/graphqlendpoint - Lifecycle hooks —
beforeCreate,afterUpdate, etc. - Role permissions — Public, Authenticated, and custom roles
Installing Test Dependencies
npm install --save-dev jest supertest @strapi/strapiJest config (jest.config.js):
export default {
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.js'],
setupFilesAfterFramework: ['./tests/setup.js'],
testTimeout: 30000, // Strapi boot takes time
};Booting a Strapi Test Instance
Strapi v5 exposes a createStrapi helper for programmatic test instance creation:
// tests/helpers/strapi.js
import { createStrapi, compileStrapi } from '@strapi/strapi';
let instance;
async function setupStrapi() {
if (!instance) {
await compileStrapi();
instance = await createStrapi({
appDir: process.cwd(),
distDir: './dist',
}).load();
instance.server = instance.server.mount();
}
return instance;
}
async function teardownStrapi() {
if (instance) {
await instance.destroy();
instance = null;
}
}
export { setupStrapi, teardownStrapi };Use in test files:
// tests/api/articles.test.js
import request from 'supertest';
import { setupStrapi, teardownStrapi } from '../helpers/strapi.js';
let strapi;
let server;
beforeAll(async () => {
strapi = await setupStrapi();
server = strapi.server.httpServer;
});
afterAll(async () => {
await teardownStrapi();
});Testing REST Endpoints
Public Endpoint Test
describe('GET /api/articles', () => {
beforeEach(async () => {
// Seed test data via Strapi entityService
await strapi.entityService.create('api::article.article', {
data: {
title: 'Test Article',
slug: 'test-article',
content: 'Article content',
publishedAt: new Date().toISOString(),
},
});
});
afterEach(async () => {
// Clean up
await strapi.db.query('api::article.article').deleteMany({});
});
it('returns published articles', async () => {
const response = await request(server)
.get('/api/articles')
.expect(200);
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0].attributes.title).toBe('Test Article');
});
it('supports pagination', async () => {
// Add more articles
await strapi.entityService.create('api::article.article', {
data: { title: 'Article 2', slug: 'article-2', publishedAt: new Date().toISOString() },
});
const response = await request(server)
.get('/api/articles?pagination[page]=1&pagination[pageSize]=1')
.expect(200);
expect(response.body.data).toHaveLength(1);
expect(response.body.meta.pagination.total).toBe(2);
});
});Authentication — Creating Test JWT Tokens
// tests/helpers/auth.js
export async function getAuthToken(strapi, { email, password }) {
const response = await request(strapi.server.httpServer)
.post('/api/auth/local')
.send({ identifier: email, password });
return response.body.jwt;
}
export async function createTestUser(strapi, overrides = {}) {
const defaultUser = {
username: 'testuser',
email: 'test@example.com',
password: 'Test1234!',
confirmed: true,
blocked: false,
...overrides,
};
return strapi.plugins['users-permissions'].services.user.add(defaultUser);
}Permission Tests
describe('POST /api/articles (create)', () => {
let authToken;
beforeAll(async () => {
await createTestUser(strapi);
authToken = await getAuthToken(strapi, {
email: 'test@example.com',
password: 'Test1234!',
});
});
it('rejects unauthenticated requests', async () => {
await request(server)
.post('/api/articles')
.send({ data: { title: 'New Article', slug: 'new-article' } })
.expect(403);
});
it('allows authenticated users to create articles', async () => {
const response = await request(server)
.post('/api/articles')
.set('Authorization', `Bearer ${authToken}`)
.send({
data: {
title: 'My New Article',
slug: 'my-new-article',
content: 'Body content',
},
})
.expect(200);
expect(response.body.data.attributes.title).toBe('My New Article');
expect(response.body.data.id).toBeDefined();
});
it('rejects articles with duplicate slug', async () => {
// Create first article
await strapi.entityService.create('api::article.article', {
data: { title: 'Existing', slug: 'duplicate-slug' },
});
const response = await request(server)
.post('/api/articles')
.set('Authorization', `Bearer ${authToken}`)
.send({ data: { title: 'Duplicate', slug: 'duplicate-slug' } })
.expect(400);
expect(response.body.error.message).toContain('slug');
});
});Testing GraphQL API
Strapi's GraphQL plugin exposes a /graphql endpoint. Test it with Supertest:
describe('GraphQL API', () => {
const gql = (query, variables = {}) =>
request(server)
.post('/graphql')
.send({ query, variables });
it('queries articles', async () => {
await strapi.entityService.create('api::article.article', {
data: {
title: 'GraphQL Test',
slug: 'gql-test',
publishedAt: new Date().toISOString(),
},
});
const response = await gql(`
query {
articles {
data {
id
attributes {
title
slug
}
}
}
}
`).expect(200);
expect(response.body.errors).toBeUndefined();
const articles = response.body.data.articles.data;
expect(articles).toHaveLength(1);
expect(articles[0].attributes.title).toBe('GraphQL Test');
});
it('returns error for unauthorized mutation', async () => {
const response = await gql(`
mutation {
createArticle(data: { title: "Unauthorized" }) {
data { id }
}
}
`).expect(200); // GraphQL always returns 200
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('Forbidden');
});
it('creates article with authenticated user', async () => {
const token = await getAuthToken(strapi, {
email: 'test@example.com',
password: 'Test1234!',
});
const response = await request(server)
.post('/graphql')
.set('Authorization', `Bearer ${token}`)
.send({
query: `
mutation CreateArticle($data: ArticleInput!) {
createArticle(data: $data) {
data { id attributes { title } }
}
}
`,
variables: {
data: { title: 'Auth Article', slug: 'auth-article' },
},
})
.expect(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.createArticle.data.attributes.title).toBe('Auth Article');
});
});Testing Custom Services
Custom services in src/api/*/services/*.js can be unit tested without booting Strapi. Mock the strapi global:
// src/api/article/services/article.js
export default ({ strapi }) => ({
async findWithRelatedTags(slug) {
const article = await strapi.db.query('api::article.article').findOne({
where: { slug },
populate: { tags: true },
});
if (!article) return null;
return {
...article,
tagNames: article.tags.map(t => t.name),
};
},
});Unit test:
// tests/unit/article-service.test.js
import { jest } from '@jest/globals';
const mockStrapi = {
db: {
query: jest.fn(),
},
};
import articleServiceFactory from '../../src/api/article/services/article.js';
describe('articleService.findWithRelatedTags', () => {
const service = articleServiceFactory({ strapi: mockStrapi });
beforeEach(() => jest.clearAllMocks());
it('returns article with tag names', async () => {
const mockQuery = {
findOne: jest.fn().mockResolvedValue({
id: 1,
title: 'Test',
slug: 'test',
tags: [{ id: 1, name: 'JavaScript' }, { id: 2, name: 'Testing' }],
}),
};
mockStrapi.db.query.mockReturnValue(mockQuery);
const result = await service.findWithRelatedTags('test');
expect(result.tagNames).toEqual(['JavaScript', 'Testing']);
expect(mockQuery.findOne).toHaveBeenCalledWith({
where: { slug: 'test' },
populate: { tags: true },
});
});
it('returns null for missing article', async () => {
mockStrapi.db.query.mockReturnValue({
findOne: jest.fn().mockResolvedValue(null),
});
const result = await service.findWithRelatedTags('missing');
expect(result).toBeNull();
});
});Testing Lifecycle Hooks
Strapi lifecycle hooks run on entity events. Test them via the entity service:
// src/api/article/content-types/article/lifecycles.js
export default {
beforeCreate(event) {
const { data } = event.params;
if (!data.slug && data.title) {
data.slug = data.title.toLowerCase().replace(/\s+/g, '-');
}
},
};Integration test using the running Strapi instance:
describe('Article lifecycle hooks', () => {
afterEach(async () => {
await strapi.db.query('api::article.article').deleteMany({});
});
it('auto-generates slug from title when slug is missing', async () => {
const article = await strapi.entityService.create('api::article.article', {
data: { title: 'Auto Generated Slug Test' },
});
expect(article.slug).toBe('auto-generated-slug-test');
});
it('preserves explicit slug when provided', async () => {
const article = await strapi.entityService.create('api::article.article', {
data: { title: 'Title', slug: 'custom-slug' },
});
expect(article.slug).toBe('custom-slug');
});
});Testing File Uploads
import path from 'path';
import fs from 'fs';
describe('Media upload', () => {
it('uploads an image and returns the file object', async () => {
const token = await getAuthToken(strapi, {
email: 'test@example.com',
password: 'Test1234!',
});
const testImagePath = path.join(process.cwd(), 'tests/fixtures/test-image.png');
const response = await request(server)
.post('/api/upload')
.set('Authorization', `Bearer ${token}`)
.attach('files', testImagePath)
.expect(200);
expect(response.body).toHaveLength(1);
expect(response.body[0].mime).toBe('image/png');
expect(response.body[0].url).toBeDefined();
});
});CI Configuration
name: Strapi API Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
env:
DATABASE_CLIENT: sqlite
DATABASE_FILENAME: .tmp/test.db
JWT_SECRET: test-jwt-secret-32chars-minimum
APP_KEYS: key1,key2,key3,key4
API_TOKEN_SALT: test-api-token-salt
ADMIN_JWT_SECRET: test-admin-jwt-secret
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm run build
- run: npx jest --runInBand # run serial — Strapi instance is sharedUse --runInBand to run tests serially — a single Strapi instance is shared across the test suite, so parallel test files would conflict on the same database.
What to Test in Strapi
Cover these scenarios:
- CRUD operations — create, read, update, delete for each content type
- Filtering and pagination —
?filters,?pagination[page],?populate - Permission levels — Public access, Authenticated access, Admin access
- Validation errors — missing required fields, invalid formats, duplicate unique fields
- Lifecycle hooks — auto-population, computed fields, validation triggers
- GraphQL — query shape, mutation authorization, error responses
For end-to-end testing of a frontend that consumes the Strapi API, HelpMeTest automates browser scenarios that verify the complete user experience — from API data through to rendered UI — without requiring Playwright or Selenium setup.