名称: bknd-testing 描述: 当为Bknd应用程序编写测试时使用,设置测试基础设施、创建单元/集成测试或测试API端点。涵盖内存数据库设置、测试助手、模拟和测试模式。
测试Bknd应用程序
使用Bun Test或Vitest以及内存数据库进行隔离测试,编写和运行Bknd应用程序的测试。
先决条件
- Bknd项目在本地设置完成
- 已安装测试运行器(Bun或Vitest)
- 理解异步/等待模式
何时使用UI模式
- 通过管理面板进行手动集成测试
- 测试运行后验证数据
- 快速冒烟测试
何时使用代码模式
- 自动化单元测试
- 集成测试
- CI/CD管道
- 回归测试
测试运行器设置
Bun(推荐)
Bun内置测试运行器:
# 运行所有测试
bun test
# 运行特定文件
bun test tests/posts.test.ts
# 监视模式
bun test --watch
Vitest
# 安装
bun add -D vitest
# 配置 vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
},
});
# 运行
npx vitest
内存数据库设置
使用内存SQLite进行快速、隔离的测试。
测试助手模块
创建 tests/helper.ts:
import { App, createApp as baseCreateApp } from "bknd";
import { em, entity, text, number, boolean } from "bknd";
import Database from "libsql";
// 测试用模式
export const testSchema = em({
posts: entity("posts", {
title: text().required(),
content: text(),
published: boolean(),
}),
comments: entity("comments", {
body: text().required(),
author: text(),
}),
}, (fn, s) => {
fn.relation(s.comments).manyToOne(s.posts);
});
// 创建具有内存数据库的隔离测试应用
export async function createTestApp(options?: {
seed?: (app: App) => Promise<void>;
}) {
const db = new Database(":memory:");
const app = new App({
connection: { database: db },
schema: testSchema,
});
await app.build();
if (options?.seed) {
await options.seed(app);
}
return {
app,
cleanup: () => {
db.close();
},
};
}
// 创建测试API客户端
export async function createTestClient(app: App) {
const baseUrl = "http://localhost:0"; // 占位符
return {
data: app.modules.data,
auth: app.modules.auth,
};
}
Bun专用助手
对于Bun的原生SQLite:
import { bunSqlite } from "bknd/adapter/bun";
import { Database } from "bun:sqlite";
export function createTestConnection() {
const db = new Database(":memory:");
return bunSqlite({ database: db });
}
单元测试模式
测试实体操作
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { createTestApp } from "./helper";
describe("Posts", () => {
let app: Awaited<ReturnType<typeof createTestApp>>;
beforeEach(async () => {
app = await createTestApp();
});
afterEach(() => {
app.cleanup();
});
test("创建帖子", async () => {
const result = await app.app.em
.mutator("posts")
.insertOne({ title: "测试帖子", content: "Hello" });
expect(result.id).toBeDefined();
expect(result.title).toBe("测试帖子");
});
test("读取帖子", async () => {
// 种子数据
await app.app.em.mutator("posts").insertOne({ title: "帖子1" });
await app.app.em.mutator("posts").insertOne({ title: "帖子2" });
const posts = await app.app.em.repo("posts").findMany();
expect(posts).toHaveLength(2);
});
test("更新帖子", async () => {
const created = await app.app.em
.mutator("posts")
.insertOne({ title: "原始" });
const updated = await app.app.em
.mutator("posts")
.updateOne(created.id, { title: "已更新" });
expect(updated.title).toBe("已更新");
});
test("删除帖子", async () => {
const created = await app.app.em
.mutator("posts")
.insertOne({ title: "待删除" });
await app.app.em.mutator("posts").deleteOne(created.id);
const found = await app.app.em.repo("posts").findOne(created.id);
expect(found).toBeNull();
});
});
测试关系
describe("Comments", () => {
let app: Awaited<ReturnType<typeof createTestApp>>;
beforeEach(async () => {
app = await createTestApp();
});
afterEach(() => app.cleanup());
test("创建带关系的评论", async () => {
const post = await app.app.em
.mutator("posts")
.insertOne({ title: "父帖子" });
const comment = await app.app.em
.mutator("comments")
.insertOne({
body: "好帖子!",
posts_id: post.id,
});
expect(comment.posts_id).toBe(post.id);
});
test("加载带帖子的评论", async () => {
const post = await app.app.em
.mutator("posts")
.insertOne({ title: "帖子" });
await app.app.em.mutator("comments").insertOne({
body: "评论1",
posts_id: post.id,
});
const comments = await app.app.em.repo("comments").findMany({
with: { posts: true },
});
expect(comments[0].posts).toBeDefined();
expect(comments[0].posts.title).toBe("帖子");
});
});
集成测试
HTTP API测试
测试完整HTTP堆栈:
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { serve } from "bknd/adapter/bun";
describe("API集成", () => {
let server: ReturnType<typeof Bun.serve>;
const port = 3999;
const baseUrl = `http://localhost:${port}`;
beforeAll(async () => {
server = Bun.serve({
port,
fetch: (await serve({
connection: { url: ":memory:" },
schema: testSchema,
})).fetch,
});
});
afterAll(() => {
server.stop();
});
test("GET /api/data/posts 返回200", async () => {
const res = await fetch(`${baseUrl}/api/data/posts`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual({ data: [] });
});
test("POST /api/data/posts 创建记录", async () => {
const res = await fetch(`${baseUrl}/api/data/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "API测试" }),
});
expect(res.status).toBe(201);
const { data } = await res.json();
expect(data.title).toBe("API测试");
});
});
使用SDK客户端测试
import { Api } from "bknd/client";
describe("SDK集成", () => {
let api: Api;
let server: ReturnType<typeof Bun.serve>;
beforeAll(async () => {
// 启动测试服务器
server = await startTestServer();
api = new Api({ host: "http://localhost:3999" });
});
afterAll(() => server.stop());
test("通过SDK创建和读取", async () => {
const created = await api.data.createOne("posts", {
title: "SDK测试",
});
expect(created.ok).toBe(true);
const read = await api.data.readOne("posts", created.data.id);
expect(read.data.title).toBe("SDK测试");
});
});
测试认证
认证流程测试
describe("认证", () => {
let app: Awaited<ReturnType<typeof createTestApp>>;
beforeEach(async () => {
app = await createTestApp({
auth: {
enabled: true,
strategies: {
password: {
hashing: "plain", // 仅用于测试!
},
},
},
});
});
afterEach(() => app.cleanup());
test("注册用户", async () => {
const auth = app.app.modules.auth;
const result = await auth.register({
email: "test@example.com",
password: "password123",
});
expect(result.user).toBeDefined();
expect(result.user.email).toBe("test@example.com");
});
test("使用正确密码登录", async () => {
const auth = app.app.modules.auth;
// 先注册
await auth.register({
email: "test@example.com",
password: "password123",
});
// 然后登录
const result = await auth.login({
email: "test@example.com",
password: "password123",
});
expect(result.token).toBeDefined();
});
test("使用错误密码登录失败", async () => {
const auth = app.app.modules.auth;
await auth.register({
email: "test@example.com",
password: "correct",
});
await expect(
auth.login({
email: "test@example.com",
password: "wrong",
})
).rejects.toThrow();
});
});
模拟模式
模拟Fetch
import { mock, jest } from "bun:test";
describe("外部API调用", () => {
let originalFetch: typeof fetch;
beforeAll(() => {
originalFetch = global.fetch;
// @ts-ignore
global.fetch = jest.fn(() =>
Promise.resolve(
new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
)
);
});
afterAll(() => {
global.fetch = originalFetch;
});
test("FetchTask使用模拟的fetch", async () => {
const task = new FetchTask("test", {
url: "https://api.example.com/data",
method: "GET",
});
const result = await task.run();
expect(result.success).toBe(true);
expect(global.fetch).toHaveBeenCalled();
});
});
模拟驱动程序
describe("邮件发送", () => {
test("使用模拟邮件驱动程序", async () => {
const sentEmails: any[] = [];
const app = await createTestApp({
drivers: {
email: {
send: async (to, subject, body) => {
sentEmails.push({ to, subject, body });
return { id: "mock-id" };
},
},
},
});
// 触发发送邮件
await app.app.drivers.email.send(
"user@example.com",
"测试",
"内容"
);
expect(sentEmails).toHaveLength(1);
expect(sentEmails[0].to).toBe("user@example.com");
app.cleanup();
});
});
测试数据工厂
创建可重用的测试数据工厂:
// tests/factories.ts
let counter = 0;
export function createPostData(overrides = {}) {
counter++;
return {
title: `测试帖子 ${counter}`,
content: `帖子内容 ${counter}`,
published: false,
...overrides,
};
}
export function createUserData(overrides = {}) {
counter++;
return {
email: `user${counter}@test.com`,
password: "password123",
...overrides,
};
}
// 在测试中使用
test("创建多个帖子", async () => {
const posts = await Promise.all([
app.em.mutator("posts").insertOne(createPostData()),
app.em.mutator("posts").insertOne(createPostData({ published: true })),
app.em.mutator("posts").insertOne(createPostData()),
]);
expect(posts).toHaveLength(3);
});
测试流程
import { Flow, FetchTask, Condition } from "bknd/flows";
describe("流程", () => {
test("执行带任务的流程", async () => {
const task1 = new FetchTask("fetch", {
url: "https://example.com/api",
method: "GET",
});
const flow = new Flow("testFlow", [task1]);
const execution = await flow.start({ input: "value" });
expect(execution.hasErrors()).toBe(false);
expect(execution.getResponse()).toBeDefined();
});
test("处理任务错误", async () => {
const failingTask = new FetchTask("fail", {
url: "https://invalid-url-that-fails.test",
method: "GET",
});
const flow = new Flow("failFlow", [failingTask]);
const execution = await flow.start({});
expect(execution.hasErrors()).toBe(true);
expect(execution.getErrors()).toHaveLength(1);
});
});
CI/CD配置
GitHub Actions
# .github/workflows/test.yml
name: 测试
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- run: bun install
- run: bun test
预提交钩子
# .husky/pre-commit
#!/bin/sh
bun test --bail
项目结构
my-bknd-app/
├── src/
│ └── ...
├── tests/
│ ├── helper.ts # 测试工具
│ ├── factories.ts # 数据工厂
│ ├── unit/
│ │ ├── posts.test.ts
│ │ └── auth.test.ts
│ └── integration/
│ ├── api.test.ts
│ └── flows.test.ts
├── bknd.config.ts
└── package.json
常见陷阱
数据库未隔离
问题: 测试共享状态,导致不稳定测试。
解决方案: 每次测试创建新的内存数据库:
beforeEach(async () => {
app = await createTestApp(); // 每次新数据库
});
afterEach(() => {
app.cleanup(); // 关闭连接
});
异步清理问题
问题: 测试挂起或资源泄漏。
解决方案: 始终等待清理:
afterEach(async () => {
await app.cleanup();
});
afterAll(async () => {
await server.stop();
});
缺少等待断言
问题: 异步操作完成前测试通过。
解决方案: 始终等待异步操作:
// 错误
test("静默失败", () => {
expect(api.data.readMany("posts")).resolves.toBeDefined();
});
// 正确
test("正确等待", async () => {
const result = await api.data.readMany("posts");
expect(result).toBeDefined();
});
测试生产数据库
问题: 测试修改真实数据。
解决方案: 始终使用 :memory: 或测试特定文件:
// 安全
connection: { url: ":memory:" }
// 也安全
connection: { url: "file:test-${Date.now()}.db" }
// 危险 - 测试中不要使用
connection: { url: process.env.DB_URL }
应做和不应做
应做:
- 使用内存数据库提高速度和隔离性
- 在afterEach/afterAll中清理资源
- 创建测试助手和工厂
- 测试成功和错误路径
- 使用有意义的测试描述
- 保持测试彼此独立
不应做:
- 测试间共享数据库状态
- 测试中使用生产凭据
- 跳过异步操作的等待
- 编写依赖执行顺序的测试
- 在测试外使用
plain密码哈希 - 提交测试数据库文件
相关技能
- bknd-local-setup - 开发环境设置
- bknd-debugging - 故障排除测试失败
- bknd-seed-data - 创建测试数据模式
- bknd-crud-create - 理解数据操作
- bknd-setup-auth - 测试的认证配置