单元与集成测试指南

页面摘要: 测试依赖于 Jest 和 Supertest,以及一个打过补丁的 Strapi 测试 harness(同时支持 TypeScript 配置文件),再加上在初始化时自动注册 /hello 路由和已认证角色的辅助方法,底层使用内存版 SQLite 数据库。

本指南以实战方式讲解如何在 Strapi 5 应用程序中配置 Jest、为插件代码的单元测试模拟(mock)Strapi 对象,并使用 Supertest 端到端地测试 REST 端点。

本指南旨在复刻 strapi-unit-testing-examples CodeSandbox 链接中提供的最小测试套件。

WARNING

如果你在 Windows 上使用 SQLite 数据库,本指南将无法工作,原因是 Windows 对 SQLite 文件的锁定方式。

安装工具

我们将首先安装测试工具,添加一条运行测试的命令,并对 Jest 进行配置。

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

    Yarn

    yarn add jest supertest --dev
    

    NPM

    npm install jest supertest --save-dev
    
    • Jest 提供测试运行器和断言工具。
    • Supertest 让你能够将全部 api 路由作为 http.Server 的实例来进行测试。
  2. 使用以下内容更新 Strapi 项目的 package.json 文件:

    • 在 scripts 部分添加一条 test 命令,使其如下所示:

        "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 构建产物,并映射你在测试中导入的任何根级模块:

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

为插件单元测试模拟 Strapi

纯单元测试非常适合 Strapi 插件,因为它们让你无需启动 Strapi 服务器即可验证控制器和服务逻辑。使用 Jest 的 mocking 模拟(Mocking)是一种测试技术,你创建应用程序某些部分(例如服务或数据库调用)的伪造版本,以便在隔离环境中测试代码。与连接真实数据库或调用实际服务不同,模拟会返回预定义的响应,使测试更快且更可预测。 工具,只重建你的代码所依赖的 Strapi 对象部分以及任何请求上下文。

控制器示例

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

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 钩子会重建模拟对象,使每个测试都从一个干净的 Strapi 实例开始。每个测试都会准备控制器所期望的 ctx 请求对象,调用控制器函数,并同时断言响应以及和 Strapi 服务的交互。

服务示例

服务可以在同一个测试套件中,也可以在独立文件中测试,只需模拟它们所调用的 Strapi 查询层即可。

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

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

搭建测试环境

要使用 Supertest 进行 API 级别的测试,框架必须拥有一个干净、空的环境来执行有效的测试,同时不能干扰你的开发数据库。

一旦 jest 开始运行,它就会使用 test environment(环境),因此请创建 ./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 客户端。

dist 目录与多个数据库配置:

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

如果你在开发模式下运行应用(例如 yarn dev),Strapi 会将配置编译到 dist/config 中。如果你的测试随后强制 Strapi 从 dist 读取(例如传入 createStrapi({ appDir: './', distDir: './dist' })),最终可能只有一份数据库配置存在于 dist/config/database.js 中。这可能导致 Jest 在开发构建后读取到错误的数据库设置。

建议:

  • 不要在测试 harness 中传入自定义的 distDir;让 Strapi 直接从源码加载。本指南中的 harness 调用 createStrapi().load() 时不带覆盖项,从而避免该冲突。
  • 始终在 Jest 中依赖 config/env/test/database.js。避免在 yarn test 之前立即运行 yarn dev。如果已经运行过,请考虑删除 dist/,或在不强制 distDir 的情况下直接运行测试。
  • 如果你必须使用 dist/,请确保其中的 config/database.js 与你的测试环境一致,或者专门为测试进行清理/重新构建。

创建 Strapi 测试 harness

我们将在项目根目录下创建一个 tests 文件夹,并添加下面的示例文件。这 3 个文件协同工作,构成一个完整的测试基础设施:

  • ts-compiler-options.js 定义了测试时 TypeScript 文件应如何编译
  • ts-runtime.js 使 Jest 能够即时理解并执行 TypeScript 文件
  • strapi.js 是主要的 test harness(测试 harness)测试 harness 是一组软件和测试数据,用于在预定义条件下运行应用程序并监控其行为,从而对其进行测试。

在本例中,我们的测试 harness 会在隔离的测试环境中搭建一个完整的 Strapi 实例、处理 TypeScript 文件,并提供工具方法以简化测试。,用于为测试搭建和拆除 Strapi 实例

TypeScript 编译器配置

创建 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 配置,并在配置文件不存在时提供合理的默认值。

TypeScript 运行时加载器

创建 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 来加载它们。

Main test harness

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

该测试 harness 的作用:

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

tests/strapi.js harness 的代码示例高亮显示了第 313–321 行,因为它们是可选的,仅在你 植入可预测的测试数据 时使用。

一旦这些文件就位,harness 会自动处理多项 Strapi 5 的要求,让你专注于编写实际的测试逻辑,而不是配置样板代码。

(optional) Seed predictable test data

某些 API 测试受益于预加载一组已知的文档。你可以将项目的种子数据逻辑暴露为一个可复用的函数,并在 harness 中通过一个环境标志来调用它:

  1. 从你的项目脚本(例如 ./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. 在测试 harness 中,当 TEST_SEED=true 时调用该函数(请参阅 main test harness 代码示例中高亮显示的第 313–321 行)。

  3. 在启用种子数据的情况下运行测试:

    Yarn

    TEST_SEED=true yarn test
    

    NPM

    TEST_SEED=true npm run test
    

种子数据在 Strapi 启动后运行,因此服务、权限和上传都可用。

建议保持种子数据的确定性,以确保断言稳定。如果你发布条目,最好使用固定的时间戳,或针对结构性属性而非瞬时日期进行断言。

创建冒烟测试

在 harness 就位后,你可以通过添加一个最小的 Jest 套件来确认 Strapi 能正确启动,具体做法是在 tests/app.test.js 或 tests/app.test.ts 文件中添加以下 冒烟测试 冒烟测试(Smoke tests)是用于验证最关键功能是否正常的测试。该术语源于硬件测试:如果你打开一个设备,它没有着火(产生烟雾),它就通过了第一项测试。在软件中,冒烟测试用于在运行更详细的测试之前,检查应用程序是否能正确启动以及基本功能是否可用。。

TypeScript 冒烟测试必须先加载 tests/strapi.js

如果你使用 tests/app.test.ts,并且某个辅助函数调用了 import { createStrapi } from '@strapi/strapi',却没有先加载本指南中的 tests/strapi.js harness,那么 Jest 仍会使用 Strapi 自带的配置扫描器。该扫描器只接受 .js 和 .json 文件,因此你会看到诸如 Config file not loaded, extension must be one of .js,.json): database.ts 之类的警告,并且由于数据库配置块从未被加载,createStrapi().load() 可能会失败。

harness 会同时修补目录扫描和逐文件加载器,使 .ts、.cts 和 .mts 配置能够在 Jest 下工作。请从该模块中获取 setupStrapi / cleanupStrapi(从 .ts 测试中以 CommonJS require 方式引入没有问题),并避免直接调用 createStrapi,除非你将 tests/strapi.js 中相同的修补前导代码复制到你的 import 之前。

import type { Strapi } from '@strapi/strapi';

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 配置。这与问题讨论串中描述的变通方案一致,但它会在每次测试运行时额外增加一个完整的构建步骤。本页的 harness 旨在无需该步骤即可保持内部循环的快速。

JavaScript 冒烟测试可以保留原始模式:

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 应该会输出:

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

如果你收到 Jest 的超时错误,请通过在 tests/strapi.js 或测试文件顶部调用 jest.setTimeout(30000) 来增大超时时间。

测试基础 API 端点

创建 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!');
    });
});

harness 会自动注册 /api/hello 路由,因此测试只需发起请求即可。

测试 API 身份验证

Strapi 使用 JWT 令牌来处理身份验证。我们将创建一个具有已知用户名和密码的用户,并使用这些凭据进行身份验证以获取 JWT 令牌。harness 中修补过的 user.add 辅助方法会自动应用“authenticated”(已认证)角色。

创建 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: '123456789',
  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 发起经过身份验证的请求。基于此示例,你可以添加更多测试来验证身份验证和授权是否按预期工作。

结合用户权限进行高级 API 测试

在创建 API 测试时,你很可能需要测试需要身份验证的端点。在下面的示例中,我们将实现一个辅助方法来获取并使用 JWT 令牌。

创建 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: '123456789',
  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 自动化测试

如需更进一步,你可以使用 GitHub Actions 在每次 push 和 pull request 时自动运行你的 Jest 测试套件。在你的项目中创建一个 .github/workflows/test.yaml 文件,并添加如下工作流:

name: 'Tests'

on:
  pull_request:
  push:

jobs:
  run-tests:
    name: Run Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install modules
        run: npm ci
      - name: Run Tests
        run: npm run test

将持续集成与你的单元和 API 测试结合起来,有助于在问题进入生产环境之前防止回归。