Skip to content

插件测试

测试是保证插件质量的关键环节。本文介绍如何为 Obsidian 插件编写和运行测试,包括单元测试、集成测试和端到端测试。

为什么需要测试?

没有测试有测试
修改代码心惊胆战重构时信心十足
手动反复验证自动验证所有场景
回归问题频繁变更立即发现问题
难以处理边界情况边界情况有覆盖
发布后才发现 Bug发布前拦截问题

测试框架选择

框架特点推荐场景
Vitest快速、ESM 原生、Vite 兼容新项目首选
Jest生态成熟、社区庞大需要丰富插件
Node Test RunnerNode.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));
});

相关资源