Testing Strapi v5 REST and GraphQL APIs with Jest and Supertest

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 /graphql endpoint
  • Lifecycle hooksbeforeCreate, afterUpdate, etc.
  • Role permissions — Public, Authenticated, and custom roles

Installing Test Dependencies

npm install --save-dev jest supertest @strapi/strapi

Jest 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 shared

Use --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.

Read more