# 测试

> Source: https://strapi.nodejs.cn/cms/testing

🌐 Unit and integration testing guide

测试依赖于 Jest 和 Supertest，以及一个内存中的 SQLite 数据库，一个经过修补的 Strapi 测试工具，它还支持 TypeScript 配置文件，以及在设置期间自动注册 `/hello` 路由和认证角色的辅助工具。

本指南提供了一种动手的方法，用于在 Strapi 5 应用中配置 [Jest](https://jest.nodejs.cn/) ，为插件代码的单元测试模拟 Strapi 对象，以及使用  [Supertest](https://github.com/visionmedia/supertest) 对 REST 端点进行端到端测试。

该指南旨在重现 [strapi-unit-testing-examples](https://codesandbox.io/p/github/pwizla/strapi-unit-testing-examples/main?import=true) CodeSandbox 链接中可用的最小测试套件。

:::caution

如果你在 Windows 上使用 SQLite 数据库，则本指南将不起作用，因为 Windows 会锁定 SQLite 文件。

🌐 The present guide will not work if you are on Windows using the SQLite database due to how Windows locks the SQLite file.

:::

## 安装工具 {#install-tools}

🌐 Install tools

我们将首先安装测试工具，添加运行测试的命令，并配置 Jest。

🌐 We'll first install test tools, add a command to run our tests, and configure Jest.

1. 通过在终端中运行以下命令安装 Jest 和 Supertest：

    ```bash
    yarn add jest supertest --dev
    ```

    ```bash
    npm install jest supertest --save-dev
    ```

    * `Jest` 提供测试运行器和断言工具。
    * `Supertest` 允许你测试所有作为 ` [http.Server](https://nodejs.cn/api/http.html#class-httpserver)` 实例的 `api` 路径。
2. 使用以下内容更新你 Strapi 项目的 `package.json` 文件：
    * 在 `scripts` 部分添加一个 `test` 命令，使其看起来如下：

      ```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"
        },
      ```

    * 在文件底部配置 Jest，以忽略 Strapi 构建产物，并映射你从测试中导入的任何根级模块：

      ```json
        "jest": {
          "testPathIgnorePatterns": [
            "/node_modules/",
            ".tmp",
            ".cache"
          ],
          "testEnvironment": "node",
          "moduleNameMapper": {
            "^/create-service$": "<rootDir>/create-service"
          }
        }
      ```

## 为插件单元测试模拟 Strapi {#mock-strapi-for-plugin-unit-tests}

🌐 Mock Strapi for plugin unit tests

纯单元测试对于 Strapi 插件来说是理想的，因为它们让你在不启动 Strapi 服务器的情况下验证控制器和服务逻辑。使用 Jest 的 **mocking** Mocking 是一种测试技术，你可以创建应用部分的假版本（例如服务或数据库调用）来隔离测试代码。mock 会返回预定义的响应，而不是连接到真实数据库或调用实际服务，从而使测试更快且更可预测。 工具来重现 Strapi 对象的部分以及你的代码依赖的任何请求上下文。

### 控制器示例 {#controller-example}

🌐 Controller example

创建一个测试文件，例如 `./tests/todo-controller.test.js`，该文件使用模拟的 Strapi 对象实例化你的控制器，并验证控制器执行的每一个调用：

🌐 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);
  });
});
```

`beforeEach` 钩子重建了 mock，因此每个测试都从一个干净的 Strapi 实例开始。每个测试会准备控制器所期望的 `ctx` 请求对象，调用控制器函数，并断言响应以及与 Strapi 服务的交互。

🌐 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}

🌐 Service example

服务可以在同一个测试套件中进行测试，也可以在专用文件中通过仅模拟它们调用的 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('./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');
  });
});
```

通过专注于 mocking 代码接触到的特定 Strapi API，你可以扩展这些测试以覆盖更多分支、错误情况和服务，同时保持它们的快速和隔离。

🌐 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}

🌐 Set up a testing environment

对于使用 [Supertest](https://github.com/visionmedia/supertest) 进行 API 级别测试，框架必须有一个干净的空环境以执行有效测试，并且不会干扰你的开发数据库。

一旦 `jest` 运行，它会使用 `test` [环境](/cms/configurations/environment)，所以请使用以下方式创建 `./config/env/test/database.js`：

🌐 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,
    },
  };
};
```

此配置镜像了生产环境中使用的默认设置，但将 `better-sqlite3` 转换为 Strapi 所期望的 `sqlite` 客户端。

🌐 This configuration mirrors the defaults used in production but converts `better-sqlite3` to the `sqlite` client Strapi expects.

<details>
<summary>Dist 目录和多个数据库配置：</summary>

在本地开发时，你可能同时拥有一个项目级的 `config/database.(ts|js)` 和一个环境特定的 `config/env/test/database.js`。

🌐 When developing locally you might have both a project-level `config/database.(ts|js)` and an environment-specific `config/env/test/database.js`.

如果你在开发环境中运行应用（例如，`yarn dev`），Strapi 会将配置编译到 `dist/config`。如果你的测试随后强制 Strapi 从 `dist` 读取（例如，通过传递 `createStrapi({ appDir: './', distDir: './dist' })`），可能最终只有一个数据库配置在 `dist/config/database.js` 中。这可能导致 Jest 在开发构建后获取错误的数据库设置。

🌐 If you run the app in development (e.g., `yarn dev`) Strapi compiles configurations into `dist/config`. If your tests then force Strapi to read from `dist` (e.g., by passing `createStrapi({ appDir: './', distDir: './dist' })`), only one database config may end up in `dist/config/database.js`. This can cause Jest to pick up the wrong DB settings after a dev build.

建议：

🌐 Recommendations:

- 不要在测试工具中传递自定义的 `distDir`；让 Strapi 直接从源加载。本指南中的工具调用 `createStrapi().load()` 时没有使用覆盖，这可以防止冲突。
- 在 Jest 中始终依赖 `config/env/test/database.js`。避免在 `yarn test` 之前立即运行 `yarn dev`。如果已经运行过，考虑移除 `dist/` 或者干脆在不强制 `distDir` 的情况下运行测试。
- 如果必须使用 `dist/`，请确保其 `config/database.js` 与你的测试环境匹配，或者专门为测试进行清理/重建。

</details>

## 创建 Strapi 测试工具 {#create-the-strapi-test-harness}

🌐 Create the Strapi test harness

我们将在你的项目根目录中创建一个 `tests` 文件夹，并添加以下示例文件。这三个文件共同工作以创建一个完整的测试基础设施：

🌐 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:

* `ts-compiler-options.js` 定义了 TypeScript 文件应如何被编译用于测试
* `ts-runtime.js` 使 Jest 能够即时理解并执行 TypeScript 文件
* `strapi.js` 是主要的 **测试工具** 测试工具是一组软件和测试数据的集合，通过在预定义条件下运行应用并监控其行为来测试应用。<br/><br/>在当前情况下，我们的测试工具在一个隔离的测试环境中设置了完整的 Strapi 实例，处理 TypeScript 文件，并提供使测试更容易的工具。 用于为测试设置和拆除 Strapi 实例

### TypeScript 编译器配置 {#typescript-compiler-configuration}

🌐 TypeScript compiler configuration

使用以下内容创建 `tests/ts-compiler-options.js`：

🌐 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,
};
```

此文件加载项目的 TypeScript 配置，并在配置文件不存在时提供合理的默认值。

🌐 This file loads your project's TypeScript configuration and provides sensible defaults if the config file doesn't exist.

### TypeScript 运行时加载器 {#typescript-runtime-loader}

🌐 TypeScript runtime loader

使用以下内容创建 `tests/ts-runtime.js`：

🌐 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,
};
```

这个文件教 Node.js 如何通过即时将 `.ts` 和 `.tsx` 文件转译为 JavaScript 来加载它们。

🌐 This file teaches Node.js how to load `.ts` and `.tsx` files by transpiling them to JavaScript on the fly.

### 主测试框架 {#main-test-harness}

🌐 Main test harness

使用以下内容创建 `tests/strapi.js`：

🌐 Create `tests/strapi.js` with the following content:

```js title="./tests/strapi.js" showLineNumbers {313-321}
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 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;
}

// ============================================
// 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 {};
    }

    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;
}

// ============================================
// 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',
  };

  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);
  };
})();

// ============================================
// 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',
};

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,
          },
        },
      ]);

      contentApi.mount(instance.server.router);
      instance.__helloRouteRegistered = true;
    }
    
    await instance.start();
    global.strapi = instance;

  // Optionally seed example data for tests if requested
  if (process.env.TEST_SEED === 'true') {
    try {
      const { seedExampleApp } = require(path.join(__dirname, '..', 'scripts', 'seed'));
      await seedExampleApp();
    } catch (e) {
      console.warn('Seeding failed:', e);
    }
  }

    // 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);

      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 };
```

测试工具的作用：

🌐 What the test harness does:

1. **TypeScript 支持**：修补 Strapi 的配置加载器，以便在你的配置目录中识别 TypeScript 文件（`.ts`、`.cts`、`.mts`）
2. **配置验证**：确保只加载有效的配置文件，并对常见错误发出警告（例如将文件命名为 `middleware.js` 而不是 `middlewares.js`）
3. **数据库规范化**：将数据库客户端名称映射到其实际驱动程序名称（例如，`sqlite` → `sqlite3`）并处理连接池
4. **环境设置**：设置测试所需的所有环境变量，包括 JWT 秘钥和数据库配置
5. **自动路由注册**：自动注册一个 `/api/hello` 测试端点，你可以在测试中使用
6. **用户权限助手**：修补用户服务，以自动将“已认证”角色分配给新创建的用户，从而简化身份验证测试
7. **清理**：在测试完成后，正确关闭连接并删除临时数据库文件

:::note

`tests/strapi.js` 测试框架的代码示例高亮了第 313-321 行，因为这些是可选的，如果你 [种子可预测的测试数据](#optional-seed-predictable-test-data) 则使用它们。

🌐 The code example for the `tests/strapi.js` harness highlights lines 313-321 because these are optional, to be used if you [seed predictable test data](#optional-seed-predictable-test-data).

:::

这些文件到位后，线束会自动处理 Strapi 5 的多项需求，让你专注于编写实际测试逻辑，而不是配置样板。

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

## (可选) 生成可预测的测试数据 {#optional-seed-predictable-test-data}

🌐 (optional) Seed predictable test data

一些 API 测试在预先加载已知文档集时会受益。你可以将项目的初始化数据功能作为可重用的函数暴露出来，并在环境标志下从测试框架中调用它：

🌐 Some API tests benefit from having a known set of documents preloaded. You can expose your project seeding as a reusable function and call it from the harness behind an environment flag:

1. 从你的项目脚本中导出一个播种函数（例如 `./scripts/seed.js`）:

    ```js title="./scripts/seed.js"
    async function seedExampleApp() {
      // In test environment, skip complex seeding and just log
      if (process.env.NODE_ENV === 'test') {
        console.log('Test seeding: Skipping complex data import (not needed for basic tests)');
        return;
      }

      const shouldImportSeedData = await isFirstRun();
      if (shouldImportSeedData) {
        try {
          console.log('Setting up the template...');
          await importSeedData();
          console.log('Ready to go');
        } catch (error) {
          console.log('Could not import seed data');
          console.error(error);
        }
      }
    }

    // Allow usage both as a CLI and as a library from tests
    if (require.main === module) {
      main().catch((error) => {
        console.error(error);
        process.exit(1);
      });
    }

    module.exports = { seedExampleApp };
    ```

2. 在测试框架中，当 `TEST_SEED=true` 时调用该函数（参见 [主测试框架](#main-test-harness) 的代码示例中高亮的第 313-321 行）。
3. 启用种子功能运行测试：

    ```bash
      TEST_SEED=true yarn test
      ```

    ```bash
      TEST_SEED=true npm run test
      ```

种子功能在 Strapi 启动后运行，因此服务、权限和上传功能均可用。

🌐 Seeding runs after Strapi starts, so services, permissions, and uploads are available.

建议保持种子确定性以确保断言稳定。如果发布条目，最好使用固定时间戳或对结构属性进行断言，而不是对瞬时日期进行断言。

🌐 It's recommended to keep seeds deterministic to ensure stable assertions. If you publish entries, prefer fixed timestamps or assert on structural properties rather than transient dates.

## 创建冒烟测试 {#create-smoke-tests}

🌐 Create smoke tests

将测试环境设置好后，你可以通过添加一个最小的 Jest 测试套件来确认 Strapi 是否正确启动，具体如下 **冒烟测试** 冒烟测试是验证最关键功能是否正常运行的基础测试。这个术语来源于硬件测试：如果你打开设备而它没有冒烟，就通过了第一个测试。在软件中，冒烟测试检查应用是否能正确启动以及基本功能是否正常，在进行更详细的测试之前。 可以在 `tests/app.test.js` 或 `tests/app.test.ts` 文件中按如下方式进行。

### TypeScript 烟雾测试必须先加载 `tests/strapi.js` {#typescript-smoke-tests-must-load-testsstrapijs-first}

🌐 TypeScript smoke tests must load `tests/strapi.js` first

如果你使用 `tests/app.test.ts` 和一个调用 `import { createStrapi } from '@strapi/strapi'` 的辅助工具，而没有先从本指南加载 `tests/strapi.js` 框架，Jest 仍然会使用 Strapi 的默认配置扫描器。该扫描器只接受 `.js` 和 `.json` 文件，因此你会看到类似 `Config file not loaded, extension must be one of .js,.json): database.ts` 的警告，而且 `createStrapi().load()` 可能会失败，因为数据库模块从未加载。

🌐 If you use `tests/app.test.ts` and a helper that calls `import { createStrapi } from '@strapi/strapi'` without first loading the `tests/strapi.js` harness from this guide, Jest still uses Strapi's stock configuration scanner. That scanner only accepts `.js` and `.json` files, so you will see warnings such as `Config file not loaded, extension must be one of .js,.json): database.ts`, and `createStrapi().load()` can fail because the database block never loaded.

该工具修补了目录扫描和每个文件的加载器，因此 `.ts`、`.cts` 和 `.mts` 配置可以在 Jest 下工作。从该模块提取 `setupStrapi` / `cleanupStrapi`（来自 `.ts` 测试的 CommonJS `require` 也可以），并且除非在导入之前从 `tests/strapi.js` 复制相同的修补前言，否则避免直接调用 `createStrapi`。

🌐 The harness patches both the directory scan and the per-file loader so `.ts`, `.cts`, and `.mts` configs work under Jest. Pull `setupStrapi` / `cleanupStrapi` from that module (CommonJS `require` from `.ts` tests is fine) and avoid calling `createStrapi` directly unless you copy the same patch preamble from `tests/strapi.js` above your import.

```ts title="./tests/app.test.ts"

const { setupStrapi, cleanupStrapi } = require('./strapi');

declare global {
  var strapi: Strapi;
}

beforeAll(async () => {
  await setupStrapi();
});

afterAll(async () => {
  await cleanupStrapi();
});

it('strapi is defined', () => {
  expect(strapi).toBeDefined();
});
```

有些团队运行 `yarn build` 并传递 `{ distDir: './dist' }`，以便 Strapi 从磁盘读取已编译的 `.js` 配置。这与问题线程中描述的解决方法相符，但它会在每次测试运行时添加一个完整的构建。此页面上的测试工具旨在在不执行该步骤的情况下保持内部循环的快速。

🌐 Some teams run `yarn build` and pass `{ distDir: './dist' }` so Strapi reads compiled `.js` configuration from disk. That matches the workaround described in the issue thread, but it adds a full build to every test run. The harness on this page is meant to keep the inner loop fast without that step.

JavaScript 烟雾测试可以保留原始模式：

🌐 JavaScript smoke tests can keep the original pattern:

```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');
```

运行 `yarn test` 或 `npm run test` 现在应该会得到:

🌐 Running `yarn test` or `npm run test` should now yield:

```bash
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

如果你在使用 Jest 时收到超时错误，可以通过在 `tests/strapi.js` 中或测试文件顶部调用 `jest.setTimeout(30000)` 来增加超时时间。

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

:::

## 测试一个基本的 API 端点 {#test-a-basic-api-endpoint}

🌐 Test a basic API endpoint

使用以下内容创建 `tests/hello.test.js`：

🌐 Create `tests/hello.test.js` with the following:

```js title="./tests/hello.test.js"
const { setupStrapi, cleanupStrapi } = require('./strapi');
const request = require('supertest');

beforeAll(async () => {
  await setupStrapi();
});

afterAll(async () => {
  await cleanupStrapi();
});

it('should return hello world', async () => {
  await request(strapi.server.httpServer)
    .get('/api/hello')
    .expect(200)
    .then((data) => {
      expect(data.text).toBe('Hello World!');
    });
});
```

该框架会自动注册 `/api/hello` 路由，因此测试只需发出请求即可。

🌐 The harness registers the `/api/hello` route automatically, so the test only has to make the request.

## 测试 API 身份验证 {#test-api-authentication}

🌐 Test API authentication

Strapi 使用 JWT 令牌来处理身份验证。我们将创建一个具有已知用户名和密码的用户，并使用这些凭据进行身份验证以获取 JWT 令牌。测试工具中的修补 `user.add` 辅助函数确保已自动应用经过身份验证的角色。

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

创建 `tests/auth.test.js`：

🌐 Create `tests/auth.test.js`:

```js title="./tests/auth.test.js"
const { setupStrapi, cleanupStrapi } = require('./strapi');
const request = require('supertest');

beforeAll(async () => {
  await setupStrapi();
});

afterAll(async () => {
  await cleanupStrapi();
});

// User mock data
const mockUserData = {
  username: 'tester',
  email: 'tester@strapi.com',
  provider: 'local',
  password: '1234abc',
  confirmed: true,
  blocked: null,
};

it('should login user and return JWT token', async () => {
  await strapi.plugins['users-permissions'].services.user.add({
    ...mockUserData,
  });

  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(200)
    .then((data) => {
      expect(data.body.jwt).toBeDefined();
    });
});
```

你可以使用返回的 JWT 令牌对 API 发起经过身份验证的请求。使用此示例，你可以添加更多测试以验证身份验证和授权是否按预期工作。

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

## 具有用户权限的高级API测试 {#advanced-api-testing-with-user-permissions}

🌐 Advanced API testing with user permissions

当你创建 API 测试时，你很可能需要测试需要身份验证的端点。在以下示例中，我们将实现一个辅助工具来获取和使用 JWT 令牌。

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

创建 `tests/user.test.js`：

🌐 Create `tests/user.test.js`:

```js title="./tests/user.test.js"
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', () => {
  beforeAll(async () => {
    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);
      });
  });
});
```

## 使用 GitHub Actions 自动化测试 {#automate-tests-with-github-actions}

🌐 Automate tests with GitHub Actions

更进一步，你可以使用 [GitHub Actions](https://github.com/features/actions)在每次推送和拉取请求时自动运行你的Jest测试套件。在你的项目中创建一个`.github/workflows/test.yaml`文件，并按如下方式添加工作流：

```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
```

将持续集成与单元测试和 API 测试相结合，有助于在回归测试进入生产环境之前防止其发生。

🌐 Pairing continuous integration with your unit and API tests helps prevent regressions before they reach production.
