From 739d8e5f9ee0d9b78a8611a6c9cabd6a5e4db698 Mon Sep 17 00:00:00 2001 From: Pierre Wizla Date: Wed, 17 Sep 2025 17:35:23 +0200 Subject: [PATCH 1/7] Refine content --- docusaurus/docs/cms/testing.md | 574 +++++++++++++++++++++------------ 1 file changed, 367 insertions(+), 207 deletions(-) diff --git a/docusaurus/docs/cms/testing.md b/docusaurus/docs/cms/testing.md index 03f396eb29..bb25b0097c 100644 --- a/docusaurus/docs/cms/testing.md +++ b/docusaurus/docs/cms/testing.md @@ -1,100 +1,203 @@ --- title: Testing displayed_sidebar: cmsSidebar -description: Learn how to test your Strapi application. +description: Learn how to test your Strapi application with Jest and Supertest. tags: -- auth endpoint controller -- environment + - testing + - jest + - supertest + - unit testing + - API testing --- -# Unit testing +# Unit testing guide Testing uses Jest and Supertest with a temporary SQLite database to run unit and API checks. Walkthroughs generate a Strapi instance, verify endpoints like `/hello`, and authenticate users to ensure controllers behave as expected. -:::strapi -The Strapi blog has a tutorial on how to implement and . -::: +The present guide shows how to configure Jest in a Strapi application, mock the Strapi object to unit test plugin code, and use Supertest to exercise REST endpoints end-to-end by presenting the essential steps so you can cover your plugin business logic and HTTP API endpoints from a single test setup. -In this guide we will see how you can run basic unit tests for a Strapi application using a testing framework. +With this guide you will use as the testing framework for both unit and API tests and as the super-agent driven library for testing Node.js HTTP servers with a fluent API. -In this example we will use Testing Framework with a focus on simplicity and - Super-agent driven library for testing node.js HTTP servers using a fluent API. +:::strapi Related resources +Strapi's blog covered and with Strapi v4. +::: :::caution -Please note that this guide will not work if you are on Windows using the SQLite database due to how windows locks the SQLite file. +The present guide will not work if you are on Windows using the SQLite database due to how Windows locks the SQLite file. ::: ## Install test tools -`Jest` contains a set of guidelines or rules used for creating and designing test cases - a combination of practices and tools that are designed to help testers test more efficiently. +* `Jest` contains a set of guidelines or rules used for creating and designing test cases, a combination of practices and tools that are designed to help testers test more efficiently. +* `Supertest` allows you to test all the `api` routes as they were instances of . Pair it with Jest for HTTP assertions. +* `sqlite3` is used to create an on-disk database that is created and deleted between tests so your API tests run against isolated data. -`Supertest` allows you to test all the `api` routes as they were instances of . +1. Install all tools required throughout this guide by running the following command in a terminal: -`sqlite3` is used to create an on-disk database that is created and deleted between tests. + - + - + ```bash + yarn add jest supertest sqlite3 --dev + ``` -```bash -yarn add --dev jest supertest sqlite3 -``` + - + - + ```bash + npm install jest supertest sqlite3 --save-dev + ``` -```bash -npm install jest supertest sqlite3 --save-dev -``` + - + - +2. Add the following to the `package.json` file of your Strapi project: -Once this is done add this to `package.json` file + * add `test` command to `scripts` section: -add `test` command to `scripts` section + ```json + "scripts": { + "develop": "strapi develop", + "start": "strapi start", + "build": "strapi build", + "strapi": "strapi", + "test": "jest --forceExit --detectOpenHandles" + }, + ``` -```json - "scripts": { - "develop": "strapi develop", - "start": "strapi start", - "build": "strapi build", - "strapi": "strapi", - "test": "jest --forceExit --detectOpenHandles" - }, + * and add the following lines at the bottom of the file, to inform `Jest` not to look for tests inside folders where it shouldn't: + + ```json + "jest": { + "testPathIgnorePatterns": [ + "/node_modules/", + ".tmp", + ".cache" + ], + "testEnvironment": "node" + } + ``` + +## Mock Strapi for plugin unit tests + +Pure unit tests are ideal for Strapi plugins because they let you validate controller and service logic without starting a Strapi server. Use Jest's mocking utilities to recreate just the parts of the Strapi object and any request context that your code relies on. + +### Controller example + +Create a test file such as `./tests/todo-controller.test.js` that instantiates your controller with a mocked Strapi object and verifies every call the controller performs: + +```js title="./tests/todo-controller.test.js" +const todoController = require('../server/controllers/todo-controller'); + +describe('Todo controller', () => { + let strapi; + + beforeEach(() => { + strapi = { + plugin: jest.fn().mockReturnValue({ + service: jest.fn().mockReturnValue({ + create: jest.fn().mockReturnValue({ + data: { + name: 'test', + status: false, + }, + }), + complete: jest.fn().mockReturnValue({ + data: { + id: 1, + status: true, + }, + }), + }), + }), + }; + }); + + it('creates a todo item', async () => { + const ctx = { + request: { + body: { + name: 'test', + }, + }, + body: null, + }; + + await todoController({ strapi }).index(ctx); + + expect(ctx.body).toBe('created'); + expect(strapi.plugin('todo').service('create').create).toHaveBeenCalledTimes(1); + }); + + it('completes a todo item', async () => { + const ctx = { + request: { + body: { + id: 1, + }, + }, + body: null, + }; + + await todoController({ strapi }).complete(ctx); + + expect(ctx.body).toBe('todo completed'); + expect(strapi.plugin('todo').service('complete').complete).toHaveBeenCalledTimes(1); + }); +}); ``` -and add those lines at the bottom of file - -```json - "jest": { - "testPathIgnorePatterns": [ - "/node_modules/", - ".tmp", - ".cache" - ], - "testEnvironment": "node" - } +The `beforeEach` hook rebuilds the mock so every test starts with a clean Strapi instance. Each test prepares the `ctx` request object that the controller expects, calls the controller function, and asserts both the response and the interactions with Strapi services. + +### Service example + +Services can be tested in the same test suite or in a dedicated file by mocking only the Strapi query layer they call into. + +```js title="./tests/create-service.test.js" +const createService = require('../server/services/create'); + +describe('Create service', () => { + let strapi; + + beforeEach(() => { + strapi = { + query: jest.fn().mockReturnValue({ + create: jest.fn().mockReturnValue({ + data: { + name: 'test', + status: false, + }, + }), + }), + }; + }); + + it('persists a todo item', async () => { + const todo = await createService({ strapi }).create({ name: 'test' }); + + expect(strapi.query('plugin::todo.todo').create).toHaveBeenCalledTimes(1); + expect(todo.data.name).toBe('test'); + }); +}); ``` -Those will inform `Jest` not to look for test inside the folder where it shouldn't. +By focusing on mocking the specific Strapi APIs your code touches, you can grow these tests to cover additional branches, error cases, and services while keeping them fast and isolated. ## Set up a testing environment -Test framework must have a clean empty environment to perform valid test and also not to interfere with current database. +For API-level testing with Supertest, the framework must have a clean empty environment to perform valid tests and also not to interfere with your development database. Once `jest` is running it uses the `test` [environment](/cms/configurations/environment) (switching `NODE_ENV` to `test`) so we need to create a special environment setting for this purpose. -Create a new config for test env `./config/env/test/database.js` and add the following value `"filename": ".tmp/test.db"`β€Š-β€Šthe reason of that is that we want to have a separate sqlite database for tests, so our test will not touch real data. -This file will be temporary, each time test is finished, we will remove that file that every time tests are run on the clean database. -The whole file will look like this: -```js title="path: ./config/env/test/database.js" +Create `./config/env/test/database.js` and add the following: +```js title="./config/env/test/database.js" module.exports = ({ env }) => ({ connection: { client: 'sqlite', @@ -102,77 +205,71 @@ module.exports = ({ env }) => ({ filename: env('DATABASE_FILENAME', '.tmp/test.db'), }, useNullAsDefault: true, - debug: false }, }); ``` -## Create a Strapi instance +## Create the Strapi test instance -In order to test anything we need to have a strapi instance that runs in the testing environment, -basically we want to get instance of strapi app as object, similar like creating an instance for . +1. Create a `tests` folder in your project root. +2. Create a `tests/strapi.js` file with the following code: -These tasks require adding some files - let's create a folder `tests` where all the tests will be put and inside it, next to folder `helpers` where main Strapi helper will be in file strapi.js. + ```js title="./tests/strapi.js" + const Strapi = require('@strapi/strapi'); + const fs = require('fs'); -```js title="path: ./tests/helpers/strapi.js" -const Strapi = require("@strapi/strapi"); -const fs = require("fs"); + let instance; -let instance; + async function setupStrapi() { + if (!instance) { + await Strapi().load(); + instance = strapi; -async function setupStrapi() { - if (!instance) { - await Strapi().load(); - instance = strapi; - - await instance.server.mount(); - } - return instance; -} + await instance.server.mount(); + } + return instance; + } -async function cleanupStrapi() { - const dbSettings = strapi.config.get("database.connection"); + async function cleanupStrapi() { + const dbSettings = strapi.config.get('database.connection'); - //close server to release the db-file - await strapi.server.httpServer.close(); + // Close server to release the db-file + await strapi.server.httpServer.close(); - // close the connection to the database before deletion - await strapi.db.connection.destroy(); + // Close the connection to the database before deletion + await strapi.db.connection.destroy(); - //delete test database after all tests have completed - if (dbSettings && dbSettings.connection && dbSettings.connection.filename) { - const tmpDbFile = dbSettings.connection.filename; - if (fs.existsSync(tmpDbFile)) { - fs.unlinkSync(tmpDbFile); + // Delete test database after all tests have completed + if (dbSettings && dbSettings.connection && dbSettings.connection.filename) { + const tmpDbFile = dbSettings.connection.filename; + if (fs.existsSync(tmpDbFile)) { + fs.unlinkSync(tmpDbFile); + } + } } - } -} - -module.exports = { setupStrapi, cleanupStrapi }; -``` -## Test a Strapi instance + module.exports = { setupStrapi, cleanupStrapi }; + ``` -We need a main entry file for our tests, one that will also test our helper file. +3. Create `tests/app.test.js` with the following basic Strapi test: -```js title="path: ./tests/app.test.js" -const fs = require('fs'); -const { setupStrapi, cleanupStrapi } = require("./helpers/strapi"); + ```js title="./tests/app.test.js" + const { setupStrapi, cleanupStrapi } = require('./strapi'); -beforeAll(async () => { - await setupStrapi(); -}); + beforeAll(async () => { + await setupStrapi(); + }); -afterAll(async () => { - await cleanupStrapi(); -}); + afterAll(async () => { + await cleanupStrapi(); + }); -it("strapi is defined", () => { - expect(strapi).toBeDefined(); -}); -``` + it('strapi is defined', () => { + expect(strapi).toBeDefined(); + }); + ``` -Actually this is all we need for writing unit tests. Just run `yarn test` and see a result of your first test +This should be all you need for writing unit tests that rely on a booted Strapi instance. Run `yarn test` or `npm run test` and see the result of your first test, as in the following example: ```bash yarn run v1.13.0 @@ -183,173 +280,236 @@ $ jest Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total -Time: 4.187 s +Time: 4.187 s, estimated 6 s Ran all test suites. -✨ Done in 5.73s. +✨ Done in 6.61s. ``` -:::tip +:::caution If you receive a timeout error for Jest, please add the following line right before the `beforeAll` method in the `app.test.js` file: `jest.setTimeout(15000)` and adjust the milliseconds value as you need. ::: -## Test a basic endpoint controller +## Test a basic API endpoint :::tip -In the example we'll use and example `Hello world` `/hello` endpoint from [controllers](/cms/backend-customization/controllers) section. +In this example we'll reuse the `Hello world` `/hello` endpoint from the [controllers](/cms/backend-customization/controllers) section. ::: -Some might say that API tests are not unit but limited integration tests, regardless of nomenclature, let's continue with testing first endpoint. - -We'll test if our endpoint works properly and route `/hello` does return `Hello World` +Create `tests/hello.test.js` with the following: -Let's create a separate test file where `supertest` will be used to check if endpoint works as expected. +```js title="./tests/hello.test.js" +const fs = require('fs'); +const { setupStrapi, cleanupStrapi } = require('./strapi'); +const request = require('supertest'); -```js title="path: ./tests/hello/index.js" +beforeAll(async () => { + await setupStrapi(); +}); -const request = require('supertest'); +afterAll(async () => { + await cleanupStrapi(); +}); -it("should return hello world", async () => { +it('should return hello world', async () => { + // Get the Koa server from strapi instance await request(strapi.server.httpServer) - .get("/api/hello") + .get('/api/hello') // Make a GET request to the API .expect(200) // Expect response http code 200 .then((data) => { - expect(data.text).toBe("Hello World!"); // expect the response text + expect(data.text).toBe('Hello World!'); // Expect response content }); }); - -``` - -Then include this code to `./tests/app.test.js` at the bottom of that file - -```js -require('./hello'); ``` -and run `yarn test` which should return - -```bash -➜ my-project yarn test -yarn run v1.13.0 -$ jest --detectOpenHandles - PASS tests/app.test.js (5.742 s) - βœ“ strapi is defined (4 ms) - βœ“ should return hello world (208 ms) - -[2020-05-22T14:37:38.018Z] debug GET /hello (58 ms) 200 -Test Suites: 1 passed, 1 total -Tests: 2 passed, 2 total -Snapshots: 0 total -Time: 6.635 s, estimated 7 s -Ran all test suites. -✨ Done in 9.09s. -``` +Then run the test with `npm test` or `yarn test` command. -:::tip -If you receive an error `Jest has detected the following 1 open handles potentially keeping Jest from exiting` check `jest` version as `26.6.3` works without an issue. -::: +## Test API authentication -## Test an `auth` endpoint controller +Strapi uses a JWT token to handle authentication. We will create one user with a known username and password, and use these credentials to authenticate and get a JWT token. -In this scenario we'll test authentication login endpoint with two tests +Let's create a new test file `tests/auth.test.js`: -1. Test `/auth/local` that should login user and return `jwt` token -2. Test `/users/me` that should return users data based on `Authorization` header +```js title="./tests/auth.test.js" +const { setupStrapi, cleanupStrapi } = require('./strapi'); +const request = require('supertest'); +beforeAll(async () => { + await setupStrapi(); +}); -```js title="path: ./tests/user/index.js" -const request = require('supertest'); +afterAll(async () => { + await cleanupStrapi(); +}); -// user mock data +// User mock data const mockUserData = { - username: "tester", - email: "tester@strapi.com", - provider: "local", - password: "1234abc", + username: 'tester', + email: 'tester@strapi.com', + provider: 'local', + password: '1234abc', confirmed: true, blocked: null, }; -it("should login user and return jwt token", async () => { - /** Creates a new user and save it to the database */ - await strapi.plugins["users-permissions"].services.user.add({ +it('should login user and return JWT token', async () => { + // Creates a new user and saves it to the database + await strapi.plugins['users-permissions'].services.user.add({ ...mockUserData, }); - await request(strapi.server.httpServer) // app server is an instance of Class: http.Server - .post("/api/auth/local") - .set("accept", "application/json") - .set("Content-Type", "application/json") + await request(strapi.server.httpServer) + .post('/api/auth/local') + .set('accept', 'application/json') + .set('Content-Type', 'application/json') .send({ identifier: mockUserData.email, password: mockUserData.password, }) - .expect("Content-Type", /json/) + .expect('Content-Type', /json/) .expect(200) .then((data) => { expect(data.body.jwt).toBeDefined(); }); }); +``` -it('should return users data for authenticated user', async () => { - /** Gets the default user role */ - const defaultRole = await strapi.query('plugin::users-permissions.role').findOne({}, []); +You can use the JWT token returned to make authenticated requests to the API. Using this example, you can add more tests to validate that the authentication and authorization are working as expected. - const role = defaultRole ? defaultRole.id : null; +## Advanced API testing with user permissions - /** Creates a new user an push to database */ - const user = await strapi.plugins['users-permissions'].services.user.add({ - ...mockUserData, - username: 'tester2', - email: 'tester2@strapi.com', - role, - }); +When you create API tests, you will most likely need to test endpoints that require authentication. In the following example we will implement a helper to get and use the JWT token. - const jwt = strapi.plugins['users-permissions'].services.jwt.issue({ - id: user.id, - }); +1. Create `tests/user.test.js`: - await request(strapi.server.httpServer) // app server is an instance of Class: http.Server - .get('/api/users/me') - .set('accept', 'application/json') - .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer ' + jwt) - .expect('Content-Type', /json/) - .expect(200) - .then(data => { - expect(data.body).toBeDefined(); - expect(data.body.id).toBe(user.id); - expect(data.body.username).toBe(user.username); - expect(data.body.email).toBe(user.email); + ```js title="./tests/user.test.js" + const fs = require('fs'); + const { setupStrapi, cleanupStrapi } = require('./strapi'); + const request = require('supertest'); + + beforeAll(async () => { + await setupStrapi(); }); -}); -``` -Then include this code to `./tests/app.test.js` at the bottom of that file + afterAll(async () => { + await cleanupStrapi(); + }); -```js -require('./user'); -``` + let authenticatedUser = {}; + + // User mock data + const mockUserData = { + username: 'tester', + email: 'tester@strapi.com', + provider: 'local', + password: '1234abc', + confirmed: true, + blocked: null, + }; + + describe('User API', () => { + // Create and authenticate a user before all tests + beforeAll(async () => { + // Create user and get JWT token + const user = await strapi.plugins['users-permissions'].services.user.add({ + ...mockUserData, + }); + + const response = await request(strapi.server.httpServer) + .post('/api/auth/local') + .set('accept', 'application/json') + .set('Content-Type', 'application/json') + .send({ + identifier: mockUserData.email, + password: mockUserData.password, + }); + + authenticatedUser.jwt = response.body.jwt; + authenticatedUser.user = response.body.user; + }); + + it('should return users data for authenticated user', async () => { + await request(strapi.server.httpServer) + .get('/api/users/me') + .set('accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', 'Bearer ' + authenticatedUser.jwt) + .expect('Content-Type', /json/) + .expect(200) + .then((data) => { + expect(data.body).toBeDefined(); + expect(data.body.id).toBe(authenticatedUser.user.id); + expect(data.body.username).toBe(authenticatedUser.user.username); + expect(data.body.email).toBe(authenticatedUser.user.email); + }); + }); + }); + ``` + +2. Run all tests by adding the following to `tests/app.test.js`: + + ```js title="./tests/app.test.js" + const { setupStrapi, cleanupStrapi } = require('./strapi'); + + /** this code is called once before any test is called */ + beforeAll(async () => { + await setupStrapi(); // Singleton so it can be called many times + }); + + /** this code is called once before all the tested are finished */ + afterAll(async () => { + await cleanupStrapi(); + }); + + it('strapi is defined', () => { + expect(strapi).toBeDefined(); + }); + + require('./hello'); + require('./user'); + ``` -All the tests above should return an console output like +All the tests above should return a console output like in the following example: ```bash ➜ my-project git:(master) yarn test - yarn run v1.13.0 -$ jest --forceExit --detectOpenHandles -[2020-05-27T08:30:30.811Z] debug GET /hello (10 ms) 200 -[2020-05-27T08:30:31.864Z] debug POST /auth/local (891 ms) 200 - PASS tests/app.test.js (6.811 s) - βœ“ strapi is defined (3 ms) - βœ“ should return hello world (54 ms) - βœ“ should login user and return jwt token (1049 ms) - βœ“ should return users data for authenticated user (163 ms) +$ jest + PASS tests/app.test.js + βœ“ strapi is defined (4 ms) + βœ“ should return hello world (15 ms) + User API + βœ“ should return users data for authenticated user (18 ms) Test Suites: 1 passed, 1 total -Tests: 4 passed, 4 total +Tests: 3 passed, 3 total Snapshots: 0 total Time: 6.874 s, estimated 9 s Ran all test suites. ✨ Done in 8.40s. ``` + +## Automate tests with GitHub Actions + +You can run your Jest test suite automatically on every push and pull request with GitHub Actions. Create a `.github/workflows/test.yaml` file in your project and add the workflow below. + +```yaml title="./.github/workflows/test.yaml" +name: 'Tests' + +on: + pull_request: + push: + +jobs: + run-tests: + name: Run Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install modules + run: npm ci + - name: Run Tests + run: npm run test +``` + +Pairing continuous integration with your unit and API tests helps prevent regressions before they reach production. \ No newline at end of file From 04c8528de2bdabc04df595f56be0a1e94c3b11a7 Mon Sep 17 00:00:00 2001 From: Pierre Wizla Date: Wed, 17 Sep 2025 18:46:16 +0200 Subject: [PATCH 2/7] Adjust code examples --- docusaurus/docs/cms/testing.md | 20 +- docusaurus/static/llms-full.txt | 526 +++++++++++++++++++++----------- docusaurus/static/llms.txt | 2 +- 3 files changed, 357 insertions(+), 191 deletions(-) diff --git a/docusaurus/docs/cms/testing.md b/docusaurus/docs/cms/testing.md index bb25b0097c..7cc0a3aad7 100644 --- a/docusaurus/docs/cms/testing.md +++ b/docusaurus/docs/cms/testing.md @@ -56,21 +56,27 @@ The present guide will not work if you are on Windows using the SQLite database -2. Add the following to the `package.json` file of your Strapi project: +2. Update the `package.json` file of your Strapi project with the following: - * add `test` command to `scripts` section: + * Add a `test` command to the `scripts` section so it looks as follows: - ```json + ```json {12} "scripts": { + "build": "strapi build", + "console": "strapi console", + "deploy": "strapi deploy", + "dev": "strapi develop", "develop": "strapi develop", + "seed:example": "node ./scripts/seed.js", "start": "strapi start", - "build": "strapi build", "strapi": "strapi", + "upgrade": "npx @strapi/upgrade latest", + "upgrade:dry": "npx @strapi/upgrade latest --dry", "test": "jest --forceExit --detectOpenHandles" }, ``` - * and add the following lines at the bottom of the file, to inform `Jest` not to look for tests inside folders where it shouldn't: + * Add the following lines at the bottom of the file, to inform `Jest` not to look for tests inside folders where it shouldn't: ```json "jest": { @@ -92,7 +98,7 @@ Pure unit tests are ideal for Strapi plugins because they let you validate contr Create a test file such as `./tests/todo-controller.test.js` that instantiates your controller with a mocked Strapi object and verifies every call the controller performs: ```js title="./tests/todo-controller.test.js" -const todoController = require('../server/controllers/todo-controller'); +const todoController = require('./todo-controller'); describe('Todo controller', () => { let strapi; @@ -159,7 +165,7 @@ The `beforeEach` hook rebuilds the mock so every test starts with a clean Strapi Services can be tested in the same test suite or in a dedicated file by mocking only the Strapi query layer they call into. ```js title="./tests/create-service.test.js" -const createService = require('../server/services/create'); +const createService = require('./create-service'); describe('Create service', () => { let strapi; diff --git a/docusaurus/static/llms-full.txt b/docusaurus/static/llms-full.txt index a59c4d7ef8..5dc328407f 100644 --- a/docusaurus/static/llms-full.txt +++ b/docusaurus/static/llms-full.txt @@ -12050,54 +12050,156 @@ An example of what a template could look like is the . # Testing Source: https://docs.strapi.io/cms/testing -# Unit testing +# Unit testing guide -:::strapi -The Strapi blog has a tutorial on how to implement +The present guide shows how to configure Jest in a Strapi application, mock the Strapi object to unit test plugin code, and use Supertest to exercise REST endpoints end-to-end by presenting the essential steps so you can cover your plugin business logic and HTTP API endpoints from a single test setup. - +With this guide you will use -Once this is done add this to `package.json` file + -add `test` command to `scripts` section +2. Add the following to the `package.json` file of your Strapi project: -```json - "scripts": { - "develop": "strapi develop", - "start": "strapi start", - "build": "strapi build", - "strapi": "strapi", - "test": "jest --forceExit --detectOpenHandles" - }, + * add `test` command to `scripts` section: + + ```json + "scripts": { + "develop": "strapi develop", + "start": "strapi start", + "build": "strapi build", + "strapi": "strapi", + "test": "jest --forceExit --detectOpenHandles" + }, + ``` + + * and add the following lines at the bottom of the file, to inform `Jest` not to look for tests inside folders where it shouldn't: + + ```json + "jest": { + "testPathIgnorePatterns": [ + "/node_modules/", + ".tmp", + ".cache" + ], + "testEnvironment": "node" + } + ``` + +## Mock Strapi for plugin unit tests + +Pure unit tests are ideal for Strapi plugins because they let you validate controller and service logic without starting a Strapi server. Use Jest's mocking utilities to recreate just the parts of the Strapi object and any request context that your code relies on. + +### Controller example + +Create a test file such as `./tests/todo-controller.test.js` that instantiates your controller with a mocked Strapi object and verifies every call the controller performs: + +```js title="./tests/todo-controller.test.js" +const todoController = require('../server/controllers/todo-controller'); + +describe('Todo controller', () => { + let strapi; + + beforeEach(() => { + strapi = { + plugin: jest.fn().mockReturnValue({ + service: jest.fn().mockReturnValue({ + create: jest.fn().mockReturnValue({ + data: { + name: 'test', + status: false, + }, + }), + complete: jest.fn().mockReturnValue({ + data: { + id: 1, + status: true, + }, + }), + }), + }), + }; + }); + + it('creates a todo item', async () => { + const ctx = { + request: { + body: { + name: 'test', + }, + }, + body: null, + }; + + await todoController({ strapi }).index(ctx); + + expect(ctx.body).toBe('created'); + expect(strapi.plugin('todo').service('create').create).toHaveBeenCalledTimes(1); + }); + + it('completes a todo item', async () => { + const ctx = { + request: { + body: { + id: 1, + }, + }, + body: null, + }; + + await todoController({ strapi }).complete(ctx); + + expect(ctx.body).toBe('todo completed'); + expect(strapi.plugin('todo').service('complete').complete).toHaveBeenCalledTimes(1); + }); +}); ``` -and add those lines at the bottom of file +The `beforeEach` hook rebuilds the mock so every test starts with a clean Strapi instance. Each test prepares the `ctx` request object that the controller expects, calls the controller function, and asserts both the response and the interactions with Strapi services. -```json - "jest": { - "testPathIgnorePatterns": [ - "/node_modules/", - ".tmp", - ".cache" - ], - "testEnvironment": "node" - } +### Service example + +Services can be tested in the same test suite or in a dedicated file by mocking only the Strapi query layer they call into. + +```js title="./tests/create-service.test.js" +const createService = require('../server/services/create'); + +describe('Create service', () => { + let strapi; + + beforeEach(() => { + strapi = { + query: jest.fn().mockReturnValue({ + create: jest.fn().mockReturnValue({ + data: { + name: 'test', + status: false, + }, + }), + }), + }; + }); + + it('persists a todo item', async () => { + const todo = await createService({ strapi }).create({ name: 'test' }); + + expect(strapi.query('plugin::todo.todo').create).toHaveBeenCalledTimes(1); + expect(todo.data.name).toBe('test'); + }); +}); ``` -Those will inform `Jest` not to look for test inside the folder where it shouldn't. +By focusing on mocking the specific Strapi APIs your code touches, you can grow these tests to cover additional branches, error cases, and services while keeping them fast and isolated. ## Set up a testing environment -Test framework must have a clean empty environment to perform valid test and also not to interfere with current database. +For API-level testing with Supertest, the framework must have a clean empty environment to perform valid tests and also not to interfere with your development database. Once `jest` is running it uses the `test` [environment](/cms/configurations/environment) (switching `NODE_ENV` to `test`) so we need to create a special environment setting for this purpose. -Create a new config for test env `./config/env/test/database.js` and add the following value `"filename": ".tmp/test.db"`β€Š-β€Šthe reason of that is that we want to have a separate sqlite database for tests, so our test will not touch real data. -This file will be temporary, each time test is finished, we will remove that file that every time tests are run on the clean database. -The whole file will look like this: -```js title="path: ./config/env/test/database.js" +Create `./config/env/test/database.js` and add the following: +```js title="./config/env/test/database.js" module.exports = ({ env }) => ({ connection: { client: 'sqlite', @@ -12105,77 +12207,71 @@ module.exports = ({ env }) => ({ filename: env('DATABASE_FILENAME', '.tmp/test.db'), }, useNullAsDefault: true, - debug: false }, }); ``` -## Create a Strapi instance +## Create the Strapi test instance -In order to test anything we need to have a strapi instance that runs in the testing environment, -basically we want to get instance of strapi app as object, similar like creating an instance for . +1. Create a `tests` folder in your project root. +2. Create a `tests/strapi.js` file with the following code: -These tasks require adding some files - let's create a folder `tests` where all the tests will be put and inside it, next to folder `helpers` where main Strapi helper will be in file strapi.js. + ```js title="./tests/strapi.js" + const Strapi = require('@strapi/strapi'); + const fs = require('fs'); -```js title="path: ./tests/helpers/strapi.js" -const Strapi = require("@strapi/strapi"); -const fs = require("fs"); + let instance; -let instance; + async function setupStrapi() { + if (!instance) { + await Strapi().load(); + instance = strapi; -async function setupStrapi() { - if (!instance) { - await Strapi().load(); - instance = strapi; - - await instance.server.mount(); - } - return instance; -} + await instance.server.mount(); + } + return instance; + } -async function cleanupStrapi() { - const dbSettings = strapi.config.get("database.connection"); + async function cleanupStrapi() { + const dbSettings = strapi.config.get('database.connection'); - //close server to release the db-file - await strapi.server.httpServer.close(); + // Close server to release the db-file + await strapi.server.httpServer.close(); - // close the connection to the database before deletion - await strapi.db.connection.destroy(); + // Close the connection to the database before deletion + await strapi.db.connection.destroy(); - //delete test database after all tests have completed - if (dbSettings && dbSettings.connection && dbSettings.connection.filename) { - const tmpDbFile = dbSettings.connection.filename; - if (fs.existsSync(tmpDbFile)) { - fs.unlinkSync(tmpDbFile); + // Delete test database after all tests have completed + if (dbSettings && dbSettings.connection && dbSettings.connection.filename) { + const tmpDbFile = dbSettings.connection.filename; + if (fs.existsSync(tmpDbFile)) { + fs.unlinkSync(tmpDbFile); + } + } } - } -} - -module.exports = { setupStrapi, cleanupStrapi }; -``` -## Test a Strapi instance + module.exports = { setupStrapi, cleanupStrapi }; + ``` -We need a main entry file for our tests, one that will also test our helper file. +3. Create `tests/app.test.js` with the following basic Strapi test: -```js title="path: ./tests/app.test.js" -const fs = require('fs'); -const { setupStrapi, cleanupStrapi } = require("./helpers/strapi"); + ```js title="./tests/app.test.js" + const { setupStrapi, cleanupStrapi } = require('./strapi'); -beforeAll(async () => { - await setupStrapi(); -}); + beforeAll(async () => { + await setupStrapi(); + }); -afterAll(async () => { - await cleanupStrapi(); -}); + afterAll(async () => { + await cleanupStrapi(); + }); -it("strapi is defined", () => { - expect(strapi).toBeDefined(); -}); -``` + it('strapi is defined', () => { + expect(strapi).toBeDefined(); + }); + ``` -Actually this is all we need for writing unit tests. Just run `yarn test` and see a result of your first test +This should be all you need for writing unit tests that rely on a booted Strapi instance. Run `yarn test` or `npm run test` and see the result of your first test, as in the following example: ```bash yarn run v1.13.0 @@ -12186,176 +12282,240 @@ $ jest Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total -Time: 4.187 s +Time: 4.187 s, estimated 6 s Ran all test suites. -✨ Done in 5.73s. +✨ Done in 6.61s. ``` -:::tip +:::caution If you receive a timeout error for Jest, please add the following line right before the `beforeAll` method in the `app.test.js` file: `jest.setTimeout(15000)` and adjust the milliseconds value as you need. ::: -## Test a basic endpoint controller +## Test a basic API endpoint :::tip -In the example we'll use and example `Hello world` `/hello` endpoint from [controllers](/cms/backend-customization/controllers) section. +In this example we'll reuse the `Hello world` `/hello` endpoint from the [controllers](/cms/backend-customization/controllers) section. ::: -Some might say that API tests are not unit but limited integration tests, regardless of nomenclature, let's continue with testing first endpoint. - -We'll test if our endpoint works properly and route `/hello` does return `Hello World` +Create `tests/hello.test.js` with the following: -Let's create a separate test file where `supertest` will be used to check if endpoint works as expected. +```js title="./tests/hello.test.js" +const fs = require('fs'); +const { setupStrapi, cleanupStrapi } = require('./strapi'); +const request = require('supertest'); -```js title="path: ./tests/hello/index.js" +beforeAll(async () => { + await setupStrapi(); +}); -const request = require('supertest'); +afterAll(async () => { + await cleanupStrapi(); +}); -it("should return hello world", async () => { +it('should return hello world', async () => { + // Get the Koa server from strapi instance await request(strapi.server.httpServer) - .get("/api/hello") + .get('/api/hello') // Make a GET request to the API .expect(200) // Expect response http code 200 .then((data) => { - expect(data.text).toBe("Hello World!"); // expect the response text + expect(data.text).toBe('Hello World!'); // Expect response content }); }); - ``` -Then include this code to `./tests/app.test.js` at the bottom of that file - -```js -require('./hello'); -``` - -and run `yarn test` which should return - -```bash -➜ my-project yarn test -yarn run v1.13.0 -$ jest --detectOpenHandles - PASS tests/app.test.js (5.742 s) - βœ“ strapi is defined (4 ms) - βœ“ should return hello world (208 ms) +Then run the test with `npm test` or `yarn test` command. -[2020-05-22T14:37:38.018Z] debug GET /hello (58 ms) 200 -Test Suites: 1 passed, 1 total -Tests: 2 passed, 2 total -Snapshots: 0 total -Time: 6.635 s, estimated 7 s -Ran all test suites. -✨ Done in 9.09s. -``` +## Test API authentication -:::tip -If you receive an error `Jest has detected the following 1 open handles potentially keeping Jest from exiting` check `jest` version as `26.6.3` works without an issue. -::: +Strapi uses a JWT token to handle authentication. We will create one user with a known username and password, and use these credentials to authenticate and get a JWT token. -## Test an `auth` endpoint controller +Let's create a new test file `tests/auth.test.js`: -In this scenario we'll test authentication login endpoint with two tests +```js title="./tests/auth.test.js" +const { setupStrapi, cleanupStrapi } = require('./strapi'); +const request = require('supertest'); -1. Test `/auth/local` that should login user and return `jwt` token -2. Test `/users/me` that should return users data based on `Authorization` header +beforeAll(async () => { + await setupStrapi(); +}); -```js title="path: ./tests/user/index.js" -const request = require('supertest'); +afterAll(async () => { + await cleanupStrapi(); +}); -// user mock data +// User mock data const mockUserData = { - username: "tester", - email: "tester@strapi.com", - provider: "local", - password: "1234abc", + username: 'tester', + email: 'tester@strapi.com', + provider: 'local', + password: '1234abc', confirmed: true, blocked: null, }; -it("should login user and return jwt token", async () => { - /** Creates a new user and save it to the database */ - await strapi.plugins["users-permissions"].services.user.add({ +it('should login user and return JWT token', async () => { + // Creates a new user and saves it to the database + await strapi.plugins['users-permissions'].services.user.add({ ...mockUserData, }); - await request(strapi.server.httpServer) // app server is an instance of Class: http.Server - .post("/api/auth/local") - .set("accept", "application/json") - .set("Content-Type", "application/json") + await request(strapi.server.httpServer) + .post('/api/auth/local') + .set('accept', 'application/json') + .set('Content-Type', 'application/json') .send({ identifier: mockUserData.email, password: mockUserData.password, }) - .expect("Content-Type", /json/) + .expect('Content-Type', /json/) .expect(200) .then((data) => { expect(data.body.jwt).toBeDefined(); }); }); +``` -it('should return users data for authenticated user', async () => { - /** Gets the default user role */ - const defaultRole = await strapi.query('plugin::users-permissions.role').findOne({}, []); +You can use the JWT token returned to make authenticated requests to the API. Using this example, you can add more tests to validate that the authentication and authorization are working as expected. - const role = defaultRole ? defaultRole.id : null; +## Advanced API testing with user permissions - /** Creates a new user an push to database */ - const user = await strapi.plugins['users-permissions'].services.user.add({ - ...mockUserData, - username: 'tester2', - email: 'tester2@strapi.com', - role, - }); +When you create API tests, you will most likely need to test endpoints that require authentication. In the following example we will implement a helper to get and use the JWT token. - const jwt = strapi.plugins['users-permissions'].services.jwt.issue({ - id: user.id, - }); +1. Create `tests/user.test.js`: - await request(strapi.server.httpServer) // app server is an instance of Class: http.Server - .get('/api/users/me') - .set('accept', 'application/json') - .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer ' + jwt) - .expect('Content-Type', /json/) - .expect(200) - .then(data => { - expect(data.body).toBeDefined(); - expect(data.body.id).toBe(user.id); - expect(data.body.username).toBe(user.username); - expect(data.body.email).toBe(user.email); + ```js title="./tests/user.test.js" + const fs = require('fs'); + const { setupStrapi, cleanupStrapi } = require('./strapi'); + const request = require('supertest'); + + beforeAll(async () => { + await setupStrapi(); }); -}); -``` -Then include this code to `./tests/app.test.js` at the bottom of that file + afterAll(async () => { + await cleanupStrapi(); + }); -```js -require('./user'); -``` + let authenticatedUser = {}; + + // User mock data + const mockUserData = { + username: 'tester', + email: 'tester@strapi.com', + provider: 'local', + password: '1234abc', + confirmed: true, + blocked: null, + }; -All the tests above should return an console output like + describe('User API', () => { + // Create and authenticate a user before all tests + beforeAll(async () => { + // Create user and get JWT token + const user = await strapi.plugins['users-permissions'].services.user.add({ + ...mockUserData, + }); + + const response = await request(strapi.server.httpServer) + .post('/api/auth/local') + .set('accept', 'application/json') + .set('Content-Type', 'application/json') + .send({ + identifier: mockUserData.email, + password: mockUserData.password, + }); + + authenticatedUser.jwt = response.body.jwt; + authenticatedUser.user = response.body.user; + }); + + it('should return users data for authenticated user', async () => { + await request(strapi.server.httpServer) + .get('/api/users/me') + .set('accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', 'Bearer ' + authenticatedUser.jwt) + .expect('Content-Type', /json/) + .expect(200) + .then((data) => { + expect(data.body).toBeDefined(); + expect(data.body.id).toBe(authenticatedUser.user.id); + expect(data.body.username).toBe(authenticatedUser.user.username); + expect(data.body.email).toBe(authenticatedUser.user.email); + }); + }); + }); + ``` + +2. Run all tests by adding the following to `tests/app.test.js`: + + ```js title="./tests/app.test.js" + const { setupStrapi, cleanupStrapi } = require('./strapi'); + + /** this code is called once before any test is called */ + beforeAll(async () => { + await setupStrapi(); // Singleton so it can be called many times + }); + + /** this code is called once before all the tested are finished */ + afterAll(async () => { + await cleanupStrapi(); + }); + + it('strapi is defined', () => { + expect(strapi).toBeDefined(); + }); + + require('./hello'); + require('./user'); + ``` + +All the tests above should return a console output like in the following example: ```bash ➜ my-project git:(master) yarn test - yarn run v1.13.0 -$ jest --forceExit --detectOpenHandles -[2020-05-27T08:30:30.811Z] debug GET /hello (10 ms) 200 -[2020-05-27T08:30:31.864Z] debug POST /auth/local (891 ms) 200 - PASS tests/app.test.js (6.811 s) - βœ“ strapi is defined (3 ms) - βœ“ should return hello world (54 ms) - βœ“ should login user and return jwt token (1049 ms) - βœ“ should return users data for authenticated user (163 ms) +$ jest + PASS tests/app.test.js + βœ“ strapi is defined (4 ms) + βœ“ should return hello world (15 ms) + User API + βœ“ should return users data for authenticated user (18 ms) Test Suites: 1 passed, 1 total -Tests: 4 passed, 4 total +Tests: 3 passed, 3 total Snapshots: 0 total Time: 6.874 s, estimated 9 s Ran all test suites. ✨ Done in 8.40s. ``` +## Automate tests with GitHub Actions + +You can run your Jest test suite automatically on every push and pull request with GitHub Actions. Create a `.github/workflows/test.yaml` file in your project and add the workflow below. + +```yaml title="./.github/workflows/test.yaml" +name: 'Tests' + +on: + pull_request: + push: + +jobs: + run-tests: + name: Run Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install modules + run: npm ci + - name: Run Tests + run: npm run test +``` + +Pairing continuous integration with your unit and API tests helps prevent regressions before they reach production. + # TypeScript diff --git a/docusaurus/static/llms.txt b/docusaurus/static/llms.txt index 3f2e868c6a..a76a449584 100644 --- a/docusaurus/static/llms.txt +++ b/docusaurus/static/llms.txt @@ -120,7 +120,7 @@ - [Project structure](https://docs.strapi.io/cms/project-structure): Discover the project structure of any default Strapi application. - [Quick Start Guide - Strapi Developer Docs](https://docs.strapi.io/cms/quick-start): Get ready to get Strapi, your favorite open-source headless cms up and running in less than 3 minutes. - [Templates](https://docs.strapi.io/cms/templates): Use and create pre-made Strapi applications designed for a specific use case. -- [Testing](https://docs.strapi.io/cms/testing): Learn how to test your Strapi application. +- [Testing](https://docs.strapi.io/cms/testing): Learn how to test your Strapi application with Jest and Supertest. - [TypeScript](https://docs.strapi.io/cms/typescript): Get started with TypeScript for your Strapi application - [TypeScript development](https://docs.strapi.io/cms/typescript/development): Learn more about TypeScript usage with Strapi 5 - [TypeScript Guides](https://docs.strapi.io/cms/typescript/guides): Learn how you can leverage TypeScript while developing your Strapi project. From 4293d02bbee435be61e82bb3bedafced9f79cb20 Mon Sep 17 00:00:00 2001 From: Pierre Wizla Date: Wed, 1 Oct 2025 16:05:22 +0200 Subject: [PATCH 3/7] WIP --- docusaurus/docs/cms/testing.md | 779 +++++++++++++++++++++++--------- docusaurus/static/llms-full.txt | 744 ++++++++++++++++++++++-------- 2 files changed, 1101 insertions(+), 422 deletions(-) diff --git a/docusaurus/docs/cms/testing.md b/docusaurus/docs/cms/testing.md index 7cc0a3aad7..0fb4fd2ca4 100644 --- a/docusaurus/docs/cms/testing.md +++ b/docusaurus/docs/cms/testing.md @@ -13,10 +13,10 @@ tags: # Unit testing guide -Testing uses Jest and Supertest with a temporary SQLite database to run unit and API checks. Walkthroughs generate a Strapi instance, verify endpoints like `/hello`, and authenticate users to ensure controllers behave as expected. +Testing relies on Jest and Supertest with an in-memory SQLite database, a patched Strapi test harness that also supports TypeScript configuration files, and helpers that automatically register the `/hello` route and authenticated role during setup. -The present guide shows how to configure Jest in a Strapi application, mock the Strapi object to unit test plugin code, and use Supertest to exercise REST endpoints end-to-end by presenting the essential steps so you can cover your plugin business logic and HTTP API endpoints from a single test setup. +This guide covers how to configure Jest in a Strapi v5 application, mock the Strapi object to unit test plugin code, and use Supertest to exercise REST endpoints end-to-end. The examples below are extracted from the `strapi-unit-testing-examples` repository and reflect the test utilities currently available on the `main` branch. With this guide you will use as the testing framework for both unit and API tests and as the super-agent driven library for testing Node.js HTTP servers with a fluent API. @@ -30,64 +30,68 @@ The present guide will not work if you are on Windows using the SQLite database ## Install test tools -* `Jest` contains a set of guidelines or rules used for creating and designing test cases, a combination of practices and tools that are designed to help testers test more efficiently. -* `Supertest` allows you to test all the `api` routes as they were instances of . Pair it with Jest for HTTP assertions. -* `sqlite3` is used to create an on-disk database that is created and deleted between tests so your API tests run against isolated data. +* `Jest` provides the test runner and assertion utilities. +* `Supertest` allows you to test all the `api` routes as they were instances of . +* `sqlite3` creates an in-memory database for fast isolation between tests. +* `typescript` enables the test harness to load TypeScript Strapi configuration files when present. -1. Install all tools required throughout this guide by running the following command in a terminal: +Install all tools required throughout this guide by running the following command in a terminal: - + - + - ```bash - yarn add jest supertest sqlite3 --dev - ``` +```bash +yarn add jest supertest sqlite3 typescript --dev +``` - + - + - ```bash - npm install jest supertest sqlite3 --save-dev - ``` +```bash +npm install jest supertest sqlite3 typescript --save-dev +``` - + - + -2. Update the `package.json` file of your Strapi project with the following: +Update the `package.json` file of your Strapi project with the following: - * Add a `test` command to the `scripts` section so it looks as follows: +* Add a `test` command to the `scripts` section so it looks as follows: - ```json {12} - "scripts": { - "build": "strapi build", - "console": "strapi console", - "deploy": "strapi deploy", - "dev": "strapi develop", - "develop": "strapi develop", - "seed:example": "node ./scripts/seed.js", - "start": "strapi start", - "strapi": "strapi", - "upgrade": "npx @strapi/upgrade latest", - "upgrade:dry": "npx @strapi/upgrade latest --dry", - "test": "jest --forceExit --detectOpenHandles" - }, - ``` - - * Add the following lines at the bottom of the file, to inform `Jest` not to look for tests inside folders where it shouldn't: - - ```json - "jest": { - "testPathIgnorePatterns": [ - "/node_modules/", - ".tmp", - ".cache" - ], - "testEnvironment": "node" - } - ``` + ```json {12} + "scripts": { + "build": "strapi build", + "console": "strapi console", + "deploy": "strapi deploy", + "dev": "strapi develop", + "develop": "strapi develop", + "seed:example": "node ./scripts/seed.js", + "start": "strapi start", + "strapi": "strapi", + "upgrade": "npx @strapi/upgrade latest", + "upgrade:dry": "npx @strapi/upgrade latest --dry", + "test": "jest --forceExit --detectOpenHandles" + }, + ``` + +* Configure Jest at the bottom of the file to ignore Strapi build artifacts and to map any root-level modules you import from tests: + + ```json + "jest": { + "testPathIgnorePatterns": [ + "/node_modules/", + ".tmp", + ".cache" + ], + "testEnvironment": "node", + "moduleNameMapper": { + "^/create-service$": "/create-service" + } + } + ``` ## Mock Strapi for plugin unit tests @@ -198,114 +202,495 @@ By focusing on mocking the specific Strapi APIs your code touches, you can grow For API-level testing with Supertest, the framework must have a clean empty environment to perform valid tests and also not to interfere with your development database. -Once `jest` is running it uses the `test` [environment](/cms/configurations/environment) (switching `NODE_ENV` to `test`) -so we need to create a special environment setting for this purpose. - -Create `./config/env/test/database.js` and add the following: +Once `jest` is running it uses the `test` [environment](/cms/configurations/environment), so create `./config/env/test/database.js` with the following: ```js title="./config/env/test/database.js" -module.exports = ({ env }) => ({ - connection: { - client: 'sqlite', +module.exports = ({ env }) => { + const filename = env('DATABASE_FILENAME', '.tmp/test.db'); + const rawClient = env('DATABASE_CLIENT', 'sqlite'); + const client = ['sqlite3', 'better-sqlite3'].includes(rawClient) ? 'sqlite' : rawClient; + + return { connection: { - filename: env('DATABASE_FILENAME', '.tmp/test.db'), + client, + connection: { + filename, + }, + useNullAsDefault: true, }, - useNullAsDefault: true, - }, -}); + }; +}; ``` -## Create the Strapi test instance +This configuration mirrors the defaults used in production but converts `better-sqlite3` to the `sqlite` client Strapi expects. -1. Create a `tests` folder in your project root. -2. Create a `tests/strapi.js` file with the following code: +## Create the Strapi test harness - ```js title="./tests/strapi.js" - const Strapi = require('@strapi/strapi'); - const fs = require('fs'); +Create a `tests` folder in your project root and add the utilities below. The harness now handles several Strapi v5 requirements: - let instance; +* It registers a fallback TypeScript loader so TypeScript configuration files (`server.ts`, `database.ts`, etc.) can be consumed by Jest. +* It patches Strapi's configuration loader to recognise `.ts`, `.cts`, and `.mts` files while preserving warnings for unsupported filenames. +* It normalises database client selection for sqlite, MySQL, and PostgreSQL in tests. +* It automatically exposes a `/hello` content API route backed by the `hello` service and ensures the authenticated role is applied to newly created users. - async function setupStrapi() { - if (!instance) { - await Strapi().load(); - instance = strapi; +Create `tests/ts-compiler-options.js`: - await instance.server.mount(); - } - return instance; +```js title="./tests/ts-compiler-options.js" +const fs = require('fs'); +const path = require('path'); +const ts = require('typescript'); + +const projectRoot = path.resolve(__dirname, '..'); +const tsconfigPath = path.join(projectRoot, 'tsconfig.json'); + +const baseCompilerOptions = { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2019, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + esModuleInterop: true, + jsx: ts.JsxEmit.React, +}; + +const loadCompilerOptions = () => { + let options = { ...baseCompilerOptions }; + + if (!fs.existsSync(tsconfigPath)) { + return options; + } + + try { + const tsconfigContent = fs.readFileSync(tsconfigPath, 'utf8'); + const parsed = ts.parseConfigFileTextToJson(tsconfigPath, tsconfigContent); + + if (!parsed.error && parsed.config && parsed.config.compilerOptions) { + options = { + ...options, + ...parsed.config.compilerOptions, + }; } + } catch (error) { + // Ignore tsconfig parsing errors and fallback to defaults + } - async function cleanupStrapi() { - const dbSettings = strapi.config.get('database.connection'); + return options; +}; + +module.exports = { + compilerOptions: loadCompilerOptions(), + loadCompilerOptions, +}; +``` + +Create `tests/ts-runtime.js`: + +```js title="./tests/ts-runtime.js" +const Module = require('module'); +const { compilerOptions } = require('./ts-compiler-options'); +const fs = require('fs'); +const ts = require('typescript'); + +const extensions = Module._extensions; + +if (!extensions['.ts']) { + extensions['.ts'] = function compileTS(module, filename) { + const source = fs.readFileSync(filename, 'utf8'); + const output = ts.transpileModule(source, { + compilerOptions, + fileName: filename, + reportDiagnostics: false, + }); + + return module._compile(output.outputText, filename); + }; +} - // Close server to release the db-file - await strapi.server.httpServer.close(); +if (!extensions['.tsx']) { + extensions['.tsx'] = extensions['.ts']; +} + +module.exports = { + compilerOptions, +}; +``` + +Finally, create `tests/strapi.js`: + +```js title="./tests/strapi.js" +try { + require('ts-node/register/transpile-only'); +} catch (err) { + try { + require('@strapi/typescript-utils/register'); + } catch (strapiRegisterError) { + require('./ts-runtime'); + } +} + +const fs = require('fs'); +const path = require('path'); +const Module = require('module'); +const ts = require('typescript'); +const databaseConnection = require('@strapi/database/dist/connection.js'); +const knexFactory = require('knex'); +const strapiCoreRoot = path.dirname(require.resolve('@strapi/core/package.json')); +const loadConfigFilePath = path.join(strapiCoreRoot, 'dist', 'utils', 'load-config-file.js'); +const loadConfigFileModule = require(loadConfigFilePath); +const { compilerOptions: baseCompilerOptions } = require('./ts-compiler-options'); + +if (!loadConfigFileModule.loadConfigFile.__tsRuntimePatched) { + const strapiUtils = require('@strapi/utils'); + const originalLoadConfigFile = loadConfigFileModule.loadConfigFile; + + const loadTypeScriptConfig = (file) => { + const source = fs.readFileSync(file, 'utf8'); + const options = { + ...baseCompilerOptions, + module: ts.ModuleKind.CommonJS, + }; + + const output = ts.transpileModule(source, { + compilerOptions: options, + fileName: file, + reportDiagnostics: false, + }); - // Close the connection to the database before deletion - await strapi.db.connection.destroy(); + const moduleInstance = new Module(file); + moduleInstance.filename = file; + moduleInstance.paths = Module._nodeModulePaths(path.dirname(file)); + moduleInstance._compile(output.outputText, file); - // Delete test database after all tests have completed - if (dbSettings && dbSettings.connection && dbSettings.connection.filename) { - const tmpDbFile = dbSettings.connection.filename; - if (fs.existsSync(tmpDbFile)) { - fs.unlinkSync(tmpDbFile); + const exported = moduleInstance.exports; + const resolved = exported && exported.__esModule ? exported.default : exported; + + if (typeof resolved === 'function') { + return resolved({ env: strapiUtils.env }); + } + + return resolved; + }; + + const patchedLoadConfigFile = (file) => { + const extension = path.extname(file).toLowerCase(); + + if (extension === '.ts' || extension === '.cts' || extension === '.mts') { + return loadTypeScriptConfig(file); + } + + return originalLoadConfigFile(file); + }; + + patchedLoadConfigFile.__tsRuntimePatched = true; + loadConfigFileModule.loadConfigFile = patchedLoadConfigFile; + require.cache[loadConfigFilePath].exports = loadConfigFileModule; +} + +const configLoaderPath = path.join(strapiCoreRoot, 'dist', 'configuration', 'config-loader.js'); +const originalLoadConfigDir = require(configLoaderPath); +const validExtensions = ['.js', '.json', '.ts', '.cts', '.mts']; +const mistakenFilenames = { + middleware: 'middlewares', + plugin: 'plugins', +}; +const restrictedFilenames = [ + 'uuid', + 'hosting', + 'license', + 'enforce', + 'disable', + 'enable', + 'telemetry', + 'strapi', + 'internal', + 'launchedAt', + 'serveAdminPanel', + 'autoReload', + 'environment', + 'packageJsonStrapi', + 'info', + 'dirs', + ...Object.keys(mistakenFilenames), +]; +const strapiConfigFilenames = ['admin', 'server', 'api', 'database', 'middlewares', 'plugins', 'features']; + +if (!originalLoadConfigDir.__tsRuntimePatched) { + const patchedLoadConfigDir = (dir) => { + if (!fs.existsSync(dir)) { + return {}; + } + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const seenFilenames = new Set(); + + const configFiles = entries.reduce((acc, entry) => { + if (!entry.isFile()) { + return acc; + } + + const extension = path.extname(entry.name); + const extensionLower = extension.toLowerCase(); + const baseName = path.basename(entry.name, extension); + const baseNameLower = baseName.toLowerCase(); + + if (!validExtensions.includes(extensionLower)) { + console.warn(`Config file not loaded, extension must be one of ${validExtensions.join(',')}): ${entry.name}`); + return acc; + } + + if (restrictedFilenames.includes(baseNameLower)) { + console.warn(`Config file not loaded, restricted filename: ${entry.name}`); + if (baseNameLower in mistakenFilenames) { + console.log(`Did you mean ${mistakenFilenames[baseNameLower]}?`); } + return acc; + } + + const restrictedPrefix = [...restrictedFilenames, ...strapiConfigFilenames].find( + (restrictedName) => restrictedName.startsWith(baseNameLower) && restrictedName !== baseNameLower + ); + + if (restrictedPrefix) { + console.warn(`Config file not loaded, filename cannot start with ${restrictedPrefix}: ${entry.name}`); + return acc; } + + if (seenFilenames.has(baseNameLower)) { + console.warn(`Config file not loaded, case-insensitive name matches other config file: ${entry.name}`); + return acc; + } + + seenFilenames.add(baseNameLower); + acc.push(entry); + return acc; + }, []); + + return configFiles.reduce((acc, entry) => { + const extension = path.extname(entry.name); + const key = path.basename(entry.name, extension); + const filePath = path.resolve(dir, entry.name); + + acc[key] = loadConfigFileModule.loadConfigFile(filePath); + return acc; + }, {}); + }; + + patchedLoadConfigDir.__tsRuntimePatched = true; + require.cache[configLoaderPath].exports = patchedLoadConfigDir; +} + +databaseConnection.createConnection = (() => { + const clientMap = { + sqlite: 'sqlite3', + mysql: 'mysql2', + postgres: 'pg', + }; + + return (userConfig, strapiConfig) => { + if (!clientMap[userConfig.client]) { + throw new Error(`Unsupported database client ${userConfig.client}`); } - module.exports = { setupStrapi, cleanupStrapi }; - ``` + const knexConfig = { + ...userConfig, + client: clientMap[userConfig.client], + }; -3. Create `tests/app.test.js` with the following basic Strapi test: + if (strapiConfig?.pool?.afterCreate) { + knexConfig.pool = knexConfig.pool || {}; - ```js title="./tests/app.test.js" - const { setupStrapi, cleanupStrapi } = require('./strapi'); + const userAfterCreate = knexConfig.pool?.afterCreate; + const strapiAfterCreate = strapiConfig.pool.afterCreate; - beforeAll(async () => { - await setupStrapi(); - }); + knexConfig.pool.afterCreate = (conn, done) => { + strapiAfterCreate(conn, (err, nativeConn) => { + if (err) { + return done(err, nativeConn); + } - afterAll(async () => { - await cleanupStrapi(); - }); + if (userAfterCreate) { + return userAfterCreate(nativeConn, done); + } - it('strapi is defined', () => { - expect(strapi).toBeDefined(); - }); - ``` + return done(null, nativeConn); + }); + }; + } + + return knexFactory(knexConfig); + }; +})(); + +if (typeof jest !== 'undefined' && typeof jest.setTimeout === 'function') { + jest.setTimeout(30000); +} + +const { createStrapi } = require('@strapi/strapi'); + +process.env.NODE_ENV = process.env.NODE_ENV || 'test'; +process.env.APP_KEYS = process.env.APP_KEYS || 'testKeyOne,testKeyTwo'; +process.env.API_TOKEN_SALT = process.env.API_TOKEN_SALT || 'test-api-token-salt'; +process.env.ADMIN_JWT_SECRET = process.env.ADMIN_JWT_SECRET || 'test-admin-jwt-secret'; +process.env.TRANSFER_TOKEN_SALT = process.env.TRANSFER_TOKEN_SALT || 'test-transfer-token-salt'; +process.env.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || '0123456789abcdef0123456789abcdef'; +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret'; +process.env.DATABASE_CLIENT = process.env.DATABASE_CLIENT || 'sqlite'; +process.env.DATABASE_FILENAME = process.env.DATABASE_FILENAME || ':memory:'; +process.env.STRAPI_DISABLE_CRON = 'true'; +process.env.PORT = process.env.PORT || '0'; + +const databaseClient = process.env.DATABASE_CLIENT || 'sqlite'; +const clientMap = { + sqlite: 'sqlite3', + 'better-sqlite3': 'sqlite3', + mysql: 'mysql2', + postgres: 'pg', +}; + +const driver = clientMap[databaseClient]; + +if (!driver) { + throw new Error(`Unsupported database client "${databaseClient}".`); +} + +if (databaseClient === 'better-sqlite3') { + process.env.DATABASE_CLIENT = 'sqlite'; +} + +require(driver); + +let instance; + +async function setupStrapi() { + if (!instance) { + instance = await createStrapi().load(); + const contentApi = instance.server?.api?.('content-api'); + if (contentApi && !instance.__helloRouteRegistered) { + const createHelloService = require(path.join( + __dirname, + '..', + 'src', + 'api', + 'hello', + 'services', + 'hello' + )); + const helloService = createHelloService({ strapi: instance }); + + contentApi.routes([ + { + method: 'GET', + path: '/hello', + handler: async (ctx) => { + ctx.body = await helloService.getMessage(); + }, + config: { + auth: false, + }, + }, + ]); -This should be all you need for writing unit tests that rely on a booted Strapi instance. Run `yarn test` or `npm run test` and see the result of your first test, as in the following example: + contentApi.mount(instance.server.router); + instance.__helloRouteRegistered = true; + } + await instance.start(); + global.strapi = instance; + + const userService = strapi.plugins['users-permissions']?.services?.user; + if (userService) { + const originalAdd = userService.add.bind(userService); + + userService.add = async (values) => { + const data = { ...values }; + + if (!data.role) { + const defaultRole = await strapi.db + .query('plugin::users-permissions.role') + .findOne({ where: { type: 'authenticated' } }); + + if (defaultRole) { + data.role = defaultRole.id; + } + } + + return originalAdd(data); + }; + } + } + return instance; +} + +async function cleanupStrapi() { + if (!global.strapi) { + return; + } + + const dbSettings = strapi.config.get('database.connection'); + + await strapi.server.httpServer.close(); + await strapi.db.connection.destroy(); + + if (typeof strapi.destroy === 'function') { + await strapi.destroy(); + } + + if (dbSettings && dbSettings.connection && dbSettings.connection.filename) { + const tmpDbFile = dbSettings.connection.filename; + if (fs.existsSync(tmpDbFile)) { + fs.unlinkSync(tmpDbFile); + } + } +} + +module.exports = { setupStrapi, cleanupStrapi }; +``` + +## Create smoke tests + +With the harness in place you can confirm Strapi boots correctly by adding a minimal Jest suite. + +Create `tests/app.test.js`: + +```js title="./tests/app.test.js" +const { setupStrapi, cleanupStrapi } = require('./strapi'); + +/** this code is called once before any test is called */ +beforeAll(async () => { + await setupStrapi(); // Singleton so it can be called many times +}); + +/** this code is called once before all the tests are finished */ +afterAll(async () => { + await cleanupStrapi(); +}); + +it('strapi is defined', () => { + expect(strapi).toBeDefined(); +}); + +require('./hello'); +require('./user'); +``` + +Running `yarn test` or `npm run test` should now yield: ```bash -yarn run v1.13.0 +yarn test $ jest PASS tests/app.test.js - βœ“ strapi is defined (2 ms) - -Test Suites: 1 passed, 1 total -Tests: 1 passed, 1 total -Snapshots: 0 total -Time: 4.187 s, estimated 6 s -Ran all test suites. -✨ Done in 6.61s. + βœ“ strapi is defined (4 ms) + βœ“ should return hello world (15 ms) + User API + βœ“ should return users data for authenticated user (18 ms) ``` :::caution -If you receive a timeout error for Jest, please add the following line right before the `beforeAll` method in the `app.test.js` file: `jest.setTimeout(15000)` and adjust the milliseconds value as you need. +If you receive a timeout error for Jest, increase the timeout by calling `jest.setTimeout(30000)` in `tests/strapi.js` or at the top of your test file. ::: ## Test a basic API endpoint -:::tip -In this example we'll reuse the `Hello world` `/hello` endpoint from the [controllers](/cms/backend-customization/controllers) section. - -::: - Create `tests/hello.test.js` with the following: ```js title="./tests/hello.test.js" -const fs = require('fs'); const { setupStrapi, cleanupStrapi } = require('./strapi'); const request = require('supertest'); @@ -318,23 +703,22 @@ afterAll(async () => { }); it('should return hello world', async () => { - // Get the Koa server from strapi instance await request(strapi.server.httpServer) - .get('/api/hello') // Make a GET request to the API - .expect(200) // Expect response http code 200 + .get('/api/hello') + .expect(200) .then((data) => { - expect(data.text).toBe('Hello World!'); // Expect response content + expect(data.text).toBe('Hello World!'); }); }); ``` -Then run the test with `npm test` or `yarn test` command. +The harness registers the `/api/hello` route automatically, so the test only has to make the request. ## Test API authentication -Strapi uses a JWT token to handle authentication. We will create one user with a known username and password, and use these credentials to authenticate and get a JWT token. +Strapi uses a JWT token to handle authentication. We will create one user with a known username and password, and use these credentials to authenticate and get a JWT token. The patched `user.add` helper in the harness ensures the authenticated role is applied automatically. -Let's create a new test file `tests/auth.test.js`: +Create `tests/auth.test.js`: ```js title="./tests/auth.test.js" const { setupStrapi, cleanupStrapi } = require('./strapi'); @@ -359,7 +743,6 @@ const mockUserData = { }; it('should login user and return JWT token', async () => { - // Creates a new user and saves it to the database await strapi.plugins['users-permissions'].services.user.add({ ...mockUserData, }); @@ -386,113 +769,67 @@ You can use the JWT token returned to make authenticated requests to the API. Us When you create API tests, you will most likely need to test endpoints that require authentication. In the following example we will implement a helper to get and use the JWT token. -1. Create `tests/user.test.js`: - - ```js title="./tests/user.test.js" - const fs = require('fs'); - const { setupStrapi, cleanupStrapi } = require('./strapi'); - const request = require('supertest'); - - beforeAll(async () => { - await setupStrapi(); - }); - - afterAll(async () => { - await cleanupStrapi(); - }); - - let authenticatedUser = {}; - - // User mock data - const mockUserData = { - username: 'tester', - email: 'tester@strapi.com', - provider: 'local', - password: '1234abc', - confirmed: true, - blocked: null, - }; - - describe('User API', () => { - // Create and authenticate a user before all tests - beforeAll(async () => { - // Create user and get JWT token - const user = await strapi.plugins['users-permissions'].services.user.add({ - ...mockUserData, - }); +Create `tests/user.test.js`: - const response = await request(strapi.server.httpServer) - .post('/api/auth/local') - .set('accept', 'application/json') - .set('Content-Type', 'application/json') - .send({ - identifier: mockUserData.email, - password: mockUserData.password, - }); - - authenticatedUser.jwt = response.body.jwt; - authenticatedUser.user = response.body.user; - }); - - it('should return users data for authenticated user', async () => { - await request(strapi.server.httpServer) - .get('/api/users/me') - .set('accept', 'application/json') - .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer ' + authenticatedUser.jwt) - .expect('Content-Type', /json/) - .expect(200) - .then((data) => { - expect(data.body).toBeDefined(); - expect(data.body.id).toBe(authenticatedUser.user.id); - expect(data.body.username).toBe(authenticatedUser.user.username); - expect(data.body.email).toBe(authenticatedUser.user.email); - }); - }); - }); - ``` +```js title="./tests/user.test.js" +const { setupStrapi, cleanupStrapi } = require('./strapi'); +const request = require('supertest'); -2. Run all tests by adding the following to `tests/app.test.js`: +beforeAll(async () => { + await setupStrapi(); +}); - ```js title="./tests/app.test.js" - const { setupStrapi, cleanupStrapi } = require('./strapi'); +afterAll(async () => { + await cleanupStrapi(); +}); - /** this code is called once before any test is called */ - beforeAll(async () => { - await setupStrapi(); // Singleton so it can be called many times - }); +let authenticatedUser = {}; - /** this code is called once before all the tested are finished */ - afterAll(async () => { - await cleanupStrapi(); - }); +// User mock data +const mockUserData = { + username: 'tester', + email: 'tester@strapi.com', + provider: 'local', + password: '1234abc', + confirmed: true, + blocked: null, +}; - it('strapi is defined', () => { - expect(strapi).toBeDefined(); +describe('User API', () => { + beforeAll(async () => { + await strapi.plugins['users-permissions'].services.user.add({ + ...mockUserData, }); - require('./hello'); - require('./user'); - ``` - -All the tests above should return a console output like in the following example: + const response = await request(strapi.server.httpServer) + .post('/api/auth/local') + .set('accept', 'application/json') + .set('Content-Type', 'application/json') + .send({ + identifier: mockUserData.email, + password: mockUserData.password, + }); -```bash -➜ my-project git:(master) yarn test -yarn run v1.13.0 -$ jest - PASS tests/app.test.js - βœ“ strapi is defined (4 ms) - βœ“ should return hello world (15 ms) - User API - βœ“ should return users data for authenticated user (18 ms) + authenticatedUser.jwt = response.body.jwt; + authenticatedUser.user = response.body.user; + }); -Test Suites: 1 passed, 1 total -Tests: 3 passed, 3 total -Snapshots: 0 total -Time: 6.874 s, estimated 9 s -Ran all test suites. -✨ Done in 8.40s. + it('should return users data for authenticated user', async () => { + await request(strapi.server.httpServer) + .get('/api/users/me') + .set('accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', 'Bearer ' + authenticatedUser.jwt) + .expect('Content-Type', /json/) + .expect(200) + .then((data) => { + expect(data.body).toBeDefined(); + expect(data.body.id).toBe(authenticatedUser.user.id); + expect(data.body.username).toBe(authenticatedUser.user.username); + expect(data.body.email).toBe(authenticatedUser.user.email); + }); + }); +}); ``` ## Automate tests with GitHub Actions diff --git a/docusaurus/static/llms-full.txt b/docusaurus/static/llms-full.txt index 5dc328407f..f0716d3841 100644 --- a/docusaurus/static/llms-full.txt +++ b/docusaurus/static/llms-full.txt @@ -12052,38 +12052,47 @@ Source: https://docs.strapi.io/cms/testing # Unit testing guide -The present guide shows how to configure Jest in a Strapi application, mock the Strapi object to unit test plugin code, and use Supertest to exercise REST endpoints end-to-end by presenting the essential steps so you can cover your plugin business logic and HTTP API endpoints from a single test setup. +This guide covers how to configure Jest in a Strapi v5 application, mock the Strapi object to unit test plugin code, and use Supertest to exercise REST endpoints end-to-end. The examples below are extracted from the `strapi-unit-testing-examples` repository and reflect the test utilities currently available on the `main` branch. With this guide you will use - + -2. Add the following to the `package.json` file of your Strapi project: +Update the `package.json` file of your Strapi project with the following: - * add `test` command to `scripts` section: +* Add a `test` command to the `scripts` section so it looks as follows: - ```json - "scripts": { - "develop": "strapi develop", - "start": "strapi start", - "build": "strapi build", - "strapi": "strapi", - "test": "jest --forceExit --detectOpenHandles" - }, - ``` - - * and add the following lines at the bottom of the file, to inform `Jest` not to look for tests inside folders where it shouldn't: - - ```json - "jest": { - "testPathIgnorePatterns": [ - "/node_modules/", - ".tmp", - ".cache" - ], - "testEnvironment": "node" - } - ``` + ```json {12} + "scripts": { + "build": "strapi build", + "console": "strapi console", + "deploy": "strapi deploy", + "dev": "strapi develop", + "develop": "strapi develop", + "seed:example": "node ./scripts/seed.js", + "start": "strapi start", + "strapi": "strapi", + "upgrade": "npx @strapi/upgrade latest", + "upgrade:dry": "npx @strapi/upgrade latest --dry", + "test": "jest --forceExit --detectOpenHandles" + }, + ``` + +* Configure Jest at the bottom of the file to ignore Strapi build artifacts and to map any root-level modules you import from tests: + + ```json + "jest": { + "testPathIgnorePatterns": [ + "/node_modules/", + ".tmp", + ".cache" + ], + "testEnvironment": "node", + "moduleNameMapper": { + "^/create-service$": "/create-service" + } + } + ``` ## Mock Strapi for plugin unit tests @@ -12094,7 +12103,7 @@ Pure unit tests are ideal for Strapi plugins because they let you validate contr Create a test file such as `./tests/todo-controller.test.js` that instantiates your controller with a mocked Strapi object and verifies every call the controller performs: ```js title="./tests/todo-controller.test.js" -const todoController = require('../server/controllers/todo-controller'); +const todoController = require('./todo-controller'); describe('Todo controller', () => { let strapi; @@ -12161,7 +12170,7 @@ The `beforeEach` hook rebuilds the mock so every test starts with a clean Strapi Services can be tested in the same test suite or in a dedicated file by mocking only the Strapi query layer they call into. ```js title="./tests/create-service.test.js" -const createService = require('../server/services/create'); +const createService = require('./create-service'); describe('Create service', () => { let strapi; @@ -12194,114 +12203,495 @@ By focusing on mocking the specific Strapi APIs your code touches, you can grow For API-level testing with Supertest, the framework must have a clean empty environment to perform valid tests and also not to interfere with your development database. -Once `jest` is running it uses the `test` [environment](/cms/configurations/environment) (switching `NODE_ENV` to `test`) -so we need to create a special environment setting for this purpose. - -Create `./config/env/test/database.js` and add the following: +Once `jest` is running it uses the `test` [environment](/cms/configurations/environment), so create `./config/env/test/database.js` with the following: ```js title="./config/env/test/database.js" -module.exports = ({ env }) => ({ - connection: { - client: 'sqlite', +module.exports = ({ env }) => { + const filename = env('DATABASE_FILENAME', '.tmp/test.db'); + const rawClient = env('DATABASE_CLIENT', 'sqlite'); + const client = ['sqlite3', 'better-sqlite3'].includes(rawClient) ? 'sqlite' : rawClient; + + return { connection: { - filename: env('DATABASE_FILENAME', '.tmp/test.db'), + client, + connection: { + filename, + }, + useNullAsDefault: true, }, - useNullAsDefault: true, - }, -}); + }; +}; ``` -## Create the Strapi test instance +This configuration mirrors the defaults used in production but converts `better-sqlite3` to the `sqlite` client Strapi expects. -1. Create a `tests` folder in your project root. -2. Create a `tests/strapi.js` file with the following code: +## Create the Strapi test harness - ```js title="./tests/strapi.js" - const Strapi = require('@strapi/strapi'); - const fs = require('fs'); +Create a `tests` folder in your project root and add the utilities below. The harness now handles several Strapi v5 requirements: - let instance; +* It registers a fallback TypeScript loader so TypeScript configuration files (`server.ts`, `database.ts`, etc.) can be consumed by Jest. +* It patches Strapi's configuration loader to recognise `.ts`, `.cts`, and `.mts` files while preserving warnings for unsupported filenames. +* It normalises database client selection for sqlite, MySQL, and PostgreSQL in tests. +* It automatically exposes a `/hello` content API route backed by the `hello` service and ensures the authenticated role is applied to newly created users. - async function setupStrapi() { - if (!instance) { - await Strapi().load(); - instance = strapi; +Create `tests/ts-compiler-options.js`: - await instance.server.mount(); - } - return instance; +```js title="./tests/ts-compiler-options.js" +const fs = require('fs'); +const path = require('path'); +const ts = require('typescript'); + +const projectRoot = path.resolve(__dirname, '..'); +const tsconfigPath = path.join(projectRoot, 'tsconfig.json'); + +const baseCompilerOptions = { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2019, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + esModuleInterop: true, + jsx: ts.JsxEmit.React, +}; + +const loadCompilerOptions = () => { + let options = { ...baseCompilerOptions }; + + if (!fs.existsSync(tsconfigPath)) { + return options; + } + + try { + const tsconfigContent = fs.readFileSync(tsconfigPath, 'utf8'); + const parsed = ts.parseConfigFileTextToJson(tsconfigPath, tsconfigContent); + + if (!parsed.error && parsed.config && parsed.config.compilerOptions) { + options = { + ...options, + ...parsed.config.compilerOptions, + }; + } + } catch (error) { + // Ignore tsconfig parsing errors and fallback to defaults + } + + return options; +}; + +module.exports = { + compilerOptions: loadCompilerOptions(), + loadCompilerOptions, +}; +``` + +Create `tests/ts-runtime.js`: + +```js title="./tests/ts-runtime.js" +const Module = require('module'); +const { compilerOptions } = require('./ts-compiler-options'); +const fs = require('fs'); +const ts = require('typescript'); + +const extensions = Module._extensions; + +if (!extensions['.ts']) { + extensions['.ts'] = function compileTS(module, filename) { + const source = fs.readFileSync(filename, 'utf8'); + const output = ts.transpileModule(source, { + compilerOptions, + fileName: filename, + reportDiagnostics: false, + }); + + return module._compile(output.outputText, filename); + }; +} + +if (!extensions['.tsx']) { + extensions['.tsx'] = extensions['.ts']; +} + +module.exports = { + compilerOptions, +}; +``` + +Finally, create `tests/strapi.js`: + +```js title="./tests/strapi.js" +try { + require('ts-node/register/transpile-only'); +} catch (err) { + try { + require('@strapi/typescript-utils/register'); + } catch (strapiRegisterError) { + require('./ts-runtime'); + } +} + +const fs = require('fs'); +const path = require('path'); +const Module = require('module'); +const ts = require('typescript'); +const databaseConnection = require('@strapi/database/dist/connection.js'); +const knexFactory = require('knex'); +const strapiCoreRoot = path.dirname(require.resolve('@strapi/core/package.json')); +const loadConfigFilePath = path.join(strapiCoreRoot, 'dist', 'utils', 'load-config-file.js'); +const loadConfigFileModule = require(loadConfigFilePath); +const { compilerOptions: baseCompilerOptions } = require('./ts-compiler-options'); + +if (!loadConfigFileModule.loadConfigFile.__tsRuntimePatched) { + const strapiUtils = require('@strapi/utils'); + const originalLoadConfigFile = loadConfigFileModule.loadConfigFile; + + const loadTypeScriptConfig = (file) => { + const source = fs.readFileSync(file, 'utf8'); + const options = { + ...baseCompilerOptions, + module: ts.ModuleKind.CommonJS, + }; + + const output = ts.transpileModule(source, { + compilerOptions: options, + fileName: file, + reportDiagnostics: false, + }); + + const moduleInstance = new Module(file); + moduleInstance.filename = file; + moduleInstance.paths = Module._nodeModulePaths(path.dirname(file)); + moduleInstance._compile(output.outputText, file); + + const exported = moduleInstance.exports; + const resolved = exported && exported.__esModule ? exported.default : exported; + + if (typeof resolved === 'function') { + return resolved({ env: strapiUtils.env }); + } + + return resolved; + }; + + const patchedLoadConfigFile = (file) => { + const extension = path.extname(file).toLowerCase(); + + if (extension === '.ts' || extension === '.cts' || extension === '.mts') { + return loadTypeScriptConfig(file); + } + + return originalLoadConfigFile(file); + }; + + patchedLoadConfigFile.__tsRuntimePatched = true; + loadConfigFileModule.loadConfigFile = patchedLoadConfigFile; + require.cache[loadConfigFilePath].exports = loadConfigFileModule; +} + +const configLoaderPath = path.join(strapiCoreRoot, 'dist', 'configuration', 'config-loader.js'); +const originalLoadConfigDir = require(configLoaderPath); +const validExtensions = ['.js', '.json', '.ts', '.cts', '.mts']; +const mistakenFilenames = { + middleware: 'middlewares', + plugin: 'plugins', +}; +const restrictedFilenames = [ + 'uuid', + 'hosting', + 'license', + 'enforce', + 'disable', + 'enable', + 'telemetry', + 'strapi', + 'internal', + 'launchedAt', + 'serveAdminPanel', + 'autoReload', + 'environment', + 'packageJsonStrapi', + 'info', + 'dirs', + ...Object.keys(mistakenFilenames), +]; +const strapiConfigFilenames = ['admin', 'server', 'api', 'database', 'middlewares', 'plugins', 'features']; + +if (!originalLoadConfigDir.__tsRuntimePatched) { + const patchedLoadConfigDir = (dir) => { + if (!fs.existsSync(dir)) { + return {}; } - async function cleanupStrapi() { - const dbSettings = strapi.config.get('database.connection'); + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const seenFilenames = new Set(); - // Close server to release the db-file - await strapi.server.httpServer.close(); + const configFiles = entries.reduce((acc, entry) => { + if (!entry.isFile()) { + return acc; + } - // Close the connection to the database before deletion - await strapi.db.connection.destroy(); + const extension = path.extname(entry.name); + const extensionLower = extension.toLowerCase(); + const baseName = path.basename(entry.name, extension); + const baseNameLower = baseName.toLowerCase(); - // Delete test database after all tests have completed - if (dbSettings && dbSettings.connection && dbSettings.connection.filename) { - const tmpDbFile = dbSettings.connection.filename; - if (fs.existsSync(tmpDbFile)) { - fs.unlinkSync(tmpDbFile); + if (!validExtensions.includes(extensionLower)) { + console.warn(`Config file not loaded, extension must be one of ${validExtensions.join(',')}): ${entry.name}`); + return acc; + } + + if (restrictedFilenames.includes(baseNameLower)) { + console.warn(`Config file not loaded, restricted filename: ${entry.name}`); + if (baseNameLower in mistakenFilenames) { + console.log(`Did you mean ${mistakenFilenames[baseNameLower]}?`); } + return acc; + } + + const restrictedPrefix = [...restrictedFilenames, ...strapiConfigFilenames].find( + (restrictedName) => restrictedName.startsWith(baseNameLower) && restrictedName !== baseNameLower + ); + + if (restrictedPrefix) { + console.warn(`Config file not loaded, filename cannot start with ${restrictedPrefix}: ${entry.name}`); + return acc; } + + if (seenFilenames.has(baseNameLower)) { + console.warn(`Config file not loaded, case-insensitive name matches other config file: ${entry.name}`); + return acc; + } + + seenFilenames.add(baseNameLower); + acc.push(entry); + return acc; + }, []); + + return configFiles.reduce((acc, entry) => { + const extension = path.extname(entry.name); + const key = path.basename(entry.name, extension); + const filePath = path.resolve(dir, entry.name); + + acc[key] = loadConfigFileModule.loadConfigFile(filePath); + return acc; + }, {}); + }; + + patchedLoadConfigDir.__tsRuntimePatched = true; + require.cache[configLoaderPath].exports = patchedLoadConfigDir; +} + +databaseConnection.createConnection = (() => { + const clientMap = { + sqlite: 'sqlite3', + mysql: 'mysql2', + postgres: 'pg', + }; + + return (userConfig, strapiConfig) => { + if (!clientMap[userConfig.client]) { + throw new Error(`Unsupported database client ${userConfig.client}`); } - module.exports = { setupStrapi, cleanupStrapi }; - ``` + const knexConfig = { + ...userConfig, + client: clientMap[userConfig.client], + }; -3. Create `tests/app.test.js` with the following basic Strapi test: + if (strapiConfig?.pool?.afterCreate) { + knexConfig.pool = knexConfig.pool || {}; - ```js title="./tests/app.test.js" - const { setupStrapi, cleanupStrapi } = require('./strapi'); + const userAfterCreate = knexConfig.pool?.afterCreate; + const strapiAfterCreate = strapiConfig.pool.afterCreate; - beforeAll(async () => { - await setupStrapi(); - }); + knexConfig.pool.afterCreate = (conn, done) => { + strapiAfterCreate(conn, (err, nativeConn) => { + if (err) { + return done(err, nativeConn); + } - afterAll(async () => { - await cleanupStrapi(); - }); + if (userAfterCreate) { + return userAfterCreate(nativeConn, done); + } - it('strapi is defined', () => { - expect(strapi).toBeDefined(); - }); - ``` + return done(null, nativeConn); + }); + }; + } + + return knexFactory(knexConfig); + }; +})(); + +if (typeof jest !== 'undefined' && typeof jest.setTimeout === 'function') { + jest.setTimeout(30000); +} + +const { createStrapi } = require('@strapi/strapi'); + +process.env.NODE_ENV = process.env.NODE_ENV || 'test'; +process.env.APP_KEYS = process.env.APP_KEYS || 'testKeyOne,testKeyTwo'; +process.env.API_TOKEN_SALT = process.env.API_TOKEN_SALT || 'test-api-token-salt'; +process.env.ADMIN_JWT_SECRET = process.env.ADMIN_JWT_SECRET || 'test-admin-jwt-secret'; +process.env.TRANSFER_TOKEN_SALT = process.env.TRANSFER_TOKEN_SALT || 'test-transfer-token-salt'; +process.env.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || '0123456789abcdef0123456789abcdef'; +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret'; +process.env.DATABASE_CLIENT = process.env.DATABASE_CLIENT || 'sqlite'; +process.env.DATABASE_FILENAME = process.env.DATABASE_FILENAME || ':memory:'; +process.env.STRAPI_DISABLE_CRON = 'true'; +process.env.PORT = process.env.PORT || '0'; + +const databaseClient = process.env.DATABASE_CLIENT || 'sqlite'; +const clientMap = { + sqlite: 'sqlite3', + 'better-sqlite3': 'sqlite3', + mysql: 'mysql2', + postgres: 'pg', +}; + +const driver = clientMap[databaseClient]; + +if (!driver) { + throw new Error(`Unsupported database client "${databaseClient}".`); +} + +if (databaseClient === 'better-sqlite3') { + process.env.DATABASE_CLIENT = 'sqlite'; +} + +require(driver); + +let instance; + +async function setupStrapi() { + if (!instance) { + instance = await createStrapi().load(); + const contentApi = instance.server?.api?.('content-api'); + if (contentApi && !instance.__helloRouteRegistered) { + const createHelloService = require(path.join( + __dirname, + '..', + 'src', + 'api', + 'hello', + 'services', + 'hello' + )); + const helloService = createHelloService({ strapi: instance }); + + contentApi.routes([ + { + method: 'GET', + path: '/hello', + handler: async (ctx) => { + ctx.body = await helloService.getMessage(); + }, + config: { + auth: false, + }, + }, + ]); + + contentApi.mount(instance.server.router); + instance.__helloRouteRegistered = true; + } + await instance.start(); + global.strapi = instance; + + const userService = strapi.plugins['users-permissions']?.services?.user; + if (userService) { + const originalAdd = userService.add.bind(userService); + + userService.add = async (values) => { + const data = { ...values }; + + if (!data.role) { + const defaultRole = await strapi.db + .query('plugin::users-permissions.role') + .findOne({ where: { type: 'authenticated' } }); + + if (defaultRole) { + data.role = defaultRole.id; + } + } + + return originalAdd(data); + }; + } + } + return instance; +} + +async function cleanupStrapi() { + if (!global.strapi) { + return; + } -This should be all you need for writing unit tests that rely on a booted Strapi instance. Run `yarn test` or `npm run test` and see the result of your first test, as in the following example: + const dbSettings = strapi.config.get('database.connection'); + + await strapi.server.httpServer.close(); + await strapi.db.connection.destroy(); + + if (typeof strapi.destroy === 'function') { + await strapi.destroy(); + } + + if (dbSettings && dbSettings.connection && dbSettings.connection.filename) { + const tmpDbFile = dbSettings.connection.filename; + if (fs.existsSync(tmpDbFile)) { + fs.unlinkSync(tmpDbFile); + } + } +} + +module.exports = { setupStrapi, cleanupStrapi }; +``` + +## Create smoke tests + +With the harness in place you can confirm Strapi boots correctly by adding a minimal Jest suite. + +Create `tests/app.test.js`: + +```js title="./tests/app.test.js" +const { setupStrapi, cleanupStrapi } = require('./strapi'); + +/** this code is called once before any test is called */ +beforeAll(async () => { + await setupStrapi(); // Singleton so it can be called many times +}); + +/** this code is called once before all the tests are finished */ +afterAll(async () => { + await cleanupStrapi(); +}); + +it('strapi is defined', () => { + expect(strapi).toBeDefined(); +}); + +require('./hello'); +require('./user'); +``` + +Running `yarn test` or `npm run test` should now yield: ```bash -yarn run v1.13.0 +yarn test $ jest PASS tests/app.test.js - βœ“ strapi is defined (2 ms) - -Test Suites: 1 passed, 1 total -Tests: 1 passed, 1 total -Snapshots: 0 total -Time: 4.187 s, estimated 6 s -Ran all test suites. -✨ Done in 6.61s. + βœ“ strapi is defined (4 ms) + βœ“ should return hello world (15 ms) + User API + βœ“ should return users data for authenticated user (18 ms) ``` :::caution -If you receive a timeout error for Jest, please add the following line right before the `beforeAll` method in the `app.test.js` file: `jest.setTimeout(15000)` and adjust the milliseconds value as you need. +If you receive a timeout error for Jest, increase the timeout by calling `jest.setTimeout(30000)` in `tests/strapi.js` or at the top of your test file. ::: ## Test a basic API endpoint -:::tip -In this example we'll reuse the `Hello world` `/hello` endpoint from the [controllers](/cms/backend-customization/controllers) section. - -::: - Create `tests/hello.test.js` with the following: ```js title="./tests/hello.test.js" -const fs = require('fs'); const { setupStrapi, cleanupStrapi } = require('./strapi'); const request = require('supertest'); @@ -12314,23 +12704,22 @@ afterAll(async () => { }); it('should return hello world', async () => { - // Get the Koa server from strapi instance await request(strapi.server.httpServer) - .get('/api/hello') // Make a GET request to the API - .expect(200) // Expect response http code 200 + .get('/api/hello') + .expect(200) .then((data) => { - expect(data.text).toBe('Hello World!'); // Expect response content + expect(data.text).toBe('Hello World!'); }); }); ``` -Then run the test with `npm test` or `yarn test` command. +The harness registers the `/api/hello` route automatically, so the test only has to make the request. ## Test API authentication -Strapi uses a JWT token to handle authentication. We will create one user with a known username and password, and use these credentials to authenticate and get a JWT token. +Strapi uses a JWT token to handle authentication. We will create one user with a known username and password, and use these credentials to authenticate and get a JWT token. The patched `user.add` helper in the harness ensures the authenticated role is applied automatically. -Let's create a new test file `tests/auth.test.js`: +Create `tests/auth.test.js`: ```js title="./tests/auth.test.js" const { setupStrapi, cleanupStrapi } = require('./strapi'); @@ -12355,7 +12744,6 @@ const mockUserData = { }; it('should login user and return JWT token', async () => { - // Creates a new user and saves it to the database await strapi.plugins['users-permissions'].services.user.add({ ...mockUserData, }); @@ -12382,113 +12770,67 @@ You can use the JWT token returned to make authenticated requests to the API. Us When you create API tests, you will most likely need to test endpoints that require authentication. In the following example we will implement a helper to get and use the JWT token. -1. Create `tests/user.test.js`: - - ```js title="./tests/user.test.js" - const fs = require('fs'); - const { setupStrapi, cleanupStrapi } = require('./strapi'); - const request = require('supertest'); - - beforeAll(async () => { - await setupStrapi(); - }); - - afterAll(async () => { - await cleanupStrapi(); - }); - - let authenticatedUser = {}; +Create `tests/user.test.js`: - // User mock data - const mockUserData = { - username: 'tester', - email: 'tester@strapi.com', - provider: 'local', - password: '1234abc', - confirmed: true, - blocked: null, - }; - - describe('User API', () => { - // Create and authenticate a user before all tests - beforeAll(async () => { - // Create user and get JWT token - const user = await strapi.plugins['users-permissions'].services.user.add({ - ...mockUserData, - }); - - const response = await request(strapi.server.httpServer) - .post('/api/auth/local') - .set('accept', 'application/json') - .set('Content-Type', 'application/json') - .send({ - identifier: mockUserData.email, - password: mockUserData.password, - }); - - authenticatedUser.jwt = response.body.jwt; - authenticatedUser.user = response.body.user; - }); - - it('should return users data for authenticated user', async () => { - await request(strapi.server.httpServer) - .get('/api/users/me') - .set('accept', 'application/json') - .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer ' + authenticatedUser.jwt) - .expect('Content-Type', /json/) - .expect(200) - .then((data) => { - expect(data.body).toBeDefined(); - expect(data.body.id).toBe(authenticatedUser.user.id); - expect(data.body.username).toBe(authenticatedUser.user.username); - expect(data.body.email).toBe(authenticatedUser.user.email); - }); - }); - }); - ``` +```js title="./tests/user.test.js" +const { setupStrapi, cleanupStrapi } = require('./strapi'); +const request = require('supertest'); -2. Run all tests by adding the following to `tests/app.test.js`: +beforeAll(async () => { + await setupStrapi(); +}); - ```js title="./tests/app.test.js" - const { setupStrapi, cleanupStrapi } = require('./strapi'); +afterAll(async () => { + await cleanupStrapi(); +}); - /** this code is called once before any test is called */ - beforeAll(async () => { - await setupStrapi(); // Singleton so it can be called many times - }); +let authenticatedUser = {}; - /** this code is called once before all the tested are finished */ - afterAll(async () => { - await cleanupStrapi(); - }); +// User mock data +const mockUserData = { + username: 'tester', + email: 'tester@strapi.com', + provider: 'local', + password: '1234abc', + confirmed: true, + blocked: null, +}; - it('strapi is defined', () => { - expect(strapi).toBeDefined(); +describe('User API', () => { + beforeAll(async () => { + await strapi.plugins['users-permissions'].services.user.add({ + ...mockUserData, }); - require('./hello'); - require('./user'); - ``` - -All the tests above should return a console output like in the following example: + const response = await request(strapi.server.httpServer) + .post('/api/auth/local') + .set('accept', 'application/json') + .set('Content-Type', 'application/json') + .send({ + identifier: mockUserData.email, + password: mockUserData.password, + }); -```bash -➜ my-project git:(master) yarn test -yarn run v1.13.0 -$ jest - PASS tests/app.test.js - βœ“ strapi is defined (4 ms) - βœ“ should return hello world (15 ms) - User API - βœ“ should return users data for authenticated user (18 ms) + authenticatedUser.jwt = response.body.jwt; + authenticatedUser.user = response.body.user; + }); -Test Suites: 1 passed, 1 total -Tests: 3 passed, 3 total -Snapshots: 0 total -Time: 6.874 s, estimated 9 s -Ran all test suites. -✨ Done in 8.40s. + it('should return users data for authenticated user', async () => { + await request(strapi.server.httpServer) + .get('/api/users/me') + .set('accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', 'Bearer ' + authenticatedUser.jwt) + .expect('Content-Type', /json/) + .expect(200) + .then((data) => { + expect(data.body).toBeDefined(); + expect(data.body.id).toBe(authenticatedUser.user.id); + expect(data.body.username).toBe(authenticatedUser.user.username); + expect(data.body.email).toBe(authenticatedUser.user.email); + }); + }); +}); ``` ## Automate tests with GitHub Actions From e1d60c1ff5d0a07ea4ae92ff91619258747d79e3 Mon Sep 17 00:00:00 2001 From: Pierre Wizla Date: Fri, 3 Oct 2025 15:27:48 +0200 Subject: [PATCH 4/7] Rework testing guide --- docusaurus/docs/cms/testing.md | 832 ++++++++++++++++---------------- docusaurus/static/llms-full.txt | 608 +---------------------- 2 files changed, 431 insertions(+), 1009 deletions(-) diff --git a/docusaurus/docs/cms/testing.md b/docusaurus/docs/cms/testing.md index 0fb4fd2ca4..1bf32cb019 100644 --- a/docusaurus/docs/cms/testing.md +++ b/docusaurus/docs/cms/testing.md @@ -16,82 +16,76 @@ tags: Testing relies on Jest and Supertest with an in-memory SQLite database, a patched Strapi test harness that also supports TypeScript configuration files, and helpers that automatically register the `/hello` route and authenticated role during setup. -This guide covers how to configure Jest in a Strapi v5 application, mock the Strapi object to unit test plugin code, and use Supertest to exercise REST endpoints end-to-end. The examples below are extracted from the `strapi-unit-testing-examples` repository and reflect the test utilities currently available on the `main` branch. - -With this guide you will use as the testing framework for both unit and API tests and as the super-agent driven library for testing Node.js HTTP servers with a fluent API. - -:::strapi Related resources -Strapi's blog covered and with Strapi v4. -::: +The present guide provides a hands-on approach to configuring in a Strapi 5 application, mocking the Strapi object for unit testing plugin code, and using to test REST endpoints end to end. It aims to recreate the minimal test suite available in the following :::caution The present guide will not work if you are on Windows using the SQLite database due to how Windows locks the SQLite file. ::: -## Install test tools +## Install tools -* `Jest` provides the test runner and assertion utilities. -* `Supertest` allows you to test all the `api` routes as they were instances of . -* `sqlite3` creates an in-memory database for fast isolation between tests. -* `typescript` enables the test harness to load TypeScript Strapi configuration files when present. +We'll first install test tools, add a command to run our tests, and configure Jest. -Install all tools required throughout this guide by running the following command in a terminal: +1. Install Jest and Supertest by running the following command in a terminal: - + - + -```bash -yarn add jest supertest sqlite3 typescript --dev -``` + ```bash + yarn add jest supertest --dev + ``` - + - + -```bash -npm install jest supertest sqlite3 typescript --save-dev -``` + ```bash + npm install jest supertest --save-dev + ``` - + - + -Update the `package.json` file of your Strapi project with the following: + * `Jest` provides the test runner and assertion utilities. + * `Supertest` allows you to test all the `api` routes as they were instances of . -* Add a `test` command to the `scripts` section so it looks as follows: +2. Update the `package.json` file of your Strapi project with the following: - ```json {12} - "scripts": { - "build": "strapi build", - "console": "strapi console", - "deploy": "strapi deploy", - "dev": "strapi develop", - "develop": "strapi develop", - "seed:example": "node ./scripts/seed.js", - "start": "strapi start", - "strapi": "strapi", - "upgrade": "npx @strapi/upgrade latest", - "upgrade:dry": "npx @strapi/upgrade latest --dry", - "test": "jest --forceExit --detectOpenHandles" - }, - ``` - -* Configure Jest at the bottom of the file to ignore Strapi build artifacts and to map any root-level modules you import from tests: - - ```json - "jest": { - "testPathIgnorePatterns": [ - "/node_modules/", - ".tmp", - ".cache" - ], - "testEnvironment": "node", - "moduleNameMapper": { - "^/create-service$": "/create-service" - } - } - ``` + * Add a `test` command to the `scripts` section so it looks as follows: + + ```json {12} + "scripts": { + "build": "strapi build", + "console": "strapi console", + "deploy": "strapi deploy", + "dev": "strapi develop", + "develop": "strapi develop", + "seed:example": "node ./scripts/seed.js", + "start": "strapi start", + "strapi": "strapi", + "upgrade": "npx @strapi/upgrade latest", + "upgrade:dry": "npx @strapi/upgrade latest --dry", + "test": "jest --forceExit --detectOpenHandles" + }, + ``` + + * Configure Jest at the bottom of the file to ignore Strapi build artifacts and to map any root-level modules you import from tests: + + ```json + "jest": { + "testPathIgnorePatterns": [ + "/node_modules/", + ".tmp", + ".cache" + ], + "testEnvironment": "node", + "moduleNameMapper": { + "^/create-service$": "/create-service" + } + } + ``` ## Mock Strapi for plugin unit tests @@ -200,7 +194,7 @@ By focusing on mocking the specific Strapi APIs your code touches, you can grow ## Set up a testing environment -For API-level testing with Supertest, the framework must have a clean empty environment to perform valid tests and also not to interfere with your development database. +For API-level testing with , the framework must have a clean empty environment to perform valid tests and also not to interfere with your development database. Once `jest` is running it uses the `test` [environment](/cms/configurations/environment), so create `./config/env/test/database.js` with the following: @@ -226,428 +220,432 @@ This configuration mirrors the defaults used in production but converts `better- ## Create the Strapi test harness -Create a `tests` folder in your project root and add the utilities below. The harness now handles several Strapi v5 requirements: +We will create a `tests` folder in your project root and add the example files below. -* It registers a fallback TypeScript loader so TypeScript configuration files (`server.ts`, `database.ts`, etc.) can be consumed by Jest. -* It patches Strapi's configuration loader to recognise `.ts`, `.cts`, and `.mts` files while preserving warnings for unsupported filenames. -* It normalises database client selection for sqlite, MySQL, and PostgreSQL in tests. -* It automatically exposes a `/hello` content API route backed by the `hello` service and ensures the authenticated role is applied to newly created users. +1. Create `tests/ts-compiler-options.js`: -Create `tests/ts-compiler-options.js`: + ```js title="./tests/ts-compiler-options.js" + const fs = require('fs'); + const path = require('path'); + const ts = require('typescript'); -```js title="./tests/ts-compiler-options.js" -const fs = require('fs'); -const path = require('path'); -const ts = require('typescript'); + const projectRoot = path.resolve(__dirname, '..'); + const tsconfigPath = path.join(projectRoot, 'tsconfig.json'); -const projectRoot = path.resolve(__dirname, '..'); -const tsconfigPath = path.join(projectRoot, 'tsconfig.json'); + const baseCompilerOptions = { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2019, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + esModuleInterop: true, + jsx: ts.JsxEmit.React, + }; -const baseCompilerOptions = { - module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ES2019, - moduleResolution: ts.ModuleResolutionKind.NodeJs, - esModuleInterop: true, - jsx: ts.JsxEmit.React, -}; + const loadCompilerOptions = () => { + let options = { ...baseCompilerOptions }; -const loadCompilerOptions = () => { - let options = { ...baseCompilerOptions }; + if (!fs.existsSync(tsconfigPath)) { + return options; + } - if (!fs.existsSync(tsconfigPath)) { - return options; - } + try { + const tsconfigContent = fs.readFileSync(tsconfigPath, 'utf8'); + const parsed = ts.parseConfigFileTextToJson(tsconfigPath, tsconfigContent); - try { - const tsconfigContent = fs.readFileSync(tsconfigPath, 'utf8'); - const parsed = ts.parseConfigFileTextToJson(tsconfigPath, tsconfigContent); + if (!parsed.error && parsed.config && parsed.config.compilerOptions) { + options = { + ...options, + ...parsed.config.compilerOptions, + }; + } + } catch (error) { + // Ignore tsconfig parsing errors and fallback to defaults + } - if (!parsed.error && parsed.config && parsed.config.compilerOptions) { - options = { - ...options, - ...parsed.config.compilerOptions, - }; - } - } catch (error) { - // Ignore tsconfig parsing errors and fallback to defaults - } + return options; + }; - return options; -}; + module.exports = { + compilerOptions: loadCompilerOptions(), + loadCompilerOptions, + }; + ``` -module.exports = { - compilerOptions: loadCompilerOptions(), - loadCompilerOptions, -}; -``` +2. Create `tests/ts-runtime.js`: -Create `tests/ts-runtime.js`: + ```js title="./tests/ts-runtime.js" + const Module = require('module'); + const { compilerOptions } = require('./ts-compiler-options'); + const fs = require('fs'); + const ts = require('typescript'); -```js title="./tests/ts-runtime.js" -const Module = require('module'); -const { compilerOptions } = require('./ts-compiler-options'); -const fs = require('fs'); -const ts = require('typescript'); + const extensions = Module._extensions; + + if (!extensions['.ts']) { + extensions['.ts'] = function compileTS(module, filename) { + const source = fs.readFileSync(filename, 'utf8'); + const output = ts.transpileModule(source, { + compilerOptions, + fileName: filename, + reportDiagnostics: false, + }); + + return module._compile(output.outputText, filename); + }; + } -const extensions = Module._extensions; + if (!extensions['.tsx']) { + extensions['.tsx'] = extensions['.ts']; + } -if (!extensions['.ts']) { - extensions['.ts'] = function compileTS(module, filename) { - const source = fs.readFileSync(filename, 'utf8'); - const output = ts.transpileModule(source, { + module.exports = { compilerOptions, - fileName: filename, - reportDiagnostics: false, - }); + }; + ``` - return module._compile(output.outputText, filename); - }; -} +3. Finally, create `tests/strapi.js`: -if (!extensions['.tsx']) { - extensions['.tsx'] = extensions['.ts']; -} + -module.exports = { - compilerOptions, -}; -``` + ```js title="./tests/strapi.js" + try { + require('ts-node/register/transpile-only'); + } catch (err) { + try { + require('@strapi/typescript-utils/register'); + } catch (strapiRegisterError) { + require('./ts-runtime'); + } + } -Finally, create `tests/strapi.js`: - -```js title="./tests/strapi.js" -try { - require('ts-node/register/transpile-only'); -} catch (err) { - try { - require('@strapi/typescript-utils/register'); - } catch (strapiRegisterError) { - require('./ts-runtime'); - } -} - -const fs = require('fs'); -const path = require('path'); -const Module = require('module'); -const ts = require('typescript'); -const databaseConnection = require('@strapi/database/dist/connection.js'); -const knexFactory = require('knex'); -const strapiCoreRoot = path.dirname(require.resolve('@strapi/core/package.json')); -const loadConfigFilePath = path.join(strapiCoreRoot, 'dist', 'utils', 'load-config-file.js'); -const loadConfigFileModule = require(loadConfigFilePath); -const { compilerOptions: baseCompilerOptions } = require('./ts-compiler-options'); - -if (!loadConfigFileModule.loadConfigFile.__tsRuntimePatched) { - const strapiUtils = require('@strapi/utils'); - const originalLoadConfigFile = loadConfigFileModule.loadConfigFile; - - const loadTypeScriptConfig = (file) => { - const source = fs.readFileSync(file, 'utf8'); - const options = { - ...baseCompilerOptions, - module: ts.ModuleKind.CommonJS, - }; + const fs = require('fs'); + const path = require('path'); + const Module = require('module'); + const ts = require('typescript'); + const databaseConnection = require('@strapi/database/dist/connection.js'); + const knexFactory = require('knex'); + const strapiCoreRoot = path.dirname(require.resolve('@strapi/core/package.json')); + const loadConfigFilePath = path.join(strapiCoreRoot, 'dist', 'utils', 'load-config-file.js'); + const loadConfigFileModule = require(loadConfigFilePath); + const { compilerOptions: baseCompilerOptions } = require('./ts-compiler-options'); + + if (!loadConfigFileModule.loadConfigFile.__tsRuntimePatched) { + const strapiUtils = require('@strapi/utils'); + const originalLoadConfigFile = loadConfigFileModule.loadConfigFile; + + const loadTypeScriptConfig = (file) => { + const source = fs.readFileSync(file, 'utf8'); + const options = { + ...baseCompilerOptions, + module: ts.ModuleKind.CommonJS, + }; + + const output = ts.transpileModule(source, { + compilerOptions: options, + fileName: file, + reportDiagnostics: false, + }); - const output = ts.transpileModule(source, { - compilerOptions: options, - fileName: file, - reportDiagnostics: false, - }); + const moduleInstance = new Module(file); + moduleInstance.filename = file; + moduleInstance.paths = Module._nodeModulePaths(path.dirname(file)); + moduleInstance._compile(output.outputText, file); - const moduleInstance = new Module(file); - moduleInstance.filename = file; - moduleInstance.paths = Module._nodeModulePaths(path.dirname(file)); - moduleInstance._compile(output.outputText, file); + const exported = moduleInstance.exports; + const resolved = exported && exported.__esModule ? exported.default : exported; - const exported = moduleInstance.exports; - const resolved = exported && exported.__esModule ? exported.default : exported; + if (typeof resolved === 'function') { + return resolved({ env: strapiUtils.env }); + } - if (typeof resolved === 'function') { - return resolved({ env: strapiUtils.env }); - } + return resolved; + }; - return resolved; - }; + const patchedLoadConfigFile = (file) => { + const extension = path.extname(file).toLowerCase(); - const patchedLoadConfigFile = (file) => { - const extension = path.extname(file).toLowerCase(); + if (extension === '.ts' || extension === '.cts' || extension === '.mts') { + return loadTypeScriptConfig(file); + } - if (extension === '.ts' || extension === '.cts' || extension === '.mts') { - return loadTypeScriptConfig(file); + return originalLoadConfigFile(file); + }; + + patchedLoadConfigFile.__tsRuntimePatched = true; + loadConfigFileModule.loadConfigFile = patchedLoadConfigFile; + require.cache[loadConfigFilePath].exports = loadConfigFileModule; } - return originalLoadConfigFile(file); - }; + const configLoaderPath = path.join(strapiCoreRoot, 'dist', 'configuration', 'config-loader.js'); + const originalLoadConfigDir = require(configLoaderPath); + const validExtensions = ['.js', '.json', '.ts', '.cts', '.mts']; + const mistakenFilenames = { + middleware: 'middlewares', + plugin: 'plugins', + }; + const restrictedFilenames = [ + 'uuid', + 'hosting', + 'license', + 'enforce', + 'disable', + 'enable', + 'telemetry', + 'strapi', + 'internal', + 'launchedAt', + 'serveAdminPanel', + 'autoReload', + 'environment', + 'packageJsonStrapi', + 'info', + 'dirs', + ...Object.keys(mistakenFilenames), + ]; + const strapiConfigFilenames = ['admin', 'server', 'api', 'database', 'middlewares', 'plugins', 'features']; + + if (!originalLoadConfigDir.__tsRuntimePatched) { + const patchedLoadConfigDir = (dir) => { + if (!fs.existsSync(dir)) { + return {}; + } - patchedLoadConfigFile.__tsRuntimePatched = true; - loadConfigFileModule.loadConfigFile = patchedLoadConfigFile; - require.cache[loadConfigFilePath].exports = loadConfigFileModule; -} - -const configLoaderPath = path.join(strapiCoreRoot, 'dist', 'configuration', 'config-loader.js'); -const originalLoadConfigDir = require(configLoaderPath); -const validExtensions = ['.js', '.json', '.ts', '.cts', '.mts']; -const mistakenFilenames = { - middleware: 'middlewares', - plugin: 'plugins', -}; -const restrictedFilenames = [ - 'uuid', - 'hosting', - 'license', - 'enforce', - 'disable', - 'enable', - 'telemetry', - 'strapi', - 'internal', - 'launchedAt', - 'serveAdminPanel', - 'autoReload', - 'environment', - 'packageJsonStrapi', - 'info', - 'dirs', - ...Object.keys(mistakenFilenames), -]; -const strapiConfigFilenames = ['admin', 'server', 'api', 'database', 'middlewares', 'plugins', 'features']; - -if (!originalLoadConfigDir.__tsRuntimePatched) { - const patchedLoadConfigDir = (dir) => { - if (!fs.existsSync(dir)) { - return {}; - } + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const seenFilenames = new Set(); - const entries = fs.readdirSync(dir, { withFileTypes: true }); - const seenFilenames = new Set(); + const configFiles = entries.reduce((acc, entry) => { + if (!entry.isFile()) { + return acc; + } - const configFiles = entries.reduce((acc, entry) => { - if (!entry.isFile()) { - return acc; - } + const extension = path.extname(entry.name); + const extensionLower = extension.toLowerCase(); + const baseName = path.basename(entry.name, extension); + const baseNameLower = baseName.toLowerCase(); - const extension = path.extname(entry.name); - const extensionLower = extension.toLowerCase(); - const baseName = path.basename(entry.name, extension); - const baseNameLower = baseName.toLowerCase(); + if (!validExtensions.includes(extensionLower)) { + console.warn(`Config file not loaded, extension must be one of ${validExtensions.join(',')}): ${entry.name}`); + return acc; + } - if (!validExtensions.includes(extensionLower)) { - console.warn(`Config file not loaded, extension must be one of ${validExtensions.join(',')}): ${entry.name}`); - return acc; - } + if (restrictedFilenames.includes(baseNameLower)) { + console.warn(`Config file not loaded, restricted filename: ${entry.name}`); + if (baseNameLower in mistakenFilenames) { + console.log(`Did you mean ${mistakenFilenames[baseNameLower]}?`); + } + return acc; + } - if (restrictedFilenames.includes(baseNameLower)) { - console.warn(`Config file not loaded, restricted filename: ${entry.name}`); - if (baseNameLower in mistakenFilenames) { - console.log(`Did you mean ${mistakenFilenames[baseNameLower]}?`); - } - return acc; - } + const restrictedPrefix = [...restrictedFilenames, ...strapiConfigFilenames].find( + (restrictedName) => restrictedName.startsWith(baseNameLower) && restrictedName !== baseNameLower + ); - const restrictedPrefix = [...restrictedFilenames, ...strapiConfigFilenames].find( - (restrictedName) => restrictedName.startsWith(baseNameLower) && restrictedName !== baseNameLower - ); + if (restrictedPrefix) { + console.warn(`Config file not loaded, filename cannot start with ${restrictedPrefix}: ${entry.name}`); + return acc; + } - if (restrictedPrefix) { - console.warn(`Config file not loaded, filename cannot start with ${restrictedPrefix}: ${entry.name}`); - return acc; - } + if (seenFilenames.has(baseNameLower)) { + console.warn(`Config file not loaded, case-insensitive name matches other config file: ${entry.name}`); + return acc; + } - if (seenFilenames.has(baseNameLower)) { - console.warn(`Config file not loaded, case-insensitive name matches other config file: ${entry.name}`); - return acc; - } + seenFilenames.add(baseNameLower); + acc.push(entry); + return acc; + }, []); - seenFilenames.add(baseNameLower); - acc.push(entry); - return acc; - }, []); + return configFiles.reduce((acc, entry) => { + const extension = path.extname(entry.name); + const key = path.basename(entry.name, extension); + const filePath = path.resolve(dir, entry.name); - return configFiles.reduce((acc, entry) => { - const extension = path.extname(entry.name); - const key = path.basename(entry.name, extension); - const filePath = path.resolve(dir, entry.name); + acc[key] = loadConfigFileModule.loadConfigFile(filePath); + return acc; + }, {}); + }; - acc[key] = loadConfigFileModule.loadConfigFile(filePath); - return acc; - }, {}); - }; + patchedLoadConfigDir.__tsRuntimePatched = true; + require.cache[configLoaderPath].exports = patchedLoadConfigDir; + } - patchedLoadConfigDir.__tsRuntimePatched = true; - require.cache[configLoaderPath].exports = patchedLoadConfigDir; -} + databaseConnection.createConnection = (() => { + const clientMap = { + sqlite: 'sqlite3', + mysql: 'mysql2', + postgres: 'pg', + }; -databaseConnection.createConnection = (() => { - const clientMap = { - sqlite: 'sqlite3', - mysql: 'mysql2', - postgres: 'pg', - }; + return (userConfig, strapiConfig) => { + if (!clientMap[userConfig.client]) { + throw new Error(`Unsupported database client ${userConfig.client}`); + } - return (userConfig, strapiConfig) => { - if (!clientMap[userConfig.client]) { - throw new Error(`Unsupported database client ${userConfig.client}`); - } + const knexConfig = { + ...userConfig, + client: clientMap[userConfig.client], + }; - const knexConfig = { - ...userConfig, - client: clientMap[userConfig.client], - }; + if (strapiConfig?.pool?.afterCreate) { + knexConfig.pool = knexConfig.pool || {}; - if (strapiConfig?.pool?.afterCreate) { - knexConfig.pool = knexConfig.pool || {}; + const userAfterCreate = knexConfig.pool?.afterCreate; + const strapiAfterCreate = strapiConfig.pool.afterCreate; - const userAfterCreate = knexConfig.pool?.afterCreate; - const strapiAfterCreate = strapiConfig.pool.afterCreate; + knexConfig.pool.afterCreate = (conn, done) => { + strapiAfterCreate(conn, (err, nativeConn) => { + if (err) { + return done(err, nativeConn); + } - knexConfig.pool.afterCreate = (conn, done) => { - strapiAfterCreate(conn, (err, nativeConn) => { - if (err) { - return done(err, nativeConn); - } + if (userAfterCreate) { + return userAfterCreate(nativeConn, done); + } - if (userAfterCreate) { - return userAfterCreate(nativeConn, done); - } + return done(null, nativeConn); + }); + }; + } - return done(null, nativeConn); - }); + return knexFactory(knexConfig); }; + })(); + + if (typeof jest !== 'undefined' && typeof jest.setTimeout === 'function') { + jest.setTimeout(30000); } - return knexFactory(knexConfig); - }; -})(); - -if (typeof jest !== 'undefined' && typeof jest.setTimeout === 'function') { - jest.setTimeout(30000); -} - -const { createStrapi } = require('@strapi/strapi'); - -process.env.NODE_ENV = process.env.NODE_ENV || 'test'; -process.env.APP_KEYS = process.env.APP_KEYS || 'testKeyOne,testKeyTwo'; -process.env.API_TOKEN_SALT = process.env.API_TOKEN_SALT || 'test-api-token-salt'; -process.env.ADMIN_JWT_SECRET = process.env.ADMIN_JWT_SECRET || 'test-admin-jwt-secret'; -process.env.TRANSFER_TOKEN_SALT = process.env.TRANSFER_TOKEN_SALT || 'test-transfer-token-salt'; -process.env.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || '0123456789abcdef0123456789abcdef'; -process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret'; -process.env.DATABASE_CLIENT = process.env.DATABASE_CLIENT || 'sqlite'; -process.env.DATABASE_FILENAME = process.env.DATABASE_FILENAME || ':memory:'; -process.env.STRAPI_DISABLE_CRON = 'true'; -process.env.PORT = process.env.PORT || '0'; - -const databaseClient = process.env.DATABASE_CLIENT || 'sqlite'; -const clientMap = { - sqlite: 'sqlite3', - 'better-sqlite3': 'sqlite3', - mysql: 'mysql2', - postgres: 'pg', -}; + const { createStrapi } = require('@strapi/strapi'); + + process.env.NODE_ENV = process.env.NODE_ENV || 'test'; + process.env.APP_KEYS = process.env.APP_KEYS || 'testKeyOne,testKeyTwo'; + process.env.API_TOKEN_SALT = process.env.API_TOKEN_SALT || 'test-api-token-salt'; + process.env.ADMIN_JWT_SECRET = process.env.ADMIN_JWT_SECRET || 'test-admin-jwt-secret'; + process.env.TRANSFER_TOKEN_SALT = process.env.TRANSFER_TOKEN_SALT || 'test-transfer-token-salt'; + process.env.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || '0123456789abcdef0123456789abcdef'; + process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret'; + process.env.DATABASE_CLIENT = process.env.DATABASE_CLIENT || 'sqlite'; + process.env.DATABASE_FILENAME = process.env.DATABASE_FILENAME || ':memory:'; + process.env.STRAPI_DISABLE_CRON = 'true'; + process.env.PORT = process.env.PORT || '0'; + + const databaseClient = process.env.DATABASE_CLIENT || 'sqlite'; + const clientMap = { + sqlite: 'sqlite3', + 'better-sqlite3': 'sqlite3', + mysql: 'mysql2', + postgres: 'pg', + }; -const driver = clientMap[databaseClient]; - -if (!driver) { - throw new Error(`Unsupported database client "${databaseClient}".`); -} - -if (databaseClient === 'better-sqlite3') { - process.env.DATABASE_CLIENT = 'sqlite'; -} - -require(driver); - -let instance; - -async function setupStrapi() { - if (!instance) { - instance = await createStrapi().load(); - const contentApi = instance.server?.api?.('content-api'); - if (contentApi && !instance.__helloRouteRegistered) { - const createHelloService = require(path.join( - __dirname, - '..', - 'src', - 'api', - 'hello', - 'services', - 'hello' - )); - const helloService = createHelloService({ strapi: instance }); - - contentApi.routes([ - { - method: 'GET', - path: '/hello', - handler: async (ctx) => { - ctx.body = await helloService.getMessage(); - }, - config: { - auth: false, - }, - }, - ]); + const driver = clientMap[databaseClient]; - contentApi.mount(instance.server.router); - instance.__helloRouteRegistered = true; + if (!driver) { + throw new Error(`Unsupported database client "${databaseClient}".`); } - await instance.start(); - global.strapi = instance; - const userService = strapi.plugins['users-permissions']?.services?.user; - if (userService) { - const originalAdd = userService.add.bind(userService); - - userService.add = async (values) => { - const data = { ...values }; + if (databaseClient === 'better-sqlite3') { + process.env.DATABASE_CLIENT = 'sqlite'; + } - if (!data.role) { - const defaultRole = await strapi.db - .query('plugin::users-permissions.role') - .findOne({ where: { type: 'authenticated' } }); + require(driver); + + let instance; + + async function setupStrapi() { + if (!instance) { + instance = await createStrapi().load(); + const contentApi = instance.server?.api?.('content-api'); + if (contentApi && !instance.__helloRouteRegistered) { + const createHelloService = require(path.join( + __dirname, + '..', + 'src', + 'api', + 'hello', + 'services', + 'hello' + )); + const helloService = createHelloService({ strapi: instance }); + + contentApi.routes([ + { + method: 'GET', + path: '/hello', + handler: async (ctx) => { + ctx.body = await helloService.getMessage(); + }, + config: { + auth: false, + }, + }, + ]); - if (defaultRole) { - data.role = defaultRole.id; - } + contentApi.mount(instance.server.router); + instance.__helloRouteRegistered = true; } + await instance.start(); + global.strapi = instance; - return originalAdd(data); - }; + const userService = strapi.plugins['users-permissions']?.services?.user; + if (userService) { + const originalAdd = userService.add.bind(userService); + + userService.add = async (values) => { + const data = { ...values }; + + if (!data.role) { + const defaultRole = await strapi.db + .query('plugin::users-permissions.role') + .findOne({ where: { type: 'authenticated' } }); + + if (defaultRole) { + data.role = defaultRole.id; + } + } + + return originalAdd(data); + }; + } + } + return instance; } - } - return instance; -} -async function cleanupStrapi() { - if (!global.strapi) { - return; - } + async function cleanupStrapi() { + if (!global.strapi) { + return; + } - const dbSettings = strapi.config.get('database.connection'); + const dbSettings = strapi.config.get('database.connection'); - await strapi.server.httpServer.close(); - await strapi.db.connection.destroy(); + await strapi.server.httpServer.close(); + await strapi.db.connection.destroy(); - if (typeof strapi.destroy === 'function') { - await strapi.destroy(); - } + if (typeof strapi.destroy === 'function') { + await strapi.destroy(); + } - if (dbSettings && dbSettings.connection && dbSettings.connection.filename) { - const tmpDbFile = dbSettings.connection.filename; - if (fs.existsSync(tmpDbFile)) { - fs.unlinkSync(tmpDbFile); + if (dbSettings && dbSettings.connection && dbSettings.connection.filename) { + const tmpDbFile = dbSettings.connection.filename; + if (fs.existsSync(tmpDbFile)) { + fs.unlinkSync(tmpDbFile); + } + } } - } -} -module.exports = { setupStrapi, cleanupStrapi }; -``` + module.exports = { setupStrapi, cleanupStrapi }; + ``` -## Create smoke tests + -With the harness in place you can confirm Strapi boots correctly by adding a minimal Jest suite. +Once these files are handed, the harness handles several Strapi v5 requirements: + +* It registers a fallback TypeScript loader so TypeScript configuration files (`server.ts`, `database.ts`, etc.) can be consumed by Jest. +* It patches Strapi's configuration loader to recognise `.ts`, `.cts`, and `.mts` files while preserving warnings for unsupported filenames. +* It normalises database client selection for sqlite, MySQL, and PostgreSQL in tests. +* It automatically exposes a `/hello` content API route backed by the `hello` service and ensures the authenticated role is applied to newly created users. + +## Create smoke tests -Create `tests/app.test.js`: +With the harness in place you can confirm Strapi boots correctly by adding a minimal Jest suite in a `tests/app.test.js` as follows: ```js title="./tests/app.test.js" const { setupStrapi, cleanupStrapi } = require('./strapi'); @@ -673,13 +671,15 @@ require('./user'); Running `yarn test` or `npm run test` should now yield: ```bash -yarn test -$ jest - PASS tests/app.test.js - βœ“ strapi is defined (4 ms) - βœ“ should return hello world (15 ms) - User API - βœ“ should return users data for authenticated user (18 ms) +PASS tests/create-service.test.js +PASS tests/todo-controller.test.js + +Test Suites: 6 passed, 6 total +Tests: 7 passed, 7 total +Snapshots: 0 total +Time: 7.952 s +Ran all test suites. +✨ Done in 8.63s. ``` :::caution diff --git a/docusaurus/static/llms-full.txt b/docusaurus/static/llms-full.txt index f0716d3841..270c2386a7 100644 --- a/docusaurus/static/llms-full.txt +++ b/docusaurus/static/llms-full.txt @@ -12052,603 +12052,23 @@ Source: https://docs.strapi.io/cms/testing # Unit testing guide -This guide covers how to configure Jest in a Strapi v5 application, mock the Strapi object to unit test plugin code, and use Supertest to exercise REST endpoints end-to-end. The examples below are extracted from the `strapi-unit-testing-examples` repository and reflect the test utilities currently available on the `main` branch. +The present guide provides a hands-on approach to configuring -With this guide you will use - - - -Update the `package.json` file of your Strapi project with the following: - -* Add a `test` command to the `scripts` section so it looks as follows: - - ```json {12} - "scripts": { - "build": "strapi build", - "console": "strapi console", - "deploy": "strapi deploy", - "dev": "strapi develop", - "develop": "strapi develop", - "seed:example": "node ./scripts/seed.js", - "start": "strapi start", - "strapi": "strapi", - "upgrade": "npx @strapi/upgrade latest", - "upgrade:dry": "npx @strapi/upgrade latest --dry", - "test": "jest --forceExit --detectOpenHandles" - }, - ``` - -* Configure Jest at the bottom of the file to ignore Strapi build artifacts and to map any root-level modules you import from tests: - - ```json - "jest": { - "testPathIgnorePatterns": [ - "/node_modules/", - ".tmp", - ".cache" - ], - "testEnvironment": "node", - "moduleNameMapper": { - "^/create-service$": "/create-service" - } - } - ``` - -## Mock Strapi for plugin unit tests - -Pure unit tests are ideal for Strapi plugins because they let you validate controller and service logic without starting a Strapi server. Use Jest's mocking utilities to recreate just the parts of the Strapi object and any request context that your code relies on. - -### Controller example - -Create a test file such as `./tests/todo-controller.test.js` that instantiates your controller with a mocked Strapi object and verifies every call the controller performs: - -```js title="./tests/todo-controller.test.js" -const todoController = require('./todo-controller'); - -describe('Todo controller', () => { - let strapi; - - beforeEach(() => { - strapi = { - plugin: jest.fn().mockReturnValue({ - service: jest.fn().mockReturnValue({ - create: jest.fn().mockReturnValue({ - data: { - name: 'test', - status: false, - }, - }), - complete: jest.fn().mockReturnValue({ - data: { - id: 1, - status: true, - }, - }), - }), - }), - }; - }); - - it('creates a todo item', async () => { - const ctx = { - request: { - body: { - name: 'test', - }, - }, - body: null, - }; - - await todoController({ strapi }).index(ctx); - - expect(ctx.body).toBe('created'); - expect(strapi.plugin('todo').service('create').create).toHaveBeenCalledTimes(1); - }); - - it('completes a todo item', async () => { - const ctx = { - request: { - body: { - id: 1, - }, - }, - body: null, - }; - - await todoController({ strapi }).complete(ctx); - - expect(ctx.body).toBe('todo completed'); - expect(strapi.plugin('todo').service('complete').complete).toHaveBeenCalledTimes(1); - }); -}); -``` - -The `beforeEach` hook rebuilds the mock so every test starts with a clean Strapi instance. Each test prepares the `ctx` request object that the controller expects, calls the controller function, and asserts both the response and the interactions with Strapi services. - -### Service example - -Services can be tested in the same test suite or in a dedicated file by mocking only the Strapi query layer they call into. - -```js title="./tests/create-service.test.js" -const createService = require('./create-service'); - -describe('Create service', () => { - let strapi; - - beforeEach(() => { - strapi = { - query: jest.fn().mockReturnValue({ - create: jest.fn().mockReturnValue({ - data: { - name: 'test', - status: false, - }, - }), - }), - }; - }); - - it('persists a todo item', async () => { - const todo = await createService({ strapi }).create({ name: 'test' }); - - expect(strapi.query('plugin::todo.todo').create).toHaveBeenCalledTimes(1); - expect(todo.data.name).toBe('test'); - }); -}); -``` - -By focusing on mocking the specific Strapi APIs your code touches, you can grow these tests to cover additional branches, error cases, and services while keeping them fast and isolated. - -## Set up a testing environment - -For API-level testing with Supertest, the framework must have a clean empty environment to perform valid tests and also not to interfere with your development database. - -Once `jest` is running it uses the `test` [environment](/cms/configurations/environment), so create `./config/env/test/database.js` with the following: - -```js title="./config/env/test/database.js" -module.exports = ({ env }) => { - const filename = env('DATABASE_FILENAME', '.tmp/test.db'); - const rawClient = env('DATABASE_CLIENT', 'sqlite'); - const client = ['sqlite3', 'better-sqlite3'].includes(rawClient) ? 'sqlite' : rawClient; - - return { - connection: { - client, - connection: { - filename, - }, - useNullAsDefault: true, - }, - }; -}; -``` - -This configuration mirrors the defaults used in production but converts `better-sqlite3` to the `sqlite` client Strapi expects. + -## Create the Strapi test harness + * `Jest` provides the test runner and assertion utilities. + * `Supertest` allows you to test all the `api` routes as they were instances of -Create a `tests` folder in your project root and add the utilities below. The harness now handles several Strapi v5 requirements: +Once these files are handed, the harness handles several Strapi v5 requirements: * It registers a fallback TypeScript loader so TypeScript configuration files (`server.ts`, `database.ts`, etc.) can be consumed by Jest. * It patches Strapi's configuration loader to recognise `.ts`, `.cts`, and `.mts` files while preserving warnings for unsupported filenames. * It normalises database client selection for sqlite, MySQL, and PostgreSQL in tests. * It automatically exposes a `/hello` content API route backed by the `hello` service and ensures the authenticated role is applied to newly created users. -Create `tests/ts-compiler-options.js`: - -```js title="./tests/ts-compiler-options.js" -const fs = require('fs'); -const path = require('path'); -const ts = require('typescript'); - -const projectRoot = path.resolve(__dirname, '..'); -const tsconfigPath = path.join(projectRoot, 'tsconfig.json'); - -const baseCompilerOptions = { - module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ES2019, - moduleResolution: ts.ModuleResolutionKind.NodeJs, - esModuleInterop: true, - jsx: ts.JsxEmit.React, -}; - -const loadCompilerOptions = () => { - let options = { ...baseCompilerOptions }; - - if (!fs.existsSync(tsconfigPath)) { - return options; - } - - try { - const tsconfigContent = fs.readFileSync(tsconfigPath, 'utf8'); - const parsed = ts.parseConfigFileTextToJson(tsconfigPath, tsconfigContent); - - if (!parsed.error && parsed.config && parsed.config.compilerOptions) { - options = { - ...options, - ...parsed.config.compilerOptions, - }; - } - } catch (error) { - // Ignore tsconfig parsing errors and fallback to defaults - } - - return options; -}; - -module.exports = { - compilerOptions: loadCompilerOptions(), - loadCompilerOptions, -}; -``` - -Create `tests/ts-runtime.js`: - -```js title="./tests/ts-runtime.js" -const Module = require('module'); -const { compilerOptions } = require('./ts-compiler-options'); -const fs = require('fs'); -const ts = require('typescript'); - -const extensions = Module._extensions; - -if (!extensions['.ts']) { - extensions['.ts'] = function compileTS(module, filename) { - const source = fs.readFileSync(filename, 'utf8'); - const output = ts.transpileModule(source, { - compilerOptions, - fileName: filename, - reportDiagnostics: false, - }); - - return module._compile(output.outputText, filename); - }; -} - -if (!extensions['.tsx']) { - extensions['.tsx'] = extensions['.ts']; -} - -module.exports = { - compilerOptions, -}; -``` - -Finally, create `tests/strapi.js`: - -```js title="./tests/strapi.js" -try { - require('ts-node/register/transpile-only'); -} catch (err) { - try { - require('@strapi/typescript-utils/register'); - } catch (strapiRegisterError) { - require('./ts-runtime'); - } -} - -const fs = require('fs'); -const path = require('path'); -const Module = require('module'); -const ts = require('typescript'); -const databaseConnection = require('@strapi/database/dist/connection.js'); -const knexFactory = require('knex'); -const strapiCoreRoot = path.dirname(require.resolve('@strapi/core/package.json')); -const loadConfigFilePath = path.join(strapiCoreRoot, 'dist', 'utils', 'load-config-file.js'); -const loadConfigFileModule = require(loadConfigFilePath); -const { compilerOptions: baseCompilerOptions } = require('./ts-compiler-options'); - -if (!loadConfigFileModule.loadConfigFile.__tsRuntimePatched) { - const strapiUtils = require('@strapi/utils'); - const originalLoadConfigFile = loadConfigFileModule.loadConfigFile; - - const loadTypeScriptConfig = (file) => { - const source = fs.readFileSync(file, 'utf8'); - const options = { - ...baseCompilerOptions, - module: ts.ModuleKind.CommonJS, - }; - - const output = ts.transpileModule(source, { - compilerOptions: options, - fileName: file, - reportDiagnostics: false, - }); - - const moduleInstance = new Module(file); - moduleInstance.filename = file; - moduleInstance.paths = Module._nodeModulePaths(path.dirname(file)); - moduleInstance._compile(output.outputText, file); - - const exported = moduleInstance.exports; - const resolved = exported && exported.__esModule ? exported.default : exported; - - if (typeof resolved === 'function') { - return resolved({ env: strapiUtils.env }); - } - - return resolved; - }; - - const patchedLoadConfigFile = (file) => { - const extension = path.extname(file).toLowerCase(); - - if (extension === '.ts' || extension === '.cts' || extension === '.mts') { - return loadTypeScriptConfig(file); - } - - return originalLoadConfigFile(file); - }; - - patchedLoadConfigFile.__tsRuntimePatched = true; - loadConfigFileModule.loadConfigFile = patchedLoadConfigFile; - require.cache[loadConfigFilePath].exports = loadConfigFileModule; -} - -const configLoaderPath = path.join(strapiCoreRoot, 'dist', 'configuration', 'config-loader.js'); -const originalLoadConfigDir = require(configLoaderPath); -const validExtensions = ['.js', '.json', '.ts', '.cts', '.mts']; -const mistakenFilenames = { - middleware: 'middlewares', - plugin: 'plugins', -}; -const restrictedFilenames = [ - 'uuid', - 'hosting', - 'license', - 'enforce', - 'disable', - 'enable', - 'telemetry', - 'strapi', - 'internal', - 'launchedAt', - 'serveAdminPanel', - 'autoReload', - 'environment', - 'packageJsonStrapi', - 'info', - 'dirs', - ...Object.keys(mistakenFilenames), -]; -const strapiConfigFilenames = ['admin', 'server', 'api', 'database', 'middlewares', 'plugins', 'features']; - -if (!originalLoadConfigDir.__tsRuntimePatched) { - const patchedLoadConfigDir = (dir) => { - if (!fs.existsSync(dir)) { - return {}; - } - - const entries = fs.readdirSync(dir, { withFileTypes: true }); - const seenFilenames = new Set(); - - const configFiles = entries.reduce((acc, entry) => { - if (!entry.isFile()) { - return acc; - } - - const extension = path.extname(entry.name); - const extensionLower = extension.toLowerCase(); - const baseName = path.basename(entry.name, extension); - const baseNameLower = baseName.toLowerCase(); - - if (!validExtensions.includes(extensionLower)) { - console.warn(`Config file not loaded, extension must be one of ${validExtensions.join(',')}): ${entry.name}`); - return acc; - } - - if (restrictedFilenames.includes(baseNameLower)) { - console.warn(`Config file not loaded, restricted filename: ${entry.name}`); - if (baseNameLower in mistakenFilenames) { - console.log(`Did you mean ${mistakenFilenames[baseNameLower]}?`); - } - return acc; - } - - const restrictedPrefix = [...restrictedFilenames, ...strapiConfigFilenames].find( - (restrictedName) => restrictedName.startsWith(baseNameLower) && restrictedName !== baseNameLower - ); - - if (restrictedPrefix) { - console.warn(`Config file not loaded, filename cannot start with ${restrictedPrefix}: ${entry.name}`); - return acc; - } - - if (seenFilenames.has(baseNameLower)) { - console.warn(`Config file not loaded, case-insensitive name matches other config file: ${entry.name}`); - return acc; - } - - seenFilenames.add(baseNameLower); - acc.push(entry); - return acc; - }, []); - - return configFiles.reduce((acc, entry) => { - const extension = path.extname(entry.name); - const key = path.basename(entry.name, extension); - const filePath = path.resolve(dir, entry.name); - - acc[key] = loadConfigFileModule.loadConfigFile(filePath); - return acc; - }, {}); - }; - - patchedLoadConfigDir.__tsRuntimePatched = true; - require.cache[configLoaderPath].exports = patchedLoadConfigDir; -} - -databaseConnection.createConnection = (() => { - const clientMap = { - sqlite: 'sqlite3', - mysql: 'mysql2', - postgres: 'pg', - }; - - return (userConfig, strapiConfig) => { - if (!clientMap[userConfig.client]) { - throw new Error(`Unsupported database client ${userConfig.client}`); - } - - const knexConfig = { - ...userConfig, - client: clientMap[userConfig.client], - }; - - if (strapiConfig?.pool?.afterCreate) { - knexConfig.pool = knexConfig.pool || {}; - - const userAfterCreate = knexConfig.pool?.afterCreate; - const strapiAfterCreate = strapiConfig.pool.afterCreate; - - knexConfig.pool.afterCreate = (conn, done) => { - strapiAfterCreate(conn, (err, nativeConn) => { - if (err) { - return done(err, nativeConn); - } - - if (userAfterCreate) { - return userAfterCreate(nativeConn, done); - } - - return done(null, nativeConn); - }); - }; - } - - return knexFactory(knexConfig); - }; -})(); - -if (typeof jest !== 'undefined' && typeof jest.setTimeout === 'function') { - jest.setTimeout(30000); -} - -const { createStrapi } = require('@strapi/strapi'); - -process.env.NODE_ENV = process.env.NODE_ENV || 'test'; -process.env.APP_KEYS = process.env.APP_KEYS || 'testKeyOne,testKeyTwo'; -process.env.API_TOKEN_SALT = process.env.API_TOKEN_SALT || 'test-api-token-salt'; -process.env.ADMIN_JWT_SECRET = process.env.ADMIN_JWT_SECRET || 'test-admin-jwt-secret'; -process.env.TRANSFER_TOKEN_SALT = process.env.TRANSFER_TOKEN_SALT || 'test-transfer-token-salt'; -process.env.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || '0123456789abcdef0123456789abcdef'; -process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret'; -process.env.DATABASE_CLIENT = process.env.DATABASE_CLIENT || 'sqlite'; -process.env.DATABASE_FILENAME = process.env.DATABASE_FILENAME || ':memory:'; -process.env.STRAPI_DISABLE_CRON = 'true'; -process.env.PORT = process.env.PORT || '0'; - -const databaseClient = process.env.DATABASE_CLIENT || 'sqlite'; -const clientMap = { - sqlite: 'sqlite3', - 'better-sqlite3': 'sqlite3', - mysql: 'mysql2', - postgres: 'pg', -}; - -const driver = clientMap[databaseClient]; - -if (!driver) { - throw new Error(`Unsupported database client "${databaseClient}".`); -} - -if (databaseClient === 'better-sqlite3') { - process.env.DATABASE_CLIENT = 'sqlite'; -} - -require(driver); - -let instance; - -async function setupStrapi() { - if (!instance) { - instance = await createStrapi().load(); - const contentApi = instance.server?.api?.('content-api'); - if (contentApi && !instance.__helloRouteRegistered) { - const createHelloService = require(path.join( - __dirname, - '..', - 'src', - 'api', - 'hello', - 'services', - 'hello' - )); - const helloService = createHelloService({ strapi: instance }); - - contentApi.routes([ - { - method: 'GET', - path: '/hello', - handler: async (ctx) => { - ctx.body = await helloService.getMessage(); - }, - config: { - auth: false, - }, - }, - ]); - - contentApi.mount(instance.server.router); - instance.__helloRouteRegistered = true; - } - await instance.start(); - global.strapi = instance; - - const userService = strapi.plugins['users-permissions']?.services?.user; - if (userService) { - const originalAdd = userService.add.bind(userService); - - userService.add = async (values) => { - const data = { ...values }; - - if (!data.role) { - const defaultRole = await strapi.db - .query('plugin::users-permissions.role') - .findOne({ where: { type: 'authenticated' } }); - - if (defaultRole) { - data.role = defaultRole.id; - } - } - - return originalAdd(data); - }; - } - } - return instance; -} - -async function cleanupStrapi() { - if (!global.strapi) { - return; - } - - const dbSettings = strapi.config.get('database.connection'); - - await strapi.server.httpServer.close(); - await strapi.db.connection.destroy(); - - if (typeof strapi.destroy === 'function') { - await strapi.destroy(); - } - - if (dbSettings && dbSettings.connection && dbSettings.connection.filename) { - const tmpDbFile = dbSettings.connection.filename; - if (fs.existsSync(tmpDbFile)) { - fs.unlinkSync(tmpDbFile); - } - } -} - -module.exports = { setupStrapi, cleanupStrapi }; -``` - ## Create smoke tests -With the harness in place you can confirm Strapi boots correctly by adding a minimal Jest suite. - -Create `tests/app.test.js`: +With the harness in place you can confirm Strapi boots correctly by adding a minimal Jest suite in a `tests/app.test.js` as follows: ```js title="./tests/app.test.js" const { setupStrapi, cleanupStrapi } = require('./strapi'); @@ -12674,13 +12094,15 @@ require('./user'); Running `yarn test` or `npm run test` should now yield: ```bash -yarn test -$ jest - PASS tests/app.test.js - βœ“ strapi is defined (4 ms) - βœ“ should return hello world (15 ms) - User API - βœ“ should return users data for authenticated user (18 ms) +PASS tests/create-service.test.js +PASS tests/todo-controller.test.js + +Test Suites: 6 passed, 6 total +Tests: 7 passed, 7 total +Snapshots: 0 total +Time: 7.952 s +Ran all test suites. +✨ Done in 8.63s. ``` :::caution From 9fd72eff075357fc2e6eb7330dc6cb3b1ce684b9 Mon Sep 17 00:00:00 2001 From: Pierre Wizla Date: Fri, 3 Oct 2025 16:28:55 +0200 Subject: [PATCH 5/7] Add more explanations --- docusaurus/docs/cms/testing.md | 756 +++++++++++++++++--------------- docusaurus/static/llms-full.txt | 25 +- 2 files changed, 425 insertions(+), 356 deletions(-) diff --git a/docusaurus/docs/cms/testing.md b/docusaurus/docs/cms/testing.md index 1bf32cb019..1c0fca8e11 100644 --- a/docusaurus/docs/cms/testing.md +++ b/docusaurus/docs/cms/testing.md @@ -89,7 +89,7 @@ We'll first install test tools, add a command to run our tests, and configure Je ## Mock Strapi for plugin unit tests -Pure unit tests are ideal for Strapi plugins because they let you validate controller and service logic without starting a Strapi server. Use Jest's mocking utilities to recreate just the parts of the Strapi object and any request context that your code relies on. +Pure unit tests are ideal for Strapi plugins because they let you validate controller and service logic without starting a Strapi server. Use Jest's **mocking** Mocking is a testing technique where you create fake versions of parts of your application (like services or database calls) to test code in isolation. Instead of connecting to a real database or calling actual services, the mock returns predefined responses, making tests faster and more predictable. utilities to recreate just the parts of the Strapi object and any request context that your code relies on. ### Controller example @@ -220,432 +220,486 @@ This configuration mirrors the defaults used in production but converts `better- ## Create the Strapi test harness -We will create a `tests` folder in your project root and add the example files below. +We will create a `tests` folder in your project root and add the example files below. These 3 files work together to create a complete testing infrastructure: -1. Create `tests/ts-compiler-options.js`: +* `ts-compiler-options.js` defines how TypeScript files should be compiled for testing +* `ts-runtime.js` enables Jest to understand and execute TypeScript files on the fly +* `strapi.js` is the main **test harness** A test harness is a collection of software and test data configured to test an application by running it in predefined conditions and monitoring its behavior.

In the present case, our test harness sets up a complete Strapi instance in an isolated testing environment, handles TypeScript files, and provides utilities to make testing easier.
that sets up and tears down Strapi instances for tests - ```js title="./tests/ts-compiler-options.js" - const fs = require('fs'); - const path = require('path'); - const ts = require('typescript'); +### TypeScript compiler configuration - const projectRoot = path.resolve(__dirname, '..'); - const tsconfigPath = path.join(projectRoot, 'tsconfig.json'); +Create `tests/ts-compiler-options.js` with the following content: - const baseCompilerOptions = { - module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ES2019, - moduleResolution: ts.ModuleResolutionKind.NodeJs, - esModuleInterop: true, - jsx: ts.JsxEmit.React, - }; - - const loadCompilerOptions = () => { - let options = { ...baseCompilerOptions }; - - if (!fs.existsSync(tsconfigPath)) { - return options; - } - - try { - const tsconfigContent = fs.readFileSync(tsconfigPath, 'utf8'); - const parsed = ts.parseConfigFileTextToJson(tsconfigPath, tsconfigContent); +```js title="./tests/ts-compiler-options.js" +const fs = require('fs'); +const path = require('path'); +const ts = require('typescript'); - if (!parsed.error && parsed.config && parsed.config.compilerOptions) { - options = { - ...options, - ...parsed.config.compilerOptions, - }; - } - } catch (error) { - // Ignore tsconfig parsing errors and fallback to defaults - } - - return options; - }; +const projectRoot = path.resolve(__dirname, '..'); +const tsconfigPath = path.join(projectRoot, 'tsconfig.json'); - module.exports = { - compilerOptions: loadCompilerOptions(), - loadCompilerOptions, - }; - ``` - -2. Create `tests/ts-runtime.js`: +const baseCompilerOptions = { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2019, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + esModuleInterop: true, + jsx: ts.JsxEmit.React, +}; - ```js title="./tests/ts-runtime.js" - const Module = require('module'); - const { compilerOptions } = require('./ts-compiler-options'); - const fs = require('fs'); - const ts = require('typescript'); +const loadCompilerOptions = () => { + let options = { ...baseCompilerOptions }; - const extensions = Module._extensions; + if (!fs.existsSync(tsconfigPath)) { + return options; + } - if (!extensions['.ts']) { - extensions['.ts'] = function compileTS(module, filename) { - const source = fs.readFileSync(filename, 'utf8'); - const output = ts.transpileModule(source, { - compilerOptions, - fileName: filename, - reportDiagnostics: false, - }); + try { + const tsconfigContent = fs.readFileSync(tsconfigPath, 'utf8'); + const parsed = ts.parseConfigFileTextToJson(tsconfigPath, tsconfigContent); - return module._compile(output.outputText, filename); + if (!parsed.error && parsed.config && parsed.config.compilerOptions) { + options = { + ...options, + ...parsed.config.compilerOptions, }; } + } catch (error) { + // Ignore tsconfig parsing errors and fallback to defaults + } - if (!extensions['.tsx']) { - extensions['.tsx'] = extensions['.ts']; - } - - module.exports = { - compilerOptions, - }; - ``` - -3. Finally, create `tests/strapi.js`: - - + return options; +}; - ```js title="./tests/strapi.js" - try { - require('ts-node/register/transpile-only'); - } catch (err) { - try { - require('@strapi/typescript-utils/register'); - } catch (strapiRegisterError) { - require('./ts-runtime'); - } - } +module.exports = { + compilerOptions: loadCompilerOptions(), + loadCompilerOptions, +}; +``` - const fs = require('fs'); - const path = require('path'); - const Module = require('module'); - const ts = require('typescript'); - const databaseConnection = require('@strapi/database/dist/connection.js'); - const knexFactory = require('knex'); - const strapiCoreRoot = path.dirname(require.resolve('@strapi/core/package.json')); - const loadConfigFilePath = path.join(strapiCoreRoot, 'dist', 'utils', 'load-config-file.js'); - const loadConfigFileModule = require(loadConfigFilePath); - const { compilerOptions: baseCompilerOptions } = require('./ts-compiler-options'); - - if (!loadConfigFileModule.loadConfigFile.__tsRuntimePatched) { - const strapiUtils = require('@strapi/utils'); - const originalLoadConfigFile = loadConfigFileModule.loadConfigFile; - - const loadTypeScriptConfig = (file) => { - const source = fs.readFileSync(file, 'utf8'); - const options = { - ...baseCompilerOptions, - module: ts.ModuleKind.CommonJS, - }; - - const output = ts.transpileModule(source, { - compilerOptions: options, - fileName: file, - reportDiagnostics: false, - }); +This file loads your project's TypeScript configuration and provides sensible defaults if the config file doesn't exist. - const moduleInstance = new Module(file); - moduleInstance.filename = file; - moduleInstance.paths = Module._nodeModulePaths(path.dirname(file)); - moduleInstance._compile(output.outputText, file); +### TypeScript runtime loader - const exported = moduleInstance.exports; - const resolved = exported && exported.__esModule ? exported.default : exported; +Create `tests/ts-runtime.js` with the following content: - if (typeof resolved === 'function') { - return resolved({ env: strapiUtils.env }); - } +```js title="./tests/ts-runtime.js" +const Module = require('module'); +const { compilerOptions } = require('./ts-compiler-options'); +const fs = require('fs'); +const ts = require('typescript'); - return resolved; - }; +const extensions = Module._extensions; - const patchedLoadConfigFile = (file) => { - const extension = path.extname(file).toLowerCase(); +if (!extensions['.ts']) { + extensions['.ts'] = function compileTS(module, filename) { + const source = fs.readFileSync(filename, 'utf8'); + const output = ts.transpileModule(source, { + compilerOptions, + fileName: filename, + reportDiagnostics: false, + }); - if (extension === '.ts' || extension === '.cts' || extension === '.mts') { - return loadTypeScriptConfig(file); - } + return module._compile(output.outputText, filename); + }; +} - return originalLoadConfigFile(file); - }; +if (!extensions['.tsx']) { + extensions['.tsx'] = extensions['.ts']; +} - patchedLoadConfigFile.__tsRuntimePatched = true; - loadConfigFileModule.loadConfigFile = patchedLoadConfigFile; - require.cache[loadConfigFilePath].exports = loadConfigFileModule; - } +module.exports = { + compilerOptions, +}; +``` - const configLoaderPath = path.join(strapiCoreRoot, 'dist', 'configuration', 'config-loader.js'); - const originalLoadConfigDir = require(configLoaderPath); - const validExtensions = ['.js', '.json', '.ts', '.cts', '.mts']; - const mistakenFilenames = { - middleware: 'middlewares', - plugin: 'plugins', +This file teaches Node.js how to load `.ts` and `.tsx` files by transpiling them to JavaScript on the fly. + +### Main test harness + +Create `tests/strapi.js` with the following content: + + + +```js title="./tests/strapi.js" +try { + require('ts-node/register/transpile-only'); +} catch (err) { + try { + require('@strapi/typescript-utils/register'); + } catch (strapiRegisterError) { + require('./ts-runtime'); + } +} + +const fs = require('fs'); +const path = require('path'); +const Module = require('module'); +const ts = require('typescript'); +const databaseConnection = require('@strapi/database/dist/connection.js'); +const knexFactory = require('knex'); +const strapiCoreRoot = path.dirname(require.resolve('@strapi/core/package.json')); +const loadConfigFilePath = path.join(strapiCoreRoot, 'dist', 'utils', 'load-config-file.js'); +const loadConfigFileModule = require(loadConfigFilePath); +const { compilerOptions: baseCompilerOptions } = require('./ts-compiler-options'); + +// ============================================ +// 1. PATCH: TypeScript Configuration Loader +// ============================================ +// This section patches Strapi's configuration loader to support TypeScript config files +// (.ts, .cts, .mts). Without this, Strapi would only load .js and .json config files. + +if (!loadConfigFileModule.loadConfigFile.__tsRuntimePatched) { + const strapiUtils = require('@strapi/utils'); + const originalLoadConfigFile = loadConfigFileModule.loadConfigFile; + + const loadTypeScriptConfig = (file) => { + const source = fs.readFileSync(file, 'utf8'); + const options = { + ...baseCompilerOptions, + module: ts.ModuleKind.CommonJS, }; - const restrictedFilenames = [ - 'uuid', - 'hosting', - 'license', - 'enforce', - 'disable', - 'enable', - 'telemetry', - 'strapi', - 'internal', - 'launchedAt', - 'serveAdminPanel', - 'autoReload', - 'environment', - 'packageJsonStrapi', - 'info', - 'dirs', - ...Object.keys(mistakenFilenames), - ]; - const strapiConfigFilenames = ['admin', 'server', 'api', 'database', 'middlewares', 'plugins', 'features']; - - if (!originalLoadConfigDir.__tsRuntimePatched) { - const patchedLoadConfigDir = (dir) => { - if (!fs.existsSync(dir)) { - return {}; - } - const entries = fs.readdirSync(dir, { withFileTypes: true }); - const seenFilenames = new Set(); + const output = ts.transpileModule(source, { + compilerOptions: options, + fileName: file, + reportDiagnostics: false, + }); - const configFiles = entries.reduce((acc, entry) => { - if (!entry.isFile()) { - return acc; - } + const moduleInstance = new Module(file); + moduleInstance.filename = file; + moduleInstance.paths = Module._nodeModulePaths(path.dirname(file)); + moduleInstance._compile(output.outputText, file); - const extension = path.extname(entry.name); - const extensionLower = extension.toLowerCase(); - const baseName = path.basename(entry.name, extension); - const baseNameLower = baseName.toLowerCase(); + const exported = moduleInstance.exports; + const resolved = exported && exported.__esModule ? exported.default : exported; - if (!validExtensions.includes(extensionLower)) { - console.warn(`Config file not loaded, extension must be one of ${validExtensions.join(',')}): ${entry.name}`); - return acc; - } + if (typeof resolved === 'function') { + return resolved({ env: strapiUtils.env }); + } - if (restrictedFilenames.includes(baseNameLower)) { - console.warn(`Config file not loaded, restricted filename: ${entry.name}`); - if (baseNameLower in mistakenFilenames) { - console.log(`Did you mean ${mistakenFilenames[baseNameLower]}?`); - } - return acc; - } + return resolved; + }; - const restrictedPrefix = [...restrictedFilenames, ...strapiConfigFilenames].find( - (restrictedName) => restrictedName.startsWith(baseNameLower) && restrictedName !== baseNameLower - ); + const patchedLoadConfigFile = (file) => { + const extension = path.extname(file).toLowerCase(); - if (restrictedPrefix) { - console.warn(`Config file not loaded, filename cannot start with ${restrictedPrefix}: ${entry.name}`); - return acc; - } + if (extension === '.ts' || extension === '.cts' || extension === '.mts') { + return loadTypeScriptConfig(file); + } - if (seenFilenames.has(baseNameLower)) { - console.warn(`Config file not loaded, case-insensitive name matches other config file: ${entry.name}`); - return acc; - } + return originalLoadConfigFile(file); + }; - seenFilenames.add(baseNameLower); - acc.push(entry); - return acc; - }, []); + patchedLoadConfigFile.__tsRuntimePatched = true; + loadConfigFileModule.loadConfigFile = patchedLoadConfigFile; + require.cache[loadConfigFilePath].exports = loadConfigFileModule; +} + +// ============================================ +// 2. PATCH: Configuration Directory Scanner +// ============================================ +// This section patches how Strapi scans the config directory to: +// - Support TypeScript extensions (.ts, .cts, .mts) +// - Validate config file names +// - Prevent loading of restricted filenames + +const configLoaderPath = path.join(strapiCoreRoot, 'dist', 'configuration', 'config-loader.js'); +const originalLoadConfigDir = require(configLoaderPath); +const validExtensions = ['.js', '.json', '.ts', '.cts', '.mts']; +const mistakenFilenames = { + middleware: 'middlewares', + plugin: 'plugins', +}; +const restrictedFilenames = [ + 'uuid', + 'hosting', + 'license', + 'enforce', + 'disable', + 'enable', + 'telemetry', + 'strapi', + 'internal', + 'launchedAt', + 'serveAdminPanel', + 'autoReload', + 'environment', + 'packageJsonStrapi', + 'info', + 'dirs', + ...Object.keys(mistakenFilenames), +]; +const strapiConfigFilenames = ['admin', 'server', 'api', 'database', 'middlewares', 'plugins', 'features']; + +if (!originalLoadConfigDir.__tsRuntimePatched) { + const patchedLoadConfigDir = (dir) => { + if (!fs.existsSync(dir)) { + return {}; + } - return configFiles.reduce((acc, entry) => { - const extension = path.extname(entry.name); - const key = path.basename(entry.name, extension); - const filePath = path.resolve(dir, entry.name); + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const seenFilenames = new Set(); - acc[key] = loadConfigFileModule.loadConfigFile(filePath); - return acc; - }, {}); - }; + const configFiles = entries.reduce((acc, entry) => { + if (!entry.isFile()) { + return acc; + } - patchedLoadConfigDir.__tsRuntimePatched = true; - require.cache[configLoaderPath].exports = patchedLoadConfigDir; - } + const extension = path.extname(entry.name); + const extensionLower = extension.toLowerCase(); + const baseName = path.basename(entry.name, extension); + const baseNameLower = baseName.toLowerCase(); - databaseConnection.createConnection = (() => { - const clientMap = { - sqlite: 'sqlite3', - mysql: 'mysql2', - postgres: 'pg', - }; + if (!validExtensions.includes(extensionLower)) { + console.warn(`Config file not loaded, extension must be one of ${validExtensions.join(',')}): ${entry.name}`); + return acc; + } - return (userConfig, strapiConfig) => { - if (!clientMap[userConfig.client]) { - throw new Error(`Unsupported database client ${userConfig.client}`); + if (restrictedFilenames.includes(baseNameLower)) { + console.warn(`Config file not loaded, restricted filename: ${entry.name}`); + if (baseNameLower in mistakenFilenames) { + console.log(`Did you mean ${mistakenFilenames[baseNameLower]}?`); } + return acc; + } - const knexConfig = { - ...userConfig, - client: clientMap[userConfig.client], - }; + const restrictedPrefix = [...restrictedFilenames, ...strapiConfigFilenames].find( + (restrictedName) => restrictedName.startsWith(baseNameLower) && restrictedName !== baseNameLower + ); - if (strapiConfig?.pool?.afterCreate) { - knexConfig.pool = knexConfig.pool || {}; + if (restrictedPrefix) { + console.warn(`Config file not loaded, filename cannot start with ${restrictedPrefix}: ${entry.name}`); + return acc; + } - const userAfterCreate = knexConfig.pool?.afterCreate; - const strapiAfterCreate = strapiConfig.pool.afterCreate; + if (seenFilenames.has(baseNameLower)) { + console.warn(`Config file not loaded, case-insensitive name matches other config file: ${entry.name}`); + return acc; + } - knexConfig.pool.afterCreate = (conn, done) => { - strapiAfterCreate(conn, (err, nativeConn) => { - if (err) { - return done(err, nativeConn); - } + seenFilenames.add(baseNameLower); + acc.push(entry); + return acc; + }, []); - if (userAfterCreate) { - return userAfterCreate(nativeConn, done); - } + return configFiles.reduce((acc, entry) => { + const extension = path.extname(entry.name); + const key = path.basename(entry.name, extension); + const filePath = path.resolve(dir, entry.name); - return done(null, nativeConn); - }); - }; - } + acc[key] = loadConfigFileModule.loadConfigFile(filePath); + return acc; + }, {}); + }; - return knexFactory(knexConfig); - }; - })(); + patchedLoadConfigDir.__tsRuntimePatched = true; + require.cache[configLoaderPath].exports = patchedLoadConfigDir; +} + +// ============================================ +// 3. PATCH: Database Connection Handler +// ============================================ +// This section normalizes database client names for testing. +// Maps Strapi's client names (sqlite, mysql, postgres) to actual driver names +// (sqlite3, mysql2, pg) and handles connection pooling. + +databaseConnection.createConnection = (() => { + const clientMap = { + sqlite: 'sqlite3', + mysql: 'mysql2', + postgres: 'pg', + }; - if (typeof jest !== 'undefined' && typeof jest.setTimeout === 'function') { - jest.setTimeout(30000); + return (userConfig, strapiConfig) => { + if (!clientMap[userConfig.client]) { + throw new Error(`Unsupported database client ${userConfig.client}`); } - const { createStrapi } = require('@strapi/strapi'); - - process.env.NODE_ENV = process.env.NODE_ENV || 'test'; - process.env.APP_KEYS = process.env.APP_KEYS || 'testKeyOne,testKeyTwo'; - process.env.API_TOKEN_SALT = process.env.API_TOKEN_SALT || 'test-api-token-salt'; - process.env.ADMIN_JWT_SECRET = process.env.ADMIN_JWT_SECRET || 'test-admin-jwt-secret'; - process.env.TRANSFER_TOKEN_SALT = process.env.TRANSFER_TOKEN_SALT || 'test-transfer-token-salt'; - process.env.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || '0123456789abcdef0123456789abcdef'; - process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret'; - process.env.DATABASE_CLIENT = process.env.DATABASE_CLIENT || 'sqlite'; - process.env.DATABASE_FILENAME = process.env.DATABASE_FILENAME || ':memory:'; - process.env.STRAPI_DISABLE_CRON = 'true'; - process.env.PORT = process.env.PORT || '0'; - - const databaseClient = process.env.DATABASE_CLIENT || 'sqlite'; - const clientMap = { - sqlite: 'sqlite3', - 'better-sqlite3': 'sqlite3', - mysql: 'mysql2', - postgres: 'pg', + const knexConfig = { + ...userConfig, + client: clientMap[userConfig.client], }; - const driver = clientMap[databaseClient]; + if (strapiConfig?.pool?.afterCreate) { + knexConfig.pool = knexConfig.pool || {}; - if (!driver) { - throw new Error(`Unsupported database client "${databaseClient}".`); - } + const userAfterCreate = knexConfig.pool?.afterCreate; + const strapiAfterCreate = strapiConfig.pool.afterCreate; - if (databaseClient === 'better-sqlite3') { - process.env.DATABASE_CLIENT = 'sqlite'; + knexConfig.pool.afterCreate = (conn, done) => { + strapiAfterCreate(conn, (err, nativeConn) => { + if (err) { + return done(err, nativeConn); + } + + if (userAfterCreate) { + return userAfterCreate(nativeConn, done); + } + + return done(null, nativeConn); + }); + }; } - require(driver); - - let instance; - - async function setupStrapi() { - if (!instance) { - instance = await createStrapi().load(); - const contentApi = instance.server?.api?.('content-api'); - if (contentApi && !instance.__helloRouteRegistered) { - const createHelloService = require(path.join( - __dirname, - '..', - 'src', - 'api', - 'hello', - 'services', - 'hello' - )); - const helloService = createHelloService({ strapi: instance }); - - contentApi.routes([ - { - method: 'GET', - path: '/hello', - handler: async (ctx) => { - ctx.body = await helloService.getMessage(); - }, - config: { - auth: false, - }, - }, - ]); + return knexFactory(knexConfig); + }; +})(); + +// ============================================ +// 4. TEST ENVIRONMENT SETUP +// ============================================ +// Configure Jest timeout and set required environment variables for testing + +if (typeof jest !== 'undefined' && typeof jest.setTimeout === 'function') { + jest.setTimeout(30000); +} + +const { createStrapi } = require('@strapi/strapi'); + +process.env.NODE_ENV = process.env.NODE_ENV || 'test'; +process.env.APP_KEYS = process.env.APP_KEYS || 'testKeyOne,testKeyTwo'; +process.env.API_TOKEN_SALT = process.env.API_TOKEN_SALT || 'test-api-token-salt'; +process.env.ADMIN_JWT_SECRET = process.env.ADMIN_JWT_SECRET || 'test-admin-jwt-secret'; +process.env.TRANSFER_TOKEN_SALT = process.env.TRANSFER_TOKEN_SALT || 'test-transfer-token-salt'; +process.env.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || '0123456789abcdef0123456789abcdef'; +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret'; +process.env.DATABASE_CLIENT = process.env.DATABASE_CLIENT || 'sqlite'; +process.env.DATABASE_FILENAME = process.env.DATABASE_FILENAME || ':memory:'; +process.env.STRAPI_DISABLE_CRON = 'true'; +process.env.PORT = process.env.PORT || '0'; + +const databaseClient = process.env.DATABASE_CLIENT || 'sqlite'; +const clientMap = { + sqlite: 'sqlite3', + 'better-sqlite3': 'sqlite3', + mysql: 'mysql2', + postgres: 'pg', +}; - contentApi.mount(instance.server.router); - instance.__helloRouteRegistered = true; - } - await instance.start(); - global.strapi = instance; +const driver = clientMap[databaseClient]; + +if (!driver) { + throw new Error(`Unsupported database client "${databaseClient}".`); +} + +if (databaseClient === 'better-sqlite3') { + process.env.DATABASE_CLIENT = 'sqlite'; +} + +require(driver); + +let instance; + +// ============================================ +// 5. STRAPI INSTANCE MANAGEMENT +// ============================================ +// Functions to set up and tear down a Strapi instance for testing + +async function setupStrapi() { + if (!instance) { + instance = await createStrapi().load(); + + // Register the /api/hello test route automatically + const contentApi = instance.server?.api?.('content-api'); + if (contentApi && !instance.__helloRouteRegistered) { + const createHelloService = require(path.join( + __dirname, + '..', + 'src', + 'api', + 'hello', + 'services', + 'hello' + )); + const helloService = createHelloService({ strapi: instance }); + + contentApi.routes([ + { + method: 'GET', + path: '/hello', + handler: async (ctx) => { + ctx.body = await helloService.getMessage(); + }, + config: { + auth: false, + }, + }, + ]); - const userService = strapi.plugins['users-permissions']?.services?.user; - if (userService) { - const originalAdd = userService.add.bind(userService); + contentApi.mount(instance.server.router); + instance.__helloRouteRegistered = true; + } + + await instance.start(); + global.strapi = instance; - userService.add = async (values) => { - const data = { ...values }; + // Patch the user service to automatically assign the authenticated role + const userService = strapi.plugins['users-permissions']?.services?.user; + if (userService) { + const originalAdd = userService.add.bind(userService); - if (!data.role) { - const defaultRole = await strapi.db - .query('plugin::users-permissions.role') - .findOne({ where: { type: 'authenticated' } }); + userService.add = async (values) => { + const data = { ...values }; - if (defaultRole) { - data.role = defaultRole.id; - } - } + if (!data.role) { + const defaultRole = await strapi.db + .query('plugin::users-permissions.role') + .findOne({ where: { type: 'authenticated' } }); - return originalAdd(data); - }; + if (defaultRole) { + data.role = defaultRole.id; + } } - } - return instance; + + return originalAdd(data); + }; } + } + return instance; +} - async function cleanupStrapi() { - if (!global.strapi) { - return; - } +async function cleanupStrapi() { + if (!global.strapi) { + return; + } - const dbSettings = strapi.config.get('database.connection'); + const dbSettings = strapi.config.get('database.connection'); - await strapi.server.httpServer.close(); - await strapi.db.connection.destroy(); + await strapi.server.httpServer.close(); + await strapi.db.connection.destroy(); - if (typeof strapi.destroy === 'function') { - await strapi.destroy(); - } + if (typeof strapi.destroy === 'function') { + await strapi.destroy(); + } - if (dbSettings && dbSettings.connection && dbSettings.connection.filename) { - const tmpDbFile = dbSettings.connection.filename; - if (fs.existsSync(tmpDbFile)) { - fs.unlinkSync(tmpDbFile); - } - } + if (dbSettings && dbSettings.connection && dbSettings.connection.filename) { + const tmpDbFile = dbSettings.connection.filename; + if (fs.existsSync(tmpDbFile)) { + fs.unlinkSync(tmpDbFile); } + } +} - module.exports = { setupStrapi, cleanupStrapi }; - ``` +module.exports = { setupStrapi, cleanupStrapi }; +``` + + - +What the test harness does: -Once these files are handed, the harness handles several Strapi v5 requirements: +1. **TypeScript Support**: Patches Strapi's configuration loader to understand TypeScript files (`.ts`, `.cts`, `.mts`) in your config directory +2. **Configuration Validation**: Ensures only valid config files are loaded and warns about common mistakes (like naming a file `middleware.js` instead of `middlewares.js`) +3. **Database Normalization**: Maps database client names to their actual driver names (e.g., `sqlite` β†’ `sqlite3`) and handles connection pooling +4. **Environment Setup**: Sets all required environment variables for testing, including JWT secrets and database configuration +5. **Automatic Route Registration**: Automatically registers a `/api/hello` test endpoint that you can use in your tests +6. **User Permission Helper**: Patches the user service to automatically assign the "authenticated" role to newly created users, simplifying authentication tests +7. **Cleanup**: Properly closes connections and removes temporary database files after tests complete -* It registers a fallback TypeScript loader so TypeScript configuration files (`server.ts`, `database.ts`, etc.) can be consumed by Jest. -* It patches Strapi's configuration loader to recognise `.ts`, `.cts`, and `.mts` files while preserving warnings for unsupported filenames. -* It normalises database client selection for sqlite, MySQL, and PostgreSQL in tests. -* It automatically exposes a `/hello` content API route backed by the `hello` service and ensures the authenticated role is applied to newly created users. +Once these files are in place, the harness handles several Strapi 5 requirements automatically, letting you focus on writing actual test logic rather than configuration boilerplate. ## Create smoke tests -With the harness in place you can confirm Strapi boots correctly by adding a minimal Jest suite in a `tests/app.test.js` as follows: +With the harness in place you can confirm Strapi boots correctly by adding a minimal Jest suite with the following **smoke tests** Smoke tests are basic tests that verify the most critical functionality works. The term comes from hardware testing: if you turn on a device and it doesn't catch fire (produce smoke), it passes the first test. In software, smoke tests check if the application starts correctly and basic features work before running more detailed tests. in a `tests/app.test.js` as follows: ```js title="./tests/app.test.js" const { setupStrapi, cleanupStrapi } = require('./strapi'); @@ -834,7 +888,7 @@ describe('User API', () => { ## Automate tests with GitHub Actions -You can run your Jest test suite automatically on every push and pull request with GitHub Actions. Create a `.github/workflows/test.yaml` file in your project and add the workflow below. +To go further, you can run your Jest test suite automatically on every push and pull request with . Create a `.github/workflows/test.yaml` file in your project and add the workflow as follows: ```yaml title="./.github/workflows/test.yaml" name: 'Tests' diff --git a/docusaurus/static/llms-full.txt b/docusaurus/static/llms-full.txt index 270c2386a7..5e92ca36d0 100644 --- a/docusaurus/static/llms-full.txt +++ b/docusaurus/static/llms-full.txt @@ -12059,15 +12059,30 @@ The present guide provides a hands-on approach to configuring * `Jest` provides the test runner and assertion utilities. * `Supertest` allows you to test all the `api` routes as they were instances of -Once these files are handed, the harness handles several Strapi v5 requirements: + **What the test harness does:** -* It registers a fallback TypeScript loader so TypeScript configuration files (`server.ts`, `database.ts`, etc.) can be consumed by Jest. -* It patches Strapi's configuration loader to recognise `.ts`, `.cts`, and `.mts` files while preserving warnings for unsupported filenames. -* It normalises database client selection for sqlite, MySQL, and PostgreSQL in tests. -* It automatically exposes a `/hello` content API route backed by the `hello` service and ensures the authenticated role is applied to newly created users. + 1. **TypeScript Support**: Patches Strapi's configuration loader to understand TypeScript files (`.ts`, `.cts`, `.mts`) in your config directory + + 2. **Configuration Validation**: Ensures only valid config files are loaded and warns about common mistakes (like naming a file `middleware.js` instead of `middlewares.js`) + + 3. **Database Normalization**: Maps database client names to their actual driver names (e.g., `sqlite` β†’ `sqlite3`) and handles connection pooling + + 4. **Environment Setup**: Sets all required environment variables for testing, including JWT secrets and database configuration + + 5. **Automatic Route Registration**: Automatically registers a `/api/hello` test endpoint that you can use in your tests + + 6. **User Permission Helper**: Patches the user service to automatically assign the "authenticated" role to newly created users, simplifying authentication tests + + 7. **Cleanup**: Properly closes connections and removes temporary database files after tests complete + +Once these files are in place, the harness handles several Strapi v5 requirements automatically, letting you focus on writing actual test logic rather than configuration boilerplate. ## Create smoke tests +:::note What are "smoke tests"? +**Smoke tests** are basic tests that verify the most critical functionality works. The term comes from hardware testing: if you turn on a device and it doesn't catch fire (produce smoke), it passes the first test. In software, smoke tests check if the application starts correctly and basic features work before running more detailed tests. +::: + With the harness in place you can confirm Strapi boots correctly by adding a minimal Jest suite in a `tests/app.test.js` as follows: ```js title="./tests/app.test.js" From b0598af17572fa8f1b7b59bf85d8f9a085a67f81 Mon Sep 17 00:00:00 2001 From: Pierre Wizla Date: Fri, 3 Oct 2025 16:39:20 +0200 Subject: [PATCH 6/7] Update title --- docusaurus/docs/cms/testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docusaurus/docs/cms/testing.md b/docusaurus/docs/cms/testing.md index 1c0fca8e11..c26fd6c3c9 100644 --- a/docusaurus/docs/cms/testing.md +++ b/docusaurus/docs/cms/testing.md @@ -10,7 +10,7 @@ tags: - API testing --- -# Unit testing guide +# Unit and integration testing guide Testing relies on Jest and Supertest with an in-memory SQLite database, a patched Strapi test harness that also supports TypeScript configuration files, and helpers that automatically register the `/hello` route and authenticated role during setup. From 8b8f81b101b780bc11e92c41b9c445e022fafaf7 Mon Sep 17 00:00:00 2001 From: Pierre Wizla Date: Fri, 3 Oct 2025 16:46:59 +0200 Subject: [PATCH 7/7] Slightly rework intro. --- docusaurus/docs/cms/testing.md | 4 +- docusaurus/static/llms-full.txt | 233 ++++++++++++++++++++++++++++---- 2 files changed, 213 insertions(+), 24 deletions(-) diff --git a/docusaurus/docs/cms/testing.md b/docusaurus/docs/cms/testing.md index c26fd6c3c9..4e8da16ebf 100644 --- a/docusaurus/docs/cms/testing.md +++ b/docusaurus/docs/cms/testing.md @@ -16,7 +16,9 @@ tags: Testing relies on Jest and Supertest with an in-memory SQLite database, a patched Strapi test harness that also supports TypeScript configuration files, and helpers that automatically register the `/hello` route and authenticated role during setup. -The present guide provides a hands-on approach to configuring in a Strapi 5 application, mocking the Strapi object for unit testing plugin code, and using to test REST endpoints end to end. It aims to recreate the minimal test suite available in the following +The present guide provides a hands-on approach to configuring in a Strapi 5 application, mocking the Strapi object for unit testing plugin code, and using to test REST endpoints end to end. + +The guide aims to recreate the minimal test suite available in the CodeSandbox link. :::caution The present guide will not work if you are on Windows using the SQLite database due to how Windows locks the SQLite file. diff --git a/docusaurus/static/llms-full.txt b/docusaurus/static/llms-full.txt index 5e92ca36d0..5a5083986b 100644 --- a/docusaurus/static/llms-full.txt +++ b/docusaurus/static/llms-full.txt @@ -12050,40 +12050,227 @@ An example of what a template could look like is the . # Testing Source: https://docs.strapi.io/cms/testing -# Unit testing guide +# Unit and integration testing guide The present guide provides a hands-on approach to configuring * `Jest` provides the test runner and assertion utilities. - * `Supertest` allows you to test all the `api` routes as they were instances of + * `Supertest` allows you to test all the `api` routes as they were instances of utilities to recreate just the parts of the Strapi object and any request context that your code relies on. + +### Controller example + +Create a test file such as `./tests/todo-controller.test.js` that instantiates your controller with a mocked Strapi object and verifies every call the controller performs: + +```js title="./tests/todo-controller.test.js" +const todoController = require('./todo-controller'); + +describe('Todo controller', () => { + let strapi; + + beforeEach(() => { + strapi = { + plugin: jest.fn().mockReturnValue({ + service: jest.fn().mockReturnValue({ + create: jest.fn().mockReturnValue({ + data: { + name: 'test', + status: false, + }, + }), + complete: jest.fn().mockReturnValue({ + data: { + id: 1, + status: true, + }, + }), + }), + }), + }; + }); - **What the test harness does:** + it('creates a todo item', async () => { + const ctx = { + request: { + body: { + name: 'test', + }, + }, + body: null, + }; - 1. **TypeScript Support**: Patches Strapi's configuration loader to understand TypeScript files (`.ts`, `.cts`, `.mts`) in your config directory - - 2. **Configuration Validation**: Ensures only valid config files are loaded and warns about common mistakes (like naming a file `middleware.js` instead of `middlewares.js`) - - 3. **Database Normalization**: Maps database client names to their actual driver names (e.g., `sqlite` β†’ `sqlite3`) and handles connection pooling - - 4. **Environment Setup**: Sets all required environment variables for testing, including JWT secrets and database configuration - - 5. **Automatic Route Registration**: Automatically registers a `/api/hello` test endpoint that you can use in your tests - - 6. **User Permission Helper**: Patches the user service to automatically assign the "authenticated" role to newly created users, simplifying authentication tests - - 7. **Cleanup**: Properly closes connections and removes temporary database files after tests complete + await todoController({ strapi }).index(ctx); -Once these files are in place, the harness handles several Strapi v5 requirements automatically, letting you focus on writing actual test logic rather than configuration boilerplate. + expect(ctx.body).toBe('created'); + expect(strapi.plugin('todo').service('create').create).toHaveBeenCalledTimes(1); + }); -## Create smoke tests + it('completes a todo item', async () => { + const ctx = { + request: { + body: { + id: 1, + }, + }, + body: null, + }; -:::note What are "smoke tests"? -**Smoke tests** are basic tests that verify the most critical functionality works. The term comes from hardware testing: if you turn on a device and it doesn't catch fire (produce smoke), it passes the first test. In software, smoke tests check if the application starts correctly and basic features work before running more detailed tests. -::: + await todoController({ strapi }).complete(ctx); + + expect(ctx.body).toBe('todo completed'); + expect(strapi.plugin('todo').service('complete').complete).toHaveBeenCalledTimes(1); + }); +}); +``` + +The `beforeEach` hook rebuilds the mock so every test starts with a clean Strapi instance. Each test prepares the `ctx` request object that the controller expects, calls the controller function, and asserts both the response and the interactions with Strapi services. + +### Service example + +Services can be tested in the same test suite or in a dedicated file by mocking only the Strapi query layer they call into. + +```js title="./tests/create-service.test.js" +const createService = require('./create-service'); + +describe('Create service', () => { + let strapi; + + beforeEach(() => { + strapi = { + query: jest.fn().mockReturnValue({ + create: jest.fn().mockReturnValue({ + data: { + name: 'test', + status: false, + }, + }), + }), + }; + }); + + it('persists a todo item', async () => { + const todo = await createService({ strapi }).create({ name: 'test' }); + + expect(strapi.query('plugin::todo.todo').create).toHaveBeenCalledTimes(1); + expect(todo.data.name).toBe('test'); + }); +}); +``` + +By focusing on mocking the specific Strapi APIs your code touches, you can grow these tests to cover additional branches, error cases, and services while keeping them fast and isolated. + +## Set up a testing environment + +For API-level testing with that sets up and tears down Strapi instances for tests + +### TypeScript compiler configuration + +Create `tests/ts-compiler-options.js` with the following content: + +```js title="./tests/ts-compiler-options.js" +const fs = require('fs'); +const path = require('path'); +const ts = require('typescript'); + +const projectRoot = path.resolve(__dirname, '..'); +const tsconfigPath = path.join(projectRoot, 'tsconfig.json'); + +const baseCompilerOptions = { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2019, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + esModuleInterop: true, + jsx: ts.JsxEmit.React, +}; + +const loadCompilerOptions = () => { + let options = { ...baseCompilerOptions }; + + if (!fs.existsSync(tsconfigPath)) { + return options; + } + + try { + const tsconfigContent = fs.readFileSync(tsconfigPath, 'utf8'); + const parsed = ts.parseConfigFileTextToJson(tsconfigPath, tsconfigContent); + + if (!parsed.error && parsed.config && parsed.config.compilerOptions) { + options = { + ...options, + ...parsed.config.compilerOptions, + }; + } + } catch (error) { + // Ignore tsconfig parsing errors and fallback to defaults + } + + return options; +}; + +module.exports = { + compilerOptions: loadCompilerOptions(), + loadCompilerOptions, +}; +``` + +This file loads your project's TypeScript configuration and provides sensible defaults if the config file doesn't exist. + +### TypeScript runtime loader + +Create `tests/ts-runtime.js` with the following content: + +```js title="./tests/ts-runtime.js" +const Module = require('module'); +const { compilerOptions } = require('./ts-compiler-options'); +const fs = require('fs'); +const ts = require('typescript'); + +const extensions = Module._extensions; + +if (!extensions['.ts']) { + extensions['.ts'] = function compileTS(module, filename) { + const source = fs.readFileSync(filename, 'utf8'); + const output = ts.transpileModule(source, { + compilerOptions, + fileName: filename, + reportDiagnostics: false, + }); + + return module._compile(output.outputText, filename); + }; +} + +if (!extensions['.tsx']) { + extensions['.tsx'] = extensions['.ts']; +} + +module.exports = { + compilerOptions, +}; +``` + +This file teaches Node.js how to load `.ts` and `.tsx` files by transpiling them to JavaScript on the fly. + +### Main test harness + +Create `tests/strapi.js` with the following content: + +What the test harness does: + +1. **TypeScript Support**: Patches Strapi's configuration loader to understand TypeScript files (`.ts`, `.cts`, `.mts`) in your config directory +2. **Configuration Validation**: Ensures only valid config files are loaded and warns about common mistakes (like naming a file `middleware.js` instead of `middlewares.js`) +3. **Database Normalization**: Maps database client names to their actual driver names (e.g., `sqlite` β†’ `sqlite3`) and handles connection pooling +4. **Environment Setup**: Sets all required environment variables for testing, including JWT secrets and database configuration +5. **Automatic Route Registration**: Automatically registers a `/api/hello` test endpoint that you can use in your tests +6. **User Permission Helper**: Patches the user service to automatically assign the "authenticated" role to newly created users, simplifying authentication tests +7. **Cleanup**: Properly closes connections and removes temporary database files after tests complete + +Once these files are in place, the harness handles several Strapi 5 requirements automatically, letting you focus on writing actual test logic rather than configuration boilerplate. + +## Create smoke tests -With the harness in place you can confirm Strapi boots correctly by adding a minimal Jest suite in a `tests/app.test.js` as follows: +With the harness in place you can confirm Strapi boots correctly by adding a minimal Jest suite with the following **smoke tests** in a `tests/app.test.js` as follows: ```js title="./tests/app.test.js" const { setupStrapi, cleanupStrapi } = require('./strapi'); @@ -12272,7 +12459,7 @@ describe('User API', () => { ## Automate tests with GitHub Actions -You can run your Jest test suite automatically on every push and pull request with GitHub Actions. Create a `.github/workflows/test.yaml` file in your project and add the workflow below. +To go further, you can run your Jest test suite automatically on every push and pull request with . Create a `.github/workflows/test.yaml` file in your project and add the workflow as follows: ```yaml title="./.github/workflows/test.yaml" name: 'Tests'