test
VextJS has a complete built-in testing tool, imported through the vextjs/testing subpath. Routers, middleware, and services can be tested end-to-end without starting a real HTTP server.
Quick Start
import { describe, it, expect } from "vitest";
import { createTestApp } from "vextjs/testing";
describe("GET /health", () => {
it("should return ok", async () => {
const app = await createTestApp();
const res = await app.request.get("/health");
expect(res.status).toBe(200);
expect(res.body.data).toEqual({ status: "ok" });
});
});
VextJS recommends using Vitest as the testing framework, but the testing tool itself does not depend on any specific testing framework. You can also use it with Jest, Node.js built-in test runner, etc.
createTestApp()
createTestApp() is the core API for testing. It creates a complete application instance and simulates the request processing process, but does not start HTTP listening.
Basic usage
import { createTestApp } from "vextjs/testing";
// Use default configuration (automatically load src/routes, src/services, etc.)
const app = await createTestApp();
//Send test request
const res = await app.request.get("/users");
expect(res.status).toBe(200);
Configuration options
interface CreateTestAppOptions {
/** Custom configuration (override default.ts) */
config?: Partial<VextConfig>;
/** Whether to load plug-ins in the src/plugins/ directory (default false) */
plugins?: boolean;
/** Custom plug-in setup function (replacing file system scanning) */
setupPlugins?: (app: VextApp) => Promise<void> | void;
/** Whether to load services in the src/services/ directory (default true) */
services?: boolean;
/** Simulation service object (replacing automatic scanning service) */
mockServices?: Partial<VextServices>;
/** Whether to load routes in the src/routes/ directory (default true) */
routes?: boolean;
/** Whether to load middleware in the src/middlewares/ directory (default true) */
middlewares?: boolean;
/** Project root directory (default process.cwd()) */
rootDir?: string;
}
Common configuration scenarios
Custom port and log level
const app = await createTestApp({
config: {
port: 0,
logger: { level: "silent" }, // Silent log during testing
},
});
Skip plugin loading
const app = await createTestApp({
plugins: false, //Default value: Do not load plugins under src/plugins/
});
Simulation service
const app = await createTestApp({
services: false, // Do not load the real service
mockServices: {
user: {
findAll: async () => [{ id: "1", name: "Alice" }],
findById: async (id: string) => ({ id, name: "Alice" }),
create: async (data: any) => ({ id: "99", ...data }),
},
},
});
Custom plugin
const app = await createTestApp({
setupPlugins: async (app) => {
//Inject mock objects for testing
app.extend("testCache", new Map());
app.extend("mailer", {
send: async () => ({ messageId: "test-123" }),
});
},
});
Send test request
The object returned by createTestApp() contains the request attribute and supports all HTTP methods:
const app = await createTestApp();
//GET
const res1 = await app.request.get("/users");
// POST
const res2 = await app.request.post("/users");
// PUT
const res3 = await app.request.put("/users/1");
// PATCH
const res4 = await app.request.patch("/users/1");
//DELETE
const res5 = await app.request.delete("/users/1");
// OPTIONS
const res6 = await app.request.options("/users");
//HEAD
const res7 = await app.request.head("/users");
Chained build requests
Each HTTP method returns a TestRequestBuilder, which supports chain calls to configure the request:
const res = await app.request
.post("/users")
.set("Authorization", "Bearer test-token") // Set a single header
.headers({
// Set headers in batches
"X-Custom": "value",
"Accept-Language": "zh-CN",
})
.query({ page: "1", limit: "10" }) // Set query parameters
.type("application/json") //Set Content-Type
.send({ name: "Alice", email: "alice@example.com" }); // Set the request body
app.request
.get("/profile")
.set("Authorization", "Bearer my-token")
.set("Accept-Language", "en-US");
app.request.get("/data").headers({
Authorization: "Bearer token",
"X-Request-Id": "test-req-001",
});
.query(obj) — Set URL query parameters
app.request.get("/search").query({ keyword: "vext", page: "1", limit: "20" });
// Actual request URL: /search?keyword=vext&page=1&limit=20
.send(body) — Set the request body
//Send JSON (default Content-Type: application/json)
app.request.post("/users").send({ name: "Alice", email: "alice@example.com" });
// send string
app.request.post("/raw").type("text/plain").send("Hello World");
.type(contentType) — Set Content-Type
app.request
.post("/upload")
.type("application/x-www-form-urlencoded")
.send("name=Alice&email=alice@example.com");
TestResponse response object
A TestResponse object is returned after the request is completed:
interface TestResponse {
/** HTTP status code */
status: number;
/** Response header (lowercase key) */
headers: Record<string, string>;
/** Parsed response body (JSON is automatically parsed into an object) */
body: any;
/** Original response body text */
text: string;
}
const res = await app.request.get("/users");
// Assert status code
expect(res.status).toBe(200);
// Assert response body (JSON automatically parsed)
expect(res.body).toEqual({
code: 0,
data: [{ id: "1", name: "Alice" }],
requestId: expect.any(String),
});
// Assert response header
expect(res.headers["content-type"]).toContain("application/json");
expect(res.headers["x-request-id"]).toBeDefined();
// Assert the original text
expect(res.text).toContain('"code":0');
Test mode features
The application created by createTestApp() is in test mode (_testMode: true), which has the following differences from production mode:
Practical example
Test CRUD routing
import { describe, it, expect, beforeAll } from "vitest";
import { createTestApp, type TestApp } from "vextjs/testing";
describe("Users API", () => {
let app: TestApp;
beforeAll(async () => {
app = await createTestApp({
config: {
logger: { level: "silent" },
},
});
});
describe("GET /users", () => {
it("should return user list", async () => {
const res = await app.request.get("/users");
expect(res.status).toBe(200);
expect(res.body.code).toBe(0);
expect(Array.isArray(res.body.data.items)).toBe(true);
});
it("should support pagination", async () => {
const res = await app.request
.get("/users")
.query({ page: "2", limit: "5" });
expect(res.status).toBe(200);
});
});
describe("POST /users", () => {
it("should create a user", async () => {
const res = await app.request
.post("/users")
.set("Authorization", "Bearer test-token")
.send({ name: "Bob", email: "bob@example.com" });
expect(res.status).toBe(201);
expect(res.body.data).toMatchObject({
name: "Bob",
email: "bob@example.com",
});
});
it("should return 422 for invalid data", async () => {
const res = await app.request
.post("/users")
.set("Authorization", "Bearer test-token")
.send({ name: "", email: "invalid" });
expect(res.status).toBe(422);
expect(res.body.errors).toBeDefined();
expect(res.body.errors.length).toBeGreaterThan(0);
});it("should return 401 without token", async () => {
const res = await app.request
.post("/users")
.send({ name: "Bob", email: "bob@example.com" });
expect(res.status).toBe(401);
});
});
describe("GET /users/:id", () => {
it("should return user by id", async () => {
const res = await app.request.get("/users/1");
expect(res.status).toBe(200);
expect(res.body.data.id).toBe("1");
});
it("should return 404 for non-existent user", async () => {
const res = await app.request.get("/users/non-existent");
expect(res.status).toBe(404);
});
});
describe("DELETE /users/:id", () => {
it("should delete user", async () => {
const res = await app.request
.delete("/users/1")
.set("Authorization", "Bearer admin-token");
expect(res.status).toBe(204);
});
});
});
Test middleware
import { describe, it, expect, beforeAll } from "vitest";
import { createTestApp, type TestApp } from "vextjs/testing";
describe("Auth Middleware", () => {
let app: TestApp;
beforeAll(async () => {
app = await createTestApp({
config: {
logger: { level: "silent" },
},
});
});
it("should allow request with valid token", async () => {
const res = await app.request
.get("/admin/dashboard")
.set("Authorization", "Bearer valid-token");
expect(res.status).toBe(200);
});
it("should reject request without token", async () => {
const res = await app.request.get("/admin/dashboard");
expect(res.status).toBe(401);
expect(res.body.message).toContain("Authorization");
});
it("should reject request with expired token", async () => {
const res = await app.request
.get("/admin/dashboard")
.set("Authorization", "Bearer expired-token");
expect(res.status).toBe(401);
});
});
Use mock service testing
Use mockServices when you just want to test routing logic without relying on a real database:
import { describe, it, expect, beforeAll, vi } from "vitest";
import { createTestApp, type TestApp } from "vextjs/testing";
describe("Users API with mock services", () => {
const mockUserService = {
findAll: vi.fn().mockResolvedValue({
items: [{ id: "1", name: "Alice" }],
total: 1,
}),
findById: vi.fn().mockImplementation(async (id: string) => {
if (id === "1") return { id: "1", name: "Alice" };
return null;
}),
create: vi.fn().mockImplementation(async (data: any) => ({
id: "2",
...data,
})),
};
let app: TestApp;
beforeAll(async () => {
app = await createTestApp({
services: false,
mockServices: {
user: mockUserService,
},
config: {
logger: { level: "silent" },
},
});
});
it("should call findAll service method", async () => {
const res = await app.request.get("/users");
expect(res.status).toBe(200);
expect(mockUserService.findAll).toHaveBeenCalled();
});
it("should call findById with correct id", async () => {
await app.request.get("/users/1");
expect(mockUserService.findById).toHaveBeenCalledWith("1");
});
it("should return 404 when service returns null", async () => {
const res = await app.request.get("/users/999");
expect(res.status).toBe(404);
expect(mockUserService.findById).toHaveBeenCalledWith("999");
});
it("should pass validated body to create", async () => {
const res = await app.request
.post("/users")
.set("Authorization", "Bearer test-token")
.send({ name: "Bob", email: "bob@example.com" });expect(res.status).toBe(201);
expect(mockUserService.create).toHaveBeenCalledWith(
expect.objectContaining({ name: "Bob", email: "bob@example.com" }),
);
});
});
Test service layer (unit testing)
The service layer can be unit tested independently of HTTP. Instantiate the service directly, passing in the simulated app object:
import { describe, it, expect, vi } from "vitest";
import UserService from "../../src/services/user.js";
describe("UserService", () => {
function createMockApp() {
return {
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
throw: vi.fn().mockImplementation((status, message) => {
const err = new Error(message);
(err as any).status = status;
throw err;
}),
config: { port: 3000 },
services: {},
};
}
it("should create user", async () => {
const app = createMockApp();
const service = new UserService(app as any);
const user = await service.create({
name: "Alice",
email: "alice@test.com",
});
expect(user).toMatchObject({ name: "Alice", email: "alice@test.com" });
expect(user.id).toBeDefined();
expect(app.logger.info).toHaveBeenCalled();
});
it("should find user by id", async () => {
const app = createMockApp();
const service = new UserService(app as any);
const user = await service.findById("1");
expect(user).toBeDefined();
expect(user?.id).toBe("1");
});
});
Test error response
describe("Error handling", () => {
let app: TestApp;
beforeAll(async () => {
app = await createTestApp({
config: { logger: { level: "silent" } },
});
});
it("should return 404 for unknown routes", async () => {
const res = await app.request.get("/this-route-does-not-exist");
expect(res.status).toBe(404);
expect(res.body).toMatchObject({
code: 404,
message: expect.any(String),
requestId: expect.any(String),
});
});
it("should include requestId in error responses", async () => {
const res = await app.request.get("/non-existent");
expect(res.body.requestId).toBeDefined();
expect(typeof res.body.requestId).toBe("string");
});
it("should return 422 for validation errors", async () => {
const res = await app.request
.post("/users")
.set("Authorization", "Bearer test-token")
.send({});
expect(res.status).toBe(422);
expect(res.body.errors).toBeInstanceOf(Array);
});
});
describe("Request headers", () => {
let app: TestApp;
beforeAll(async () => {
app = await createTestApp({
config: { logger: { level: "silent" } },
});
});
it("should forward X-Request-Id header", async () => {
const customId = "my-custom-request-id";
const res = await app.request.get("/health").set("X-Request-Id", customId);
expect(res.body.requestId).toBe(customId);
});
it("should generate request id when not provided", async () => {
const res = await app.request.get("/health");
expect(res.body.requestId).toBeDefined();
expect(res.body.requestId.length).toBeGreaterThan(0);
});
});
Project configuration
TypeScript service files and ESM loading
When services: true (default), createTestApp() scans src/services/ and loads .ts source files. service-loader automatically uses esbuild internally to compile and load each .ts file bundle, completely solving two native problems:| Problem | Cause | Solution |
| ---------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| ERR_UNKNOWN_FILE_EXTENSION: .ts | Node.js native ESM does not support the .ts extension | esbuild compiles to .mjs and then import() |
| .js → .ts remapping is missing | TypeScript ESM convention is to write .js in import, and Node.js/Vite resolver does not automatically fall back to .ts | esbuild bundle: true fully resolves all local dependencies during the compilation phase |
Scenarios not affected by this restriction:
vext start (load compiled files from dist/services/*.js)
vext dev (load the esbuild compiled product from .vext/dev/services/*.js)
- Integration tests use
mockServices (bypass service-loader scanning)
Recommended mockServices for unit testing
If the test only focuses on routing logic, using mockServices + services: false is faster and completely isolated, without loading the real .ts service file:
const app = await createTestApp({
services: false,
mockServices: {
user: {
findAll: vi.fn().mockResolvedValue({ items: [], total: 0 }),
findById: vi.fn().mockResolvedValue({ id: "1", name: "Alice" }),
},
},
});
Vitest configuration
Recommended vitest.config.ts configuration:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["test/**/*.test.ts"],
coverage: {
provider: "v8",
include: ["src/**/*.ts"],
exclude: ["src/types/**", "src/cli/**"],
},
},
});
Test directory structure
Recommended test directory organization:
test/
├── unit/ # unit test
│ ├── services/
│ │ ├── user.test.ts
│ │ └── order.test.ts
│ ├── middlewares/
│ │ └── auth.test.ts
│ └── lib/
│ └── config-loader.test.ts
│
├── integration/ # Integration test
│ ├── routes/
│ │ ├── users.test.ts
│ │ └── orders.test.ts
│ └── plugins/
│ └── redis.test.ts
│
└── e2e/ # End-to-end testing
└── api.test.ts
package.json script
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run test/unit",
"test:int": "vitest run test/integration",
"test:e2e": "vitest run test/e2e",
"test:cov": "vitest run --coverage"
}
}
Best Practices
1. Silent log during test
Avoid test output being overwhelmed by log messages:
const app = await createTestApp({
config: {
logger: { level: "silent" },
},
});
2. Use beforeAll to reuse application instances
createTestApp() has some initialization overhead. Reuse application instances within the same describe block:
describe("Users API", () => {
let app: TestApp;
beforeAll(async () => {
app = await createTestApp();
});
it("test 1", async () => {
/* Reuse app */
});
it("test 2", async () => {
/* Reuse app */
});
});
3. Isolate test side effects
If your tests modify data state, use a mock service to avoid side effects between tests:
const app = await createTestApp({
services: false,
mockServices: {
user: createFreshMockUserService(), // Use a new mock for each set of tests
},
});
4. Test edge cases
Don't just test normal paths, cover error cases as well:
// ✅ Test normal + abnormal
it("should create user", async () => {
/* Create normally */
});
it("should reject invalid email", async () => {
/* Verification failed */
});
it("should reject duplicate email", async () => {
/* Business error */
});
it("should reject without auth", async () => {
/* Not authenticated */
});
it("should reject with wrong role", async () => {
/* No permission */
});
5. Assert response structure
Use toMatchObject or toEqual to assert the complete response structure instead of just checking individual fields:
// ✅ Assert the complete structure
expect(res.body).toMatchObject({
code: 0,
data: {
id: expect.any(String),
name: "Alice",
email: "alice@example.com",
},
requestId: expect.any(String),
});
// ❌ Only check a single field (easy to miss problems)
expect(res.body.data.name).toBe("Alice");
6. Use mockServices first for unit testing
Unit testing (testing routing logic, middleware, error handling) should use mockServices instead of real services for the following reasons:
- ✅ Faster (no esbuild compilation overhead, no database connection)
- ✅ Complete isolation (not dependent on service implementation details, more stable testing)
- ✅ Precise control of return values and error scenarios
// ✅ Unit testing: mock service, focusing on routing logic
const app = await createTestApp({
services: false,
mockServices: {
user: {
findAll: vi.fn().mockResolvedValue({ items: [], total: 0 }),
findById: vi.fn().mockResolvedValue(null), // Simulate "does not exist" scenario
create: vi.fn().mockRejectedValue(
// Simulate "duplicate mailbox" scenario
Object.assign(new Error("email_taken"), { status: 409 }),
),
},
},
});
// ✅ Integration testing: load real services and test the complete business process
const app = await createTestApp({
services: true, // By default, service-loader automatically handles .ts compilation
});
Next step