插件测试
测试是保证插件质量的关键环节。本文介绍如何为 Obsidian 插件编写和运行测试,包括单元测试、集成测试和端到端测试。
为什么需要测试?
| 没有测试 | 有测试 |
|---|---|
| 修改代码心惊胆战 | 重构时信心十足 |
| 手动反复验证 | 自动验证所有场景 |
| 回归问题频繁 | 变更立即发现问题 |
| 难以处理边界情况 | 边界情况有覆盖 |
| 发布后才发现 Bug | 发布前拦截问题 |
测试框架选择
| 框架 | 特点 | 推荐场景 |
|---|---|---|
| Vitest | 快速、ESM 原生、Vite 兼容 | 新项目首选 |
| Jest | 生态成熟、社区庞大 | 需要丰富插件 |
| Node Test Runner | Node.js 内置 | 零依赖场景 |
推荐 Vitest
Vitest 与 Vite/ESM 生态更兼容,启动速度快,且 API 与 Jest 兼容,迁移成本低。新项目建议选择 Vitest。
环境搭建
安装 Vitest
bash
npm install -D vitest安装 Jest
bash
npm install -D jest ts-jest @types/jest配置 Vitest
创建 vitest.config.ts:
typescript
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
},
},
});配置 Jest
创建 jest.config.js:
javascript
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
roots: ["<rootDir>/src"],
testMatch: ["**/*.test.ts"],
moduleFileExtensions: ["ts", "js", "json"],
};添加测试脚本
在 package.json 中添加:
json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}Mock Obsidian API
由于测试环境没有真正的 Obsidian 应用,需要模拟 Obsidian API。
创建 Mock 模块
typescript
// src/__mocks__/obsidian.ts
export class Plugin {
app: any;
manifest: any;
settings: any;
constructor(app: any, manifest: any) {
this.app = app;
this.manifest = manifest;
}
addSettingTab() {}
registerView() {}
loadData() { return Promise.resolve({}); }
saveData() { return Promise.resolve(); }
}
export class PluginSettingTab {
app: any;
plugin: any;
containerEl: HTMLElement;
constructor(app: any, plugin: any) {
this.app = app;
this.plugin = plugin;
this.containerEl = document.createElement("div");
}
display() {}
hide() {}
}
export class Notice {
message: string;
constructor(message: string) {
this.message = message;
}
}
export class Modal {
app: any;
contentEl: HTMLElement;
constructor(app: any) {
this.app = app;
this.contentEl = document.createElement("div");
}
open() {}
close() {}
}
export class ItemView {
app: any;
containerEl: HTMLElement;
constructor(leaf: any) {
this.containerEl = document.createElement("div");
}
getViewType() { return "test-view"; }
getDisplayText() { return "Test View"; }
}
export class WorkspaceLeaf {
view: any;
constructor() {
this.view = null;
}
}
export function MarkdownRenderer.render(app: any, markdown: string, el: HTMLElement, path: string, component: any) {
el.innerHTML = markdown;
}
export function moment() {
return {
format: (fmt: string) => "2024-01-15",
};
}
export function normalizePath(path: string): string {
return path.replace(/\\/g, "/");
}
export function sanitizeFileName(name: string): string {
return name.replace(/[\\/:*?"<>|]/g, "_");
}配置 Vitest Mock
在 vitest.config.ts 中配置路径映射:
typescript
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
alias: {
"obsidian": path.resolve(__dirname, "src/__mocks__/obsidian.ts"),
},
},
});创建 Mock App
typescript
// src/__mocks__/mockApp.ts
export function createMockApp() {
const files = new Map<string, string>();
return {
vault: {
getMarkdownFiles: () => Array.from(files.keys()).map((p) => ({ path: p })),
read: (file: { path: string }) => Promise.resolve(files.get(file.path) || ""),
create: (path: string, content: string) => {
files.set(path, content);
return Promise.resolve({ path });
},
modify: (file: { path: string }, content: string) => {
files.set(file.path, content);
return Promise.resolve();
},
delete: (file: { path: string }) => {
files.delete(file.path);
return Promise.resolve();
},
rename: (file: { path: string }, newPath: string) => {
const content = files.get(file.path);
files.delete(file.path);
if (content !== undefined) files.set(newPath, content);
return Promise.resolve();
},
_files: files, // 测试中可访问
},
workspace: {
getActiveFile: () => null,
on: () => ({ unsubscribe: () => {} }),
off: () => {},
},
metadataCache: {
getFileCache: () => ({ frontmatter: {} }),
on: () => ({ unsubscribe: () => {} }),
},
internalPlugins: {
plugins: {},
},
commands: {
executeCommandById: () => {},
},
};
}编写单元测试
基础测试
typescript
// src/utils/formatDate.test.ts
import { describe, it, expect } from "vitest";
import { formatDate, parseDate } from "../utils/formatDate";
describe("formatDate", () => {
it("应该正确格式化日期", () => {
const date = new Date(2024, 0, 15); // 2024-01-15
expect(formatDate(date, "YYYY-MM-DD")).toBe("2024-01-15");
});
it("应该处理月份补零", () => {
const date = new Date(2024, 2, 5); // 2024-03-05
expect(formatDate(date, "YYYY-MM-DD")).toBe("2024-03-05");
});
it("应该支持自定义格式", () => {
const date = new Date(2024, 0, 15);
expect(formatDate(date, "DD/MM/YYYY")).toBe("15/01/2024");
});
});
describe("parseDate", () => {
it("应该解析标准日期字符串", () => {
const result = parseDate("2024-01-15");
expect(result).toBeInstanceOf(Date);
expect(result.getFullYear()).toBe(2024);
});
it("应该返回 null 对于无效输入", () => {
expect(parseDate("invalid")).toBeNull();
});
it("应该处理空字符串", () => {
expect(parseDate("")).toBeNull();
});
});测试插件功能
typescript
// src/settings.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { DEFAULT_SETTINGS, validateSettings } from "./settings";
describe("DEFAULT_SETTINGS", () => {
it("应该包含所有必要字段", () => {
expect(DEFAULT_SETTINGS).toHaveProperty("apiKey");
expect(DEFAULT_SETTINGS).toHaveProperty("maxResults");
expect(DEFAULT_SETTINGS).toHaveProperty("enableFeature");
});
it("maxResults 应该有合理的默认值", () => {
expect(DEFAULT_SETTINGS.maxResults).toBeGreaterThan(0);
expect(DEFAULT_SETTINGS.maxResults).toBeLessThanOrEqual(100);
});
});
describe("validateSettings", () => {
it("应该验证有效的设置", () => {
const result = validateSettings(DEFAULT_SETTINGS);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("应该拒绝无效的 maxResults", () => {
const result = validateSettings({
...DEFAULT_SETTINGS,
maxResults: -1,
});
expect(result.valid).toBe(false);
expect(result.errors).toContain("maxResults 必须大于 0");
});
it("应该拒绝空字符串的 apiKey", () => {
const result = validateSettings({
...DEFAULT_SETTINGS,
apiKey: "",
});
expect(result.valid).toBe(false);
});
});测试文件操作
typescript
// src/fileOperations.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { createMockApp } from "../__mocks__/mockApp";
import { FileOperations } from "./fileOperations";
describe("FileOperations", () => {
let fileOps: FileOperations;
let mockApp: any;
beforeEach(() => {
mockApp = createMockApp();
fileOps = new FileOperations(mockApp);
});
describe("createNote", () => {
it("应该创建新笔记", async () => {
await fileOps.createNote("test.md", "# Test");
expect(mockApp.vault._files.has("test.md")).toBe(true);
});
it("应该正确写入内容", async () => {
const content = "# Hello\nThis is a test.";
await fileOps.createNote("test.md", content);
const result = await mockApp.vault.read({ path: "test.md" });
expect(result).toBe(content);
});
});
describe("searchNotes", () => {
beforeEach(async () => {
await mockApp.vault.create("note1.md", "Hello World");
await mockApp.vault.create("note2.md", "Hello Obsidian");
await mockApp.vault.create("note3.md", "Goodbye");
});
it("应该搜索包含关键词的笔记", async () => {
const results = await fileOps.searchNotes("Hello");
expect(results).toHaveLength(2);
});
it("应该返回空数组如果无匹配", async () => {
const results = await fileOps.searchNotes("NotFound");
expect(results).toHaveLength(0);
});
});
});编写集成测试
测试命令注册
typescript
// src/commands.test.ts
import { describe, it, expect, beforeEach, vi } from "vitest";
import { createMockApp } from "../__mocks__/mockApp";
import MyPlugin from "./main";
describe("命令注册", () => {
let plugin: MyPlugin;
let mockApp: any;
beforeEach(async () => {
mockApp = createMockApp();
const manifest = { id: "test-plugin", version: "1.0.0" };
plugin = new MyPlugin(mockApp, manifest);
await plugin.onload();
});
it("应该注册所有命令", () => {
const commands = plugin.registeredCommands;
expect(commands).toContain("my-plugin:hello");
expect(commands).toContain("my-plugin:search");
});
it("hello 命令应该显示通知", () => {
const noticeSpy = vi.fn();
vi.spyOn(mockApp, "notice", noticeSpy);
plugin.executeCommand("my-plugin:hello");
expect(noticeSpy).toHaveBeenCalledWith("Hello from My Plugin!");
});
});测试设置面板
typescript
// src/settingsTab.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { createMockApp } from "../__mocks__/mockApp";
import { MySettingTab } from "./settingsTab";
import MyPlugin from "./main";
describe("MySettingTab", () => {
let settingTab: MySettingTab;
let plugin: MyPlugin;
let mockApp: any;
beforeEach(async () => {
mockApp = createMockApp();
const manifest = { id: "test-plugin", version: "1.0.0" };
plugin = new MyPlugin(mockApp, manifest);
settingTab = new MySettingTab(mockApp, plugin);
});
it("应该在 display 中创建设置项", () => {
settingTab.display();
const container = settingTab.containerEl;
expect(container.querySelector(".setting-item")).toBeTruthy();
});
it("应该正确渲染 API Key 输入框", () => {
settingTab.display();
const input = settingTab.containerEl.querySelector('input[type="password"]');
expect(input).toBeTruthy();
});
});测试覆盖率
查看覆盖率
bash
# Vitest
npm run test:coverage
# 输出示例:
# %Stmt %Branch %Func %Lines Uncovered Line
# 85.71 75.00 90.00 85.71 src/utils.ts:12-15覆盖率目标
| 模块 | 语句覆盖 | 分支覆盖 | 函数覆盖 |
|---|---|---|---|
| 工具函数 | ≥ 90% | ≥ 85% | ≥ 90% |
| 核心逻辑 | ≥ 80% | ≥ 70% | ≥ 80% |
| UI 组件 | ≥ 60% | ≥ 50% | ≥ 60% |
覆盖率建议
- 追求高覆盖率但要务实,不要为了覆盖率而写测试
- 重点覆盖核心逻辑和边界情况
- UI 交互测试可以交给端到端测试
端到端测试
使用 Obsidian 测试工具
typescript
// e2e/plugin.e2e.test.ts
import { describe, it, expect } from "vitest";
describe("插件 E2E 测试", () => {
// 注意:E2E 测试需要在真实 Obsidian 环境中运行
// 可以使用 obsidian-vault-parser 或手动测试
it("应该能在 Obsidian 中加载", async () => {
// 通过 Obsidian API 加载插件
// 验证插件状态
});
it("设置页面应该正常工作", async () => {
// 打开设置页面
// 修改设置
// 验证设置已保存
});
});手动测试清单
创建 TESTING.md 作为手动测试清单:
markdown
## 手动测试清单
### 安装
- [ ] 从社区市场安装
- [ ] 手动安装(复制文件到 plugins 目录)
- [ ] 启用插件后无报错
### 基本功能
- [ ] 命令面板中显示所有命令
- [ ] 侧边栏图标正常显示
- [ ] 设置页面正常打开
- [ ] 保存设置后重启保持
### 边界情况
- [ ] 空仓库下正常工作
- [ ] 大量笔记下不卡顿
- [ ] 含特殊字符的文件名
- [ ] 移动端正常工作(如适用)
### 主题兼容
- [ ] 默认亮色主题
- [ ] 默认暗色主题
- [ ] 至少 2 个社区主题CI/CD 集成
GitHub Actions 配置
yaml
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Check coverage
run: npm run test:coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/最佳实践
测试命名
typescript
// ✅ 好的命名
describe("formatDate", () => {
it("应该在月份小于 10 时补零", () => {});
it("应该对无效日期返回 null", () => {});
});
// ❌ 差的命名
describe("formatDate", () => {
it("works", () => {});
it("test1", () => {});
});测试结构 (AAA 模式)
typescript
it("应该正确计算笔记字数", async () => {
// Arrange(准备)
const content = "# Hello\nThis is a test note.";
await mockApp.vault.create("test.md", content);
// Act(执行)
const wordCount = await fileOps.getWordCount("test.md");
// Assert(断言)
expect(wordCount).toBe(7);
});避免测试实现细节
typescript
// ✅ 测试行为
it("应该过滤已完成的任务", () => {
const tasks = getIncompleteTasks(allTasks);
expect(tasks.every(t => !t.completed)).toBe(true);
});
// ❌ 测试实现
it("应该使用 filter 方法", () => {
const spy = vi.spyOn(Array.prototype, "filter");
getIncompleteTasks(allTasks);
expect(spy).toHaveBeenCalled();
});常见问题
如何测试异步代码?
typescript
it("应该等待数据加载完成", async () => {
const data = await plugin.loadData();
expect(data).toBeDefined();
});
it("应该处理超时", async () => {
await expect(
fetchDataWithTimeout("http://slow.api", 1000)
).rejects.toThrow("timeout");
}, 2000); // 设置测试超时如何测试 DOM 操作?
typescript
it("应该创建正确的按钮", () => {
const container = document.createElement("div");
renderButton(container, "Click me");
const button = container.querySelector("button");
expect(button).toBeTruthy();
expect(button?.textContent).toBe("Click me");
});测试中如何使用 vi.fn()?
typescript
it("应该在点击时调用回调", () => {
const onClick = vi.fn();
const button = createButton({ onClick });
button.click();
expect(onClick).toHaveBeenCalledOnce();
expect(onClick).toHaveBeenCalledWith(expect.any(MouseEvent));
});相关资源
- 插件开发入门 — 开发环境搭建
- 插件开发完整教程 — 详细开发教程
- 调试技巧 — 调试方法
- Vitest 官方文档 — 测试框架文档
- Jest 官方文档 — Jest 文档