测试工具
本页详细介绍 VextJS 的测试工具 API,包括 createTestApp、TestApp、TestRequest、TestRequestBuilder 和 TestResponse。
概述
VextJS 提供零配置的测试工具,通过 vextjs/testing 子路径导入:
import { createTestApp } from "vextjs/testing";
核心设计:
- 零网络 I/O:
TestRequest 内部不启动 HTTP 服务器,直接通过 adapter.buildHandler() 构造 (req, res) handler,用内存中的 Mock 对象模拟请求。比 supertest 更快,CI 中可并行运行无端口冲突。
- 零配置:默认禁用限流、静默日志、随机端口,开箱即用。
- 链式 API:类似
supertest 风格的链式请求构造器,支持 await 直接获取响应。
- 安全退出:
config._testMode = true 阻止 shutdown() 调用 process.exit(0)。
createTestApp
createTestApp 是测试用 App 工厂函数,创建一个完整的测试应用实例。
函数签名
async function createTestApp(options?: CreateTestAppOptions): Promise<TestApp>;
基本用法
import { describe, it, expect, afterEach } from "vitest";
import { createTestApp } from "vextjs/testing";
describe("用户接口", () => {
let testApp;
afterEach(async () => {
await testApp?.close();
});
it("GET /users/list 应返回用户列表", async () => {
testApp = await createTestApp();
const res = await testApp.request
.get("/users/list")
.query({ page: "1", limit: "10" });
expect(res.status).toBe(200);
expect(res.body.code).toBe(0);
expect(Array.isArray(res.body.data)).toBe(true);
});
});
返回值
返回 Promise<TestApp>,包含 app、request 和 close 三个成员。
CreateTestAppOptions
createTestApp 的配置选项。所有字段均为可选。
interface CreateTestAppOptions {
config?: Partial<VextConfig>;
plugins?: boolean;
setupPlugins?: (app: VextApp) => Promise<void> | void;
services?: boolean;
mockServices?: Partial<VextServices>;
routes?: boolean;
middlewares?: boolean;
rootDir?: string;
}
字段说明
config
覆盖测试默认配置,深度合并。
testApp = await createTestApp({
config: {
adapter: "fastify", // 测试特定 adapter
response: { wrap: false }, // 禁用出口包装
cors: { enabled: false }, // 禁用 CORS
},
});
测试默认配置(自动应用,无需手动设置):
{
port: 0, // 随机端口,避免冲突
host: '127.0.0.1',
logger: { level: 'silent' }, // 日志静默
rateLimit: {
enabled: false, // 禁用限流
max: 100,
window: 60,
message: 'Too Many Requests',
keyBy: 'ip',
},
shutdown: { timeout: 1 }, // 快速关闭(1 秒)
_testMode: true, // 阻止 process.exit()
}
配置合并优先级:测试默认值 → DEFAULT_CONFIG → config 参数
plugins
控制是否自动扫描 src/plugins/ 目录加载插件。
// 默认不加载插件(单元测试通常不需要真实插件)
testApp = await createTestApp(); // plugins: false
// 集成测试可能需要加载插件
testApp = await createTestApp({ plugins: true });
setupPlugins
手动注册插件,替代自动扫描。适用于需要精确控制测试依赖的场景。
testApp = await createTestApp({
setupPlugins: async (app) => {
// 只注册测试需要的插件
app.extend("cache", new MockCache());
app.extend("db", new MockDatabase());
},
});
Tip
setupPlugins 用于替代自动扫描:当传入 setupPlugins 时,测试工具只执行该函数,不再读取 plugins: true 触发的文件系统扫描。若需要真实插件,请使用 plugins: true;若需要精确控制测试依赖,请只使用 setupPlugins。
services
控制是否自动加载 src/services/ 目录的服务。
// 加载真实服务(默认,集成测试推荐)
testApp = await createTestApp(); // services: true
// 不加载服务(单元测试推荐:使用 mockServices 替代)
testApp = await createTestApp({
services: false,
mockServices: {
user: {
findAll: vi.fn().mockResolvedValue({ items: [], total: 0 }),
findById: vi.fn().mockResolvedValue({ id: "1", name: "Test User" }),
},
},
});
TypeScript 服务文件加载机制
当 services: true 时,service-loader 扫描 src/services/ 目录并自动加载 .ts 源文件。由于 Node.js 原生 ESM 存在两个限制,框架内部使用 esbuild 自动处理:
service-loader 对每个 .ts 服务文件执行以下流程:
- 调用
esbuild.build({ bundle: true, packages: 'external' }) 将 .ts 及其本地相对依赖打包为 .mjs
- 将编译产物写到源文件同目录的临时文件(命名含
.__vext_compiled__,不会被重复扫描)
import() 临时 .mjs 文件,完成后自动清理
单元测试推荐:优先使用 mockServices
services: false + mockServices 方案无需 esbuild 编译,速度更快且隔离性更好,是单元测试的推荐方式。services: true 适用于需要真实服务行为的集成测试。
mockServices
手动注入 mock 服务,覆盖 service-loader 扫描结果。
const mockUserService = {
findAll: vi.fn().mockResolvedValue([
{ id: "1", name: "Alice" },
{ id: "2", name: "Bob" },
]),
findById: vi.fn().mockResolvedValue({ id: "1", name: "Alice" }),
create: vi.fn().mockImplementation(async (data) => ({
id: "3",
...data,
})),
update: vi.fn().mockResolvedValue({ id: "1", name: "Updated" }),
delete: vi.fn().mockResolvedValue(undefined),
};
testApp = await createTestApp({
mockServices: {
user: mockUserService,
},
});
合并逻辑:
routes
控制是否自动加载 src/routes/ 目录的路由文件。
// 加载真实路由(默认,集成测试)
testApp = await createTestApp(); // routes: true
// 不加载路由(单元测试服务层时不需要路由)
testApp = await createTestApp({ routes: false });
middlewares
控制是否自动加载 src/middlewares/ 目录的用户中间件。
// 加载用户中间件(默认)
testApp = await createTestApp(); // middlewares: true
// 跳过用户中间件加载(测试不涉及路由级中间件时)
testApp = await createTestApp({ middlewares: false });
Tip
无论 middlewares 设置如何,内置中间件(requestId / cors / bodyParser / responseWrapper / errorHandler)始终会注册。此选项只控制 src/middlewares/ 目录下的用户自定义中间件。
rootDir
项目根目录,用于定位 src/routes、src/services、src/plugins 等目录。
import { join } from "node:path";
testApp = await createTestApp({
rootDir: join(__dirname, "../../"), // 自定义项目根目录
});
TestApp
createTestApp 返回的测试应用实例。
interface TestApp {
app: VextApp;
request: TestRequest;
close(): Promise<void>;
}
app
底层 VextApp 实例,可用于直接访问应用能力:
const testApp = await createTestApp();
// 访问配置
console.log(testApp.app.config.port);
// 访问服务
const user = await testApp.app.services.user.findById("1");
// 访问日志
testApp.app.logger.info("测试中");
request
HTTP 请求模拟器,类似 supertest 风格的 API。详见 TestRequest。
close()
关闭测试应用,触发 onClose 钩子、清理资源。
Warning
务必在 afterEach 或 afterAll 中调用 close(),否则会导致资源泄漏(数据库连接、定时器等)和测试进程无法退出。
import { afterEach } from "vitest";
let testApp;
afterEach(async () => {
await testApp?.close();
});
TestRequest
HTTP 请求模拟器,提供类似 supertest 风格的链式 API。
interface TestRequest {
get(path: string): TestRequestBuilder;
post(path: string): TestRequestBuilder;
put(path: string): TestRequestBuilder;
patch(path: string): TestRequestBuilder;
delete(path: string): TestRequestBuilder;
options(path: string): TestRequestBuilder;
head(path: string): TestRequestBuilder;
}
支持的 HTTP 方法
每个方法返回 TestRequestBuilder,支持链式配置后通过 await 执行请求。
基本用法
// GET 请求
const res = await testApp.request.get("/users/list");
// POST 请求
const res = await testApp.request.post("/users").send({
name: "Alice",
email: "alice@example.com",
});
// PUT 请求
const res = await testApp.request.put("/users/1").send({
name: "Alice Updated",
});
// DELETE 请求
const res = await testApp.request.delete("/users/1");
// OPTIONS 请求(CORS 预检)
const res = await testApp.request.options("/users");
TestRequestBuilder
链式请求构造器,支持设置请求头、查询参数、请求体等,最终通过 await 或 .then() 执行请求。
interface TestRequestBuilder extends PromiseLike<TestResponse> {
set(key: string, value: string): this;
headers(headers: Record<string, string>): this;
query(params: Record<string, string | number | boolean>): this;
send(body: unknown): this;
type(contentType: string): this;
}
Tip
TestRequestBuilder 实现了 PromiseLike 接口,因此可以直接使用 await 执行请求,无需调用额外的 .execute() 方法。
set(key, value)
设置单个请求头。
set(key: string, value: string): this;
const res = await testApp.request
.get("/profile")
.set("Authorization", "Bearer eyJ...")
.set("Accept-Language", "zh-CN");
expect(res.status).toBe(200);
设置多个请求头(对象形式)。
headers(headers: Record<string, string>): this;
const res = await testApp.request.get("/profile").headers({
Authorization: "Bearer eyJ...",
"Accept-Language": "zh-CN",
"X-Custom-Header": "custom-value",
});
Tip
set() 和 headers() 可以混合使用,后设置的值会覆盖先设置的同名请求头。
query(params)
设置 URL 查询参数。
query(params: Record<string, string | number | boolean>): this;
参数值会自动转换为字符串并 URL 编码。
const res = await testApp.request
.get("/users/list")
.query({ page: 1, limit: 10, active: true });
// 等价于 GET /users/list?page=1&limit=10&active=true
expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(10);
多次调用 query() 会合并参数:
const res = await testApp.request
.get("/search")
.query({ keyword: "hello" })
.query({ page: 1 });
// 等价于 GET /search?keyword=hello&page=1
send(body)
设置请求体。自动序列化为 JSON 并设置 Content-Type: application/json。
send(body: unknown): this;
// JSON 对象
const res = await testApp.request
.post("/users")
.send({ name: "Alice", email: "alice@example.com" });
expect(res.status).toBe(201);
expect(res.body.data.name).toBe("Alice");
// 嵌套对象
const res = await testApp.request.post("/orders").send({
items: [
{ productId: "p1", quantity: 2 },
{ productId: "p2", quantity: 1 },
],
shippingAddress: {
city: "北京",
street: "朝阳区xxx路",
},
});
// 字符串(直接发送,不做 JSON 序列化)
const res = await testApp.request
.post("/webhook")
.type("text/plain")
.send("raw text body");
type(contentType)
设置 Content-Type 请求头。
type(contentType: string): this;
// 发送 form-urlencoded
const res = await testApp.request
.post("/login")
.type("application/x-www-form-urlencoded")
.send("username=alice&password=secret");
// 发送 XML
const res = await testApp.request
.post("/xml-endpoint")
.type("application/xml")
.send("<user><name>Alice</name></user>");
Tip
send() 会自动设置 Content-Type: application/json(如果尚未设置)。如果需要其他类型,在 send() 之前调用 type() 进行覆盖。
链式组合
所有方法支持链式调用,最终通过 await 执行请求:
const res = await testApp.request
.post("/users")
.set("Authorization", "Bearer eyJ...")
.set("X-Request-Id", "test-req-001")
.query({ notify: "true" })
.type("application/json")
.send({
name: "Alice",
email: "alice@example.com",
role: "admin",
});
expect(res.status).toBe(201);
expect(res.body.code).toBe(0);
expect(res.body.data.name).toBe("Alice");
expect(res.headers["x-request-id"]).toBe("test-req-001");
TestResponse
模拟 HTTP 响应对象,包含状态码、响应头和解析后的响应体。
interface TestResponse {
status: number;
headers: Record<string, string>;
body: any;
text: string;
}
status
HTTP 状态码。
const res = await testApp.request.get("/users/list");
expect(res.status).toBe(200);
const res2 = await testApp.request.get("/users/nonexistent");
expect(res2.status).toBe(404);
const res3 = await testApp.request
.post("/users")
.send({ name: "Alice", email: "alice@example.com" });
expect(res3.status).toBe(201);
响应头对象,所有 key 为小写。
headers: Record<string, string>;
const res = await testApp.request.get("/users/list");
// 检查 Content-Type
expect(res.headers["content-type"]).toContain("application/json");
// 检查自定义响应头
expect(res.headers["x-request-id"]).toBeDefined();
// 检查 CORS 头
expect(res.headers["access-control-allow-origin"]).toBeDefined();
body
自动解析的 JSON 响应体。如果响应的 Content-Type 为 application/json,body 为解析后的 JavaScript 对象/数组;否则为 undefined。
const res = await testApp.request.get("/users/list");
// 出口包装格式
expect(res.body).toEqual({
code: 0,
data: expect.any(Array),
requestId: expect.any(String),
});
// 直接访问业务数据
expect(res.body.data).toHaveLength(2);
expect(res.body.data[0].name).toBe("Alice");
错误响应:
const res = await testApp.request.get("/users/nonexistent-id");
expect(res.body).toEqual({
code: -1,
message: "用户不存在",
requestId: expect.any(String),
});
带业务错误码:
const res = await testApp.request.post("/users").send({
email: "existing@example.com",
});
expect(res.body.code).toBe(10001);
expect(res.body.message).toBe("邮箱已注册");
text
原始响应文本。对于 JSON 响应,text 是 JSON 字符串;对于文本响应,text 是原始文本内容。
// JSON 响应的原始文本
const res = await testApp.request.get("/users/list");
console.log(res.text);
// '{"code":0,"data":[...],"requestId":"..."}'
// 文本响应
const res2 = await testApp.request.get("/health");
expect(res2.text).toBe("OK");
使用模式
集成测试
加载真实的路由、服务、中间件,验证完整的请求-响应流程:
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createTestApp } from "vextjs/testing";
import type { TestApp } from "vextjs";
describe("用户 CRUD", () => {
let testApp: TestApp;
beforeEach(async () => {
testApp = await createTestApp({
plugins: true, // 加载真实插件(如数据库)
});
});
afterEach(async () => {
await testApp.close();
});
it("创建用户", async () => {
const res = await testApp.request
.post("/users")
.set("Authorization", "Bearer test-admin-token")
.send({
name: "Alice",
email: "alice@example.com",
});
expect(res.status).toBe(201);
expect(res.body.code).toBe(0);
expect(res.body.data).toMatchObject({
name: "Alice",
email: "alice@example.com",
});
expect(res.body.data.id).toBeDefined();
});
it("查询用户列表", async () => {
const res = await testApp.request
.get("/users/list")
.query({ page: 1, limit: 10 });
expect(res.status).toBe(200);
expect(res.body.code).toBe(0);
expect(Array.isArray(res.body.data)).toBe(true);
});
it("用户不存在应返回 404", async () => {
const res = await testApp.request.get("/users/nonexistent");
expect(res.status).toBe(404);
expect(res.body.message).toBe("用户不存在");
});
it("参数校验失败应返回 400", async () => {
const res = await testApp.request
.post("/users")
.set("Authorization", "Bearer test-admin-token")
.send({
name: "", // 不满足 string:1-50
email: "invalid-email", // 不满足 email 格式
});
expect(res.status).toBe(400);
expect(res.body.errors).toBeDefined();
expect(res.body.errors.length).toBeGreaterThan(0);
});
});
单元测试(Mock Services)
不加载真实服务,使用 mock 替代,专注测试路由逻辑:
import { describe, it, expect, afterEach, vi } from "vitest";
import { createTestApp } from "vextjs/testing";
describe("用户路由(mock 服务)", () => {
let testApp;
const mockUserService = {
findAll: vi.fn().mockResolvedValue([
{ id: "1", name: "Alice" },
{ id: "2", name: "Bob" },
]),
findById: vi.fn().mockImplementation(async (id) => {
if (id === "1") return { id: "1", name: "Alice" };
return null;
}),
create: vi.fn().mockImplementation(async (data) => ({
id: "3",
...data,
createdAt: new Date().toISOString(),
})),
};
afterEach(async () => {
await testApp?.close();
vi.clearAllMocks();
});
it("GET /users/list 调用 findAll", async () => {
testApp = await createTestApp({
mockServices: { user: mockUserService },
});
const res = await testApp.request
.get("/users/list")
.query({ page: "1", limit: "10" });
expect(res.status).toBe(200);
expect(mockUserService.findAll).toHaveBeenCalledOnce();
});
it("GET /users/:id 存在时返回用户", async () => {
testApp = await createTestApp({
mockServices: { user: mockUserService },
});
const res = await testApp.request.get("/users/1");
expect(res.status).toBe(200);
expect(res.body.data.name).toBe("Alice");
expect(mockUserService.findById).toHaveBeenCalledWith("1");
});
it("GET /users/:id 不存在时返回 404", async () => {
testApp = await createTestApp({
mockServices: { user: mockUserService },
});
const res = await testApp.request.get("/users/999");
expect(res.status).toBe(404);
});
});
测试中间件
验证认证、权限等中间件的行为:
import { describe, it, expect, afterEach } from "vitest";
import { createTestApp } from "vextjs/testing";
describe("认证中间件", () => {
let testApp;
afterEach(async () => {
await testApp?.close();
});
it("无 token 应返回 401", async () => {
testApp = await createTestApp();
const res = await testApp.request.post("/users").send({
name: "Alice",
email: "alice@example.com",
});
expect(res.status).toBe(401);
expect(res.body.message).toContain("认证");
});
it("无效 token 应返回 401", async () => {
testApp = await createTestApp();
const res = await testApp.request
.post("/users")
.set("Authorization", "Bearer invalid-token")
.send({
name: "Alice",
email: "alice@example.com",
});
expect(res.status).toBe(401);
});
it("有效 token 应正常通过", async () => {
testApp = await createTestApp();
const res = await testApp.request
.post("/users")
.set("Authorization", "Bearer valid-admin-token")
.send({
name: "Alice",
email: "alice@example.com",
});
expect(res.status).not.toBe(401);
});
});
测试不同 Adapter
验证 Adapter 切换后行为一致:
import { describe, it, expect, afterEach } from "vitest";
import { createTestApp } from "vextjs/testing";
const adapters = ["native", "hono", "fastify", "express", "koa"] as const;
describe.each(adapters)("Adapter: %s", (adapter) => {
let testApp;
afterEach(async () => {
await testApp?.close();
});
it("GET /health 应返回 200", async () => {
testApp = await createTestApp({
config: { adapter },
});
const res = await testApp.request.get("/health");
expect(res.status).toBe(200);
});
it("POST JSON 应正确解析 body", async () => {
testApp = await createTestApp({
config: { adapter },
});
const res = await testApp.request.post("/echo").send({ message: "hello" });
expect(res.status).toBe(200);
expect(res.body.data.message).toBe("hello");
});
});
测试自定义插件
使用 setupPlugins 注册测试专用插件:
import { describe, it, expect, afterEach } from "vitest";
import { createTestApp } from "vextjs/testing";
describe("Redis 缓存插件", () => {
let testApp;
afterEach(async () => {
await testApp?.close();
});
it("缓存命中时直接返回", async () => {
const mockCache = new Map();
mockCache.set(
"cache:/users/1",
JSON.stringify({ id: "1", name: "Cached Alice" }),
);
testApp = await createTestApp({
setupPlugins: async (app) => {
app.extend("cache", {
get: async (key) => mockCache.get(key) ?? null,
set: async (key, value, ttl) => mockCache.set(key, value),
del: async (key) => mockCache.delete(key),
});
},
});
// 假设路由有缓存中间件,应返回缓存数据
const res = await testApp.request.get("/users/1");
expect(res.status).toBe(200);
});
});
测试错误处理
import { describe, it, expect, afterEach } from "vitest";
import { createTestApp } from "vextjs/testing";
describe("错误处理", () => {
let testApp;
afterEach(async () => {
await testApp?.close();
});
it("404 路由应返回标准格式", async () => {
testApp = await createTestApp();
const res = await testApp.request.get("/nonexistent-path");
expect(res.status).toBe(404);
expect(res.body).toMatchObject({
code: expect.any(Number),
message: expect.any(String),
requestId: expect.any(String),
});
});
it("校验失败应返回错误列表", async () => {
testApp = await createTestApp();
const res = await testApp.request
.post("/users")
.set("Authorization", "Bearer valid-token")
.send({}); // 缺少必填字段
expect(res.status).toBe(400);
expect(res.body.errors).toBeDefined();
expect(Array.isArray(res.body.errors)).toBe(true);
for (const error of res.body.errors) {
expect(error).toHaveProperty("field");
expect(error).toHaveProperty("message");
}
});
it("500 错误在生产模式隐藏详情", async () => {
testApp = await createTestApp({
config: {
response: { hideInternalErrors: true },
},
mockServices: {
user: {
findAll: vi.fn().mockRejectedValue(new Error("数据库连接失败")),
},
},
});
const res = await testApp.request.get("/users/list");
expect(res.status).toBe(500);
expect(res.body.message).toBe("Internal Server Error");
// 不应暴露内部错误信息
expect(res.body.message).not.toContain("数据库连接失败");
});
});
测试出口包装
describe("出口包装", () => {
it("wrap: true 时响应包含 code/data/requestId", async () => {
const testApp = await createTestApp({
config: { response: { wrap: true } },
});
const res = await testApp.request.get("/health");
expect(res.body).toHaveProperty("code", 0);
expect(res.body).toHaveProperty("data");
expect(res.body).toHaveProperty("requestId");
await testApp.close();
});
it("wrap: false 时响应为原始数据", async () => {
const testApp = await createTestApp({
config: { response: { wrap: false } },
});
const res = await testApp.request.get("/health");
// 原始数据,无 code/data 包装
expect(res.body).not.toHaveProperty("code");
expect(res.body).toHaveProperty("status", "ok");
await testApp.close();
});
});
最佳实践
1. 始终调用 close()
afterEach(async () => {
await testApp?.close();
});
防止资源泄漏导致测试进程挂起。使用 ?. 可选链防止 testApp 未初始化时报错。
2. 每个测试独立创建 TestApp
// ✅ 推荐:每个测试独立 app
it("test 1", async () => {
testApp = await createTestApp();
// ...
});
it("test 2", async () => {
testApp = await createTestApp();
// ...
});
// ❌ 避免:共享 app(测试间可能互相影响)
// beforeAll(async () => {
// testApp = await createTestApp();
// });
3. Mock 外部依赖
testApp = await createTestApp({
services: false,
mockServices: {
user: mockUserService,
email: mockEmailService,
},
});
单元测试中 mock 掉数据库、外部 API 等依赖,只测试被测代码本身。
4. 使用 vi.fn() 验证调用
const mockService = {
create: vi.fn().mockResolvedValue({ id: "1" }),
};
testApp = await createTestApp({ mockServices: { user: mockService } });
await testApp.request.post("/users").send({ name: "Alice" });
expect(mockService.create).toHaveBeenCalledWith(
expect.objectContaining({ name: "Alice" }),
);
5. 测试描述用中文
describe('用户管理接口', () => {
it('创建用户成功应返回 201', async () => { ... });
it('邮箱重复应返回 409', async () => { ... });
it('未认证应返回 401', async () => { ... });
});
类型导入
// 运行时值
import { createTestApp } from "vextjs/testing";
// 类型(从主入口导入)
import type {
CreateTestAppOptions,
TestApp,
TestRequest,
TestRequestBuilder,
TestResponse,
} from "vextjs";
Tip
测试工具通过 vextjs/testing 子路径导入(运行时值),类型可从 vextjs 主入口导入。这样设计是为了避免测试依赖(如 mock 相关代码)污染生产代码的打包体积。