Files
Project-Caffeine/projects/arabica/docs/test/arabica-sprint3- code-unit-test-specification.md
2026-03-11 10:59:47 +08:00

12 KiB
Raw Blame History

Arabica Sprint 3 代码单元测试样例

1. 测试架构概览

在 Sprint 3 中系统彻底从“提示词Prompt驱动”转向了“工具Tool驱动”。我们的测试重点也应该从原来的 Prompts 转移到核心服务层Services和工具控制层Controllers

当前 Sprint 3 真实需要进行单元测试的核心文件如下:

  1. src/services/arxivService.ts:测试连接 Arxiv API 并解析 XML 的能力。
  2. src/services/resourceService.ts:测试本地笔记的读取、扫描和保存能力。
  3. src/controllers/toolsController.ts:【测试重点】测试五大核心工具(文献、框架、存笔记、查笔记、读笔记)的路由分发和容错机制。

2. 核心测试场景设计与示例

为了让您的 Jest 测试跑通,以下是针对 Sprint 3 真实文件的测试用例编写指南及代码示例。您可以在 src/services/__test__/src/controllers/__test__/ 目录下创建或修改对应的 .test.ts 文件。

场景 A测试文献检索服务 (arxivService.test.ts)

目标:验证 searchArxiv 函数是否能正确发起 HTTP 请求,并从 Arxiv 复杂的 XML 中提取出干净的标题和摘要。

// 文件路径: src/services/__test__/arxivService.test.ts
import { searchArxiv } from '../arxivService';

// Mock 全局的 fetch 函数以防真实发起网络请求
global.fetch = jest.fn();

describe('Arxiv Service', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('应该成功解析 Arxiv 的 XML 并返回文献数组', async () => {
    const mockXml = `
      <feed>
        <entry>
          <id>http://arxiv.org/abs/1234.5678</id>
          <title>Quantum Machine Learning</title>
          <summary>This is a summary of QML.</summary>
        </entry>
      </feed>
    `;
    (global.fetch as jest.Mock).mockResolvedValue({
      ok: true,
      text: jest.fn().mockResolvedValue(mockXml)
    });

    const results = await searchArxiv('Quantum', 1);
    expect(results).toHaveLength(1);
    expect(results[0].title).toBe('Quantum Machine Learning');
    expect(results[0].id).toBe('http://arxiv.org/abs/1234.5678');
  });

  it('当网络请求失败时应该抛出错误', async () => {
    (global.fetch as jest.Mock).mockResolvedValue({
      ok: false,
      status: 500
    });

    await expect(searchArxiv('Error', 1)).rejects.toThrow('Arxiv API 响应错误: 500');
  });
});

场景 B测试工具分发与框架剥离 (toolsController.test.ts)

目标:测试 handleToolCall 是否正确将参数分发给对应函数,特别要测试 fetch_framework_template 是否成功拦截了原始 JSON 并加上了“刹车指令”

// 文件路径: src/controllers/__test__/toolsController.test.ts
import { handleToolCall } from '../toolsController';
import * as fs from 'fs';

// Mock 文件系统,防止测试时去真实读取本地 JSON
jest.mock('fs');

describe('Tools Controller - Sprint 3', () => {

  it('当调用未知工具时,应返回 isError 为 true', async () => {
    const result = await handleToolCall('unknown_tool', {});
    expect(result.isError).toBe(true);
    expect(result.content[0].text).toContain('未知工具');
  });

  it('fetch_framework_template 应该正确解析 JSON 并附带刹车指令', async () => {
    // 伪造一个框架 JSON 文件内容
    const mockFrameworkJson = JSON.stringify({
      messages: [
        { role: 'system', content: { text: 'Mock系统指令' } },
        { role: 'user', content: { text: 'Mock用户模板' } }
      ]
    });
    (fs.readFileSync as jest.Mock).mockReturnValue(mockFrameworkJson);

    const result = await handleToolCall('fetch_framework_template', { framework_name: 'swot' });

    expect(result.isError).toBeUndefined(); // 不应报错
    const outputText = result.content[0].text;

    // 断言是否包含了防止死循环的强指令
    expect(outputText).toContain('立即停止调用任何工具');
    // 断言 JSON 壳子被剥离,提取出了纯文本
    expect(outputText).toContain('Mock系统指令');
    expect(outputText).toContain('Mock用户模板');
  });
});

场景 C测试容错版保存工具 (resourceService.test.ts 相关逻辑)

目标:在 toolsController 层测试 save_note,验证当大模型错误地传入 JSON 对象而不是字符串时,系统能否自动将其序列化以避免崩溃。

import { handleToolCall } from '../../controllers/toolsController'';
import * as resourceService from '../../services/resourceService';
import * as arxivService from '../../services/arxivService';
import * as fs from 'fs';

// ====================================================
// 1. 全局 Mock 外部依赖 (防止测试时发生真实读写和网络请求)
// ====================================================
jest.mock('fs');

jest.mock('../../services/resourceService', () => ({
  saveNote: jest.fn().mockResolvedValue('保存成功'),
  listObsidianNotes: jest.fn().mockResolvedValue(['test-note.md', 'ai-trend.md']),
  readObsidianNote: jest.fn().mockResolvedValue('这是模拟的本地笔记内容')
}));

jest.mock('../../services/arxivService', () => ({
  searchArxiv: jest.fn().mockResolvedValue([
    { id: 'http://arxiv.org/abs/1234', title: 'Test Paper', summary: 'Mock Summary' }
  ])
}));

describe('Tools Controller - Sprint 3 (意图驱动与容错机制)', () => {
  beforeEach(() => {
    // 每次测试前清理 Mock 调用记录
    jest.clearAllMocks();
  });

  // ====================================================
  // 测试: 未知工具防呆
  // ====================================================
  it('当调用未知工具时,应返回 isError 为 true 的友好提示', async () => {
    const result = await handleToolCall('unknown_tool', {});
    expect(result.isError).toBe(true);
    expect(result.content[0].text).toContain('未知工具: unknown_tool');
  });

  // ====================================================
  // 测试: 意图 3 - 保存笔记工具 (重点测试大模型容错能力)
  // ====================================================
  describe('Tool: save_note (容错版保存工具)', () => {
    it('当大模型老老实实传入【纯文本字符串】时,应直接正常保存', async () => {
      const result = await handleToolCall('save_note', { filename: 'output.md', content: '这是一段完美的Markdown文本' });

      expect(result.isError).toBeUndefined();
      expect(result.content[0].text).toBe('保存成功');
      expect(resourceService.saveNote).toHaveBeenCalledWith('output.md', '这是一段完美的Markdown文本');
    });

    it('🚨 容错测试:当大模型错误地传入【深度嵌套的 JSON 对象】时,应自动将其序列化,而不是崩溃', async () => {
      // 模拟大模型发生幻觉,把整个分析对象当成了参数传进来
      const mockJsonObject = {
        title: "行业分析报告",
        data: { trend: "上升", keywords: ["AI", "Quantum"] }
      };

      const result = await handleToolCall('save_note', { filename: 'report.md', content: mockJsonObject });

      expect(result.isError).toBeUndefined();
      expect(result.content[0].text).toBe('保存成功');

      // 期望底层调用保存时Controller 已经非常聪明地把 JSON 对象转换为了带缩进的字符串
      const expectedString = JSON.stringify(mockJsonObject, null, 2);
      expect(resourceService.saveNote).toHaveBeenCalledWith('report.md', expectedString);
    });

    it('当缺少必要参数时,应优雅地返回错误信息', async () => {
      const result = await handleToolCall('save_note', { filename: 'error.md' }); // 故意漏掉 content
      expect(result.isError).toBe(true);
      expect(result.content[0].text).toContain('缺少 filename 或 content 参数');
    });
  });

  // ====================================================
  // 测试: 意图 2 - 获取思维框架模板
  // ====================================================
  describe('Tool: fetch_framework_template (抗死循环剥离器)', () => {
    it('应该正确读取 JSON 文件,剥离外壳,并【强制附加刹车指令】防止大模型死循环', async () => {
      // 伪造一个框架 JSON 文件内容
      const mockFrameworkJson = JSON.stringify({
        messages: [
          { role: 'system', content: { text: '我是系统架构师' } },
          { role: 'user', content: { text: '请按 SWOT 分析化验' } }
        ]
      });
      (fs.readFileSync as jest.Mock).mockReturnValue(mockFrameworkJson);

      const result = await handleToolCall('fetch_framework_template', { framework_name: 'swot' });

      expect(result.isError).toBeUndefined();
      const outputText = result.content[0].text;

      // 断言:必须包含这句护身符,阻止大模型不断重复调工具
      expect(outputText).toContain('立即停止调用任何工具');

      // 断言JSON 外壳已经被剥离,直接透出了内部指导文字
      expect(outputText).toContain('我是系统架构师');
      expect(outputText).toContain('请按 SWOT 分析化验');
    });
  });

  // ====================================================
  // 测试: 意图 1 - 文献检索
  // ====================================================
  describe('Tool: search_arxiv', () => {
    it('应该正确调用服务并格式化返回带 Markdown 语法的文献列表', async () => {
      const result = await handleToolCall('search_arxiv', { query: 'AI' });

      expect(result.isError).toBeUndefined();
      expect(arxivService.searchArxiv).toHaveBeenCalledWith('AI', 5);

      const outputText = result.content[0].text;
      expect(outputText).toContain('找到了关于 "AI" 的相关文献');
      expect(outputText).toContain('Test Paper');
      expect(outputText).toContain('http://arxiv.org/abs/1234');
    });
  });

  // ====================================================
  // 测试: 意图 4 - 本地笔记相关
  // ====================================================
  describe('Local Notes Tools', () => {
    it('list_local_notes 应该正确返回笔记列表', async () => {
      const result = await handleToolCall('list_local_notes', {});
      expect(result.content[0].text).toContain('test-note.md');
      expect(result.content[0].text).toContain('ai-trend.md');
    });

    it('read_local_note 应该正确读取具体笔记内容', async () => {
      const result = await handleToolCall('read_local_note', { filename: 'test-note.md' });
      expect(resourceService.readObsidianNote).toHaveBeenCalledWith('test-note.md');
      expect(result.content[0].text).toContain('这是模拟的本地笔记内容');
    });
  });
});

3. 测试执行说明

在您配置好上述的测试文件后,请在终端使用项目中 package.json 原有配置的命令执行测试:

  1. 执行基础测试

    npm run test
    
  2. 执行并查看覆盖率Coverage

    npm run test:coverage
    

    执行完成后,请检查终端输出的表格,重点关注 arxivService.tstoolsController.tsStmts (语句覆盖率)Branch (分支覆盖率) 是否达到预期(推荐 > 80%)。

  3. 运行单个测试文件

如果您只想跑 toolsController.test.ts 这一个文件,可以使用以下命令:

# 方式 A (推荐,使用 npx 直接调用 jest)
npx jest src/controllers/__test__/toolsController.test.ts

# 方式 B (使用 npm 脚本透传参数,注意中间的 `--`)
npm run test -- src/controllers/__test__/toolsController.test.ts
  1. 监听模式

在写测试或改 Bug 时,我们经常需要修改完代码后自动重新跑那个报错的测试文件。您可以加上 --watch 参数:

npx jest src/controllers/__test__/toolsController.test.ts --watch

这样您每次保存代码Jest 就会自动为您重新运行这个文件的测试,极大提升开发效率!


许可声明

本文档采用 知识共享署名--相同方式共享 4.0 国际许可协议 (CC BY--SA 4.0) 进行许可,© 2025-2026 Gitconomy Research。