测试工具

本页详细介绍 VextJS 的测试工具 API,包括 createTestAppTestAppTestRequestTestRequestBuilderTestResponse

概述

VextJS 提供零配置的测试工具,通过 vextjs/testing 子路径导入:

import { createTestApp } from "vextjs/testing";

核心设计

  • 零网络 I/OTestRequest 内部不启动 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>,包含 apprequestclose 三个成员。


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

字段说明

字段类型默认值说明
configPartial<VextConfig>{}覆盖默认配置(深度合并到测试默认配置之上)
pluginsbooleanfalse是否加载 src/plugins/(测试环境默认不加载)
setupPluginsFunctionundefined手动注册插件(替代自动扫描)
servicesbooleantrue是否加载 src/services/
mockServicesPartial<VextServices>undefined手动注入 mock services
routesbooleantrue是否加载 src/routes/
middlewaresbooleantrue是否加载 src/middlewares/
rootDirstringprocess.cwd()项目根目录(用于定位 src/ 子目录)

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_CONFIGconfig 参数


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 自动处理:

限制说明
ERR_UNKNOWN_FILE_EXTENSION: .tsNode.js 原生 ESM 不支持直接 import() .ts 文件
.js → .ts 重映射缺失TypeScript ESM 约定在 import 中写 .js 扩展名,Node.js/Vite resolver 均不自动回退到 .ts

service-loader 对每个 .ts 服务文件执行以下流程:

  1. 调用 esbuild.build({ bundle: true, packages: 'external' }).ts 及其本地相对依赖打包为 .mjs
  2. 将编译产物写到源文件同目录的临时文件(命名含 .__vext_compiled__,不会被重复扫描)
  3. 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,
  },
});

合并逻辑

servicesmockServices行为
true有值先加载真实服务,再用 mockServices 覆盖同名服务
true无值仅使用真实服务
false有值仅使用 mockServices
false无值app.services 为空对象 {}

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/routessrc/servicessrc/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 钩子、清理资源。

close(): Promise<void>;
Warning

务必在 afterEachafterAll 中调用 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 方法

方法说明
request.get(path)发送 GET 请求
request.post(path)发送 POST 请求
request.put(path)发送 PUT 请求
request.patch(path)发送 PATCH 请求
request.delete(path)发送 DELETE 请求
request.options(path)发送 OPTIONS 请求
request.head(path)发送 HEAD 请求

每个方法返回 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)

设置多个请求头(对象形式)。

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 状态码。

status: number;
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);

headers

响应头对象,所有 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-Typeapplication/jsonbody 为解析后的 JavaScript 对象/数组;否则为 undefined

body: any;
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 是原始文本内容。

text: string;
// 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 相关代码)污染生产代码的打包体积。