free-code-dotnet/docs/测试与构建/测试与构建-测试方案设计.md
应文浩wenhao.ying@xiaobao100.com e25ac591a7 init easy-code
2026-04-06 07:24:24 +08:00

12 KiB
Raw Permalink Blame History

测试方案设计

所属项目: free-code .NET 10 重写 文档类型: 测试设计 来源章节: DESIGN-NET10-PART3.md § 22 上级文档: 测试与构建总览


概述

原始 TypeScript 项目没有系统化的测试覆盖。.NET 重写从零建立分层测试体系,目标是核心路径 80%+ 覆盖率。测试框架组合为 xUnit 2 + NSubstitute 5 + FluentAssertions 7,三个独立测试项目分别承载单元、集成和 E2E 三个层次。


22.1 测试项目结构

tests/
├── FreeCode.Tests.Unit/           # 120+ 单元测试
│   ├── Engine/
│   │   ├── QueryEngineTests.cs
│   │   └── SystemPromptBuilderTests.cs
│   ├── Tools/
│   │   ├── BashToolTests.cs
│   │   ├── FileEditToolTests.cs
│   │   ├── FileReadToolTests.cs
│   │   ├── FileWriteToolTests.cs
│   │   ├── GlobToolTests.cs
│   │   ├── GrepToolTests.cs
│   │   ├── AgentToolTests.cs
│   │   └── ToolRegistryTests.cs
│   ├── Commands/
│   │   └── CommandRegistryTests.cs
│   ├── ApiProviders/
│   │   ├── AnthropicProviderTests.cs
│   │   ├── CodexProviderTests.cs
│   │   └── ProviderRouterTests.cs
│   ├── Mcp/
│   │   ├── McpClientTests.cs
│   │   ├── McpClientManagerTests.cs
│   │   └── TransportTests.cs
│   ├── Lsp/
│   │   └── LspClientManagerTests.cs
│   ├── Services/
│   │   ├── AuthServiceTests.cs
│   │   ├── RateLimitServiceTests.cs
│   │   ├── SessionMemoryServiceTests.cs
│   │   ├── CompanionServiceTests.cs
│   │   └── NotificationServiceTests.cs
│   └── State/
│       └── AppStateStoreTests.cs
├── FreeCode.Tests.Integration/    # 60 集成测试
│   ├── QueryPipelineTests.cs
│   ├── McpIntegrationTests.cs
│   ├── LspIntegrationTests.cs
│   ├── BridgeIntegrationTests.cs
│   ├── TaskManagerTests.cs
│   └── PluginLoadingTests.cs
└── FreeCode.Tests.E2E/            # 20 端到端测试
    ├── CliTests.cs
    ├── OneShotModeTests.cs
    └── InteractiveReplTests.cs

22.2 单元测试示例

BashToolTests.cs

测试覆盖: 命令执行、超时处理、后台任务、只读分类。 原始来源: ../../src/tools/BashTool.tsx

// === BashToolTests.cs ===
public class BashToolTests
{
    private readonly BashTool _tool = new(
        Substitute.For<IBackgroundTaskManager>(),
        Substitute.For<IFeatureFlagService>());

    [Fact]
    public async Task ExecuteAsync_SimpleCommand_ReturnsOutput()
    {
        // Arrange
        var input = new BashToolInput { Command = "echo hello" };
        var context = new ToolExecutionContext(
            WorkingDirectory: Path.GetTempPath(),
            PermissionMode: PermissionMode.Default,
            AdditionalWorkingDirectories: [],
            PermissionEngine: Substitute.For<IPermissionEngine>(),
            LspManager: Substitute.For<ILspClientManager>(),
            TaskManager: Substitute.For<IBackgroundTaskManager>(),
            Services: Substitute.For<IServiceProvider>());

        // Act
        var result = await _tool.ExecuteAsync(input, context);

        // Assert
        result.IsError.Should().BeFalse();
        result.Data.Stdout.Trim().Should().Be("hello");
        result.Data.ExitCode.Should().Be(0);
    }

    [Fact]
    public async Task ExecuteAsync_Timeout_KillsProcess()
    {
        var input = new BashToolInput { Command = "sleep 10", Timeout = 500 };
        var context = CreateContext();

        var result = await _tool.ExecuteAsync(input, context);

        result.Data.Interrupted.Should().BeTrue();
        result.Data.ExitCode.Should().Be(-1);
    }

    [Fact]
    public async Task ExecuteAsync_Background_ReturnsTaskId()
    {
        var taskManager = Substitute.For<IBackgroundTaskManager>();
        taskManager.CreateShellTaskAsync(Arg.Any<string>(), Arg.Any<ProcessStartInfo>())
            .Returns(new LocalShellTask { TaskId = "bg-123", Command = "sleep 10" });

        var tool = new BashTool(taskManager, Substitute.For<IFeatureFlagService>());
        var input = new BashToolInput { Command = "sleep 10", RunInBackground = true };
        var result = await tool.ExecuteAsync(input, CreateContext());

        result.Data.BackgroundTaskId.Should().Be("bg-123");
    }

    [Theory]
    [InlineData("ls", true)]
    [InlineData("rm -rf /", false)]
    [InlineData("cat file.txt", true)]
    [InlineData("echo test > file", false)]
    public void IsReadOnly_ClassifiesCorrectly(string command, bool expected)
    {
        var input = new BashToolInput { Command = command };
        _tool.IsReadOnly(input).Should().Be(expected);
    }
}

ToolRegistryTests.cs

测试覆盖: 基础工具注册、MCP 工具去重策略。 原始来源: ../../src/tools.ts

// === ToolRegistryTests.cs ===
public class ToolRegistryTests
{
    [Fact]
    public async Task GetToolsAsync_ReturnsBaseTools()
    {
        var registry = CreateRegistry();
        var tools = await registry.GetToolsAsync();

        tools.Should().Contain(t => t.Name == "Read");
        tools.Should().Contain(t => t.Name == "Edit");
        tools.Should().Contain(t => t.Name == "Bash");
        tools.Should().Contain(t => t.Name == "Agent");
    }

    [Fact]
    public async Task AssembleToolPool_DeduplicatesMcpTools()
    {
        // 内置 "Bash" 工具 + MCP 也提供 "Bash" → 只保留内置
        var registry = CreateRegistry();
        var mcpManager = Substitute.For<IMcpClientManager>();
        mcpManager.GetToolsAsync().Returns(new List<ITool>
        {
            CreateTool("Bash"),        // 与内置重名,应被丢弃
            CreateTool("SlackSearch"), // 新工具,应被添加
        });

        // 验证: Bash 只有一个 (内置), SlackSearch 被添加
    }
}

AnthropicProviderTests.cs

测试覆盖: SSE 流式事件解析。 原始来源: ../../src/services/api/claude.ts

// === AnthropicProviderTests.cs ===
public class AnthropicProviderTests
{
    [Fact]
    public async Task StreamAsync_ParsesSSECorrectly()
    {
        var sseResponse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\n"
            + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"text\":\"Hello\"}}\n\n"
            + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";

        var handler = new MockHttpHandler(sseResponse);
        var provider = new AnthropicProvider(new HttpClient(handler));

        var messages = new List<SDKMessage>();
        await foreach (var msg in provider.StreamAsync(new ApiRequest()))
            messages.Add(msg);

        messages.Should().ContainSingle(m => m is SDKMessage.StreamingDelta sd && sd.Text == "Hello");
    }
}

CompanionServiceTests.cs

测试覆盖: 骰子结果确定性、不同用户产生不同结果。 原始来源: 无直接对应(新增 Companion 功能)

// === CompanionServiceTests.cs ===
public class CompanionServiceTests
{
    [Fact]
    public void RollBones_IsDeterministic()
    {
        // 相同 userId → 相同骨骼
        var service = new CompanionService(Substitute.For<IGlobalConfig>());
        var c1 = service.RollBones("user-123");
        var c2 = service.RollBones("user-123");
        c1.Should().BeEquivalentTo(c2);
    }

    [Fact]
    public void RollBones_DifferentUsers_DifferentResults()
    {
        var service = new CompanionService(Substitute.For<IGlobalConfig>());
        var c1 = service.RollBones("user-A");
        var c2 = service.RollBones("user-B");
        // 极大概率不同 (18 种族, 6 眼形, 5 稀有度)
        (c1.Species != c2.Species || c1.Eye != c2.Eye).Should().BeTrue();
    }
}

22.3 集成测试示例

McpIntegrationTests.cs

测试覆盖: stdio MCP 服务器连接建立。 原始来源: ../../src/services/mcp/

// === McpIntegrationTests.cs ===
public class McpIntegrationTests : IAsyncLifetime
{
    private McpClientManager _manager = null!;
    private string _tempDir = null!;

    public async Task InitializeAsync()
    {
        _tempDir = Path.Combine(Path.GetTempPath(), $"mcp-test-{Guid.NewGuid():N}");
        Directory.CreateDirectory(_tempDir);

        // 创建一个测试用 MCP stdio 服务器脚本
        var serverScript = Path.Combine(_tempDir, "test-server.sh");
        await File.WriteAllTextAsync(serverScript, @"#!/bin/bash
echo '{""jsonrpc"":""2.0"",""id"":1,""result"":{""protocolVersion"":""2025-03-26"",""capabilities"":{""tools"":{}}}}'
");
        Process.Start("chmod", $"+x {serverScript}")?.WaitForExit();
    }

    [Fact]
    public async Task ConnectServersAsync_ConnectsToStdioServer()
    {
        // 配置一个 stdio MCP 服务器
        var config = new StdioServerConfig { Command = "echo", Scope = ConfigScope.Local };
        // ... 验证连接成功
    }
}

22.4 E2E 测试示例

CliTests.cs

测试覆盖: 版本标志输出、一次性模式响应。 原始来源: ../../src/entrypoints/cli.tsx

// === CliTests.cs ===
public class CliTests
{
    [Fact]
    public async Task VersionFlag_PrintsVersion()
    {
        var result = await RunCliAsync("--version");
        result.ExitCode.Should().Be(0);
        result.Output.Should().StartWith("free-code ");
    }

    [Fact]
    public async Task OneShotMode_ReturnsResponse()
    {
        // 需要 mock API server
        var result = await RunCliAsync("-p", "what is 2+2?");
        result.ExitCode.Should().Be(0);
    }

    private static async Task<(int ExitCode, string Output)> RunCliAsync(params string[] args)
    {
        using var process = new Process
        {
            StartInfo = new ProcessStartInfo
            {
                FileName = "dotnet",
                Arguments = $"run --project src/FreeCode {string.Join(" ", args)}",
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                CreateNoWindow = true,
            }
        };
        process.Start();
        var output = await process.StandardOutput.ReadToEndAsync();
        await process.WaitForExitAsync();
        return (process.ExitCode, output);
    }
}

22.5 测试金字塔

层次 项目 数量 执行时间目标
单元测试 FreeCode.Tests.Unit 120+ < 30 秒
集成测试 FreeCode.Tests.Integration 60 < 3 分钟
E2E 测试 FreeCode.Tests.E2E 20 < 10 分钟
合计 200

单元测试覆盖所有工具、引擎、命令、API 提供商、MCP/LSP 客户端、服务层和状态管理。集成测试验证跨组件交互查询管道、MCP 连接、LSP 通信、Bridge 握手、任务管理器、插件加载。E2E 测试启动完整进程,验证 CLI 行为对外部用户可见的部分。


22.6 测试框架版本

版本 用途
xunit 2.x 测试运行器和断言基础
NSubstitute 5.x 接口 mock / stub / spy
FluentAssertions 7.x 可读的断言链
Microsoft.NET.Test.Sdk 最新 dotnet test 集成
coverlet.collector 最新 代码覆盖率收集

参考资料