单元与集成测试指南
页面摘要: 测试依赖于 Jest 和 Supertest,以及一个打过补丁的 Strapi 测试 harness(同时支持 TypeScript 配置文件),再加上在初始化时自动注册
/hello路由和已认证角色的辅助方法,底层使用内存版 SQLite 数据库。
本指南以实战方式讲解如何在 Strapi 5 应用程序中配置 Jest、为插件代码的单元测试模拟(mock)Strapi 对象,并使用 Supertest 端到端地测试 REST 端点。
本指南旨在复刻 strapi-unit-testing-examples CodeSandbox 链接中提供的最小测试套件。
如果你在 Windows 上使用 SQLite 数据库,本指南将无法工作,原因是 Windows 对 SQLite 文件的锁定方式。
安装工具
我们将首先安装测试工具,添加一条运行测试的命令,并对 Jest 进行配置。
-
在终端中运行以下命令安装 Jest 和 Supertest:
Yarn
yarn add jest supertest --devNPM
npm install jest supertest --save-devJest提供测试运行器和断言工具。Supertest让你能够将全部api路由作为 http.Server 的实例来进行测试。
-
使用以下内容更新 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 的作用:
- TypeScript 支持:修补 Strapi 的配置加载器,使其能识别配置目录中的 TypeScript 文件(
.ts、.cts、.mts) - 配置校验:确保只加载有效的配置文件,并就常见错误(例如将文件命名为
middleware.js而非middlewares.js)发出警告 - 数据库规范化:将数据库客户端名称映射到其实际驱动名称(例如
sqlite→sqlite3),并处理连接池 - 环境设置:设置测试所需的所有环境变量,包括 JWT 密钥和数据库配置
- 自动路由注册:自动注册一个
/api/hello测试端点,你可以在测试中使用它 - 用户权限辅助:修补用户服务,使其自动为新创建的用户分配“authenticated”(已认证)角色,从而简化身份验证测试
- 清理:在测试完成后正确关闭连接并删除临时数据库文件
tests/strapi.js harness 的代码示例高亮显示了第 313–321 行,因为它们是可选的,仅在你 植入可预测的测试数据 时使用。
一旦这些文件就位,harness 会自动处理多项 Strapi 5 的要求,让你专注于编写实际的测试逻辑,而不是配置样板代码。
(optional) Seed predictable test data
某些 API 测试受益于预加载一组已知的文档。你可以将项目的种子数据逻辑暴露为一个可复用的函数,并在 harness 中通过一个环境标志来调用它:
-
从你的项目脚本(例如
./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 }; -
在测试 harness 中,当
TEST_SEED=true时调用该函数(请参阅 main test harness 代码示例中高亮显示的第 313–321 行)。 -
在启用种子数据的情况下运行测试:
Yarn
TEST_SEED=true yarn testNPM
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.
如果你收到 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 测试结合起来,有助于在问题进入生产环境之前防止回归。