跳转到主内容

SeatFlow 测试架构(4 个 xUnit v3 项目)、Headless UI 视觉回归、FluentAssertions/NSubstitute 使用规范和测试编写指南

测试指南

测试技术栈

组件 版本 用途
xUnit v3 4.0.1(3 个核心层项目);3.2.2(Presentation.Tests 锁定) 测试框架
FluentAssertions 8.11.0 可读性断言
NSubstitute 6.2.0 Mock/Stub 对象
Microsoft.Testing.Platform — 测试运行器(global.json 指定 MTP)
Avalonia.Headless.XUnit 12.1.3 Headless UI 测试(Presentation.Tests)
Avalonia.Skia 12.1.3 真实绘制与视觉基线(Presentation.Tests)

版本说明

xUnit v3 通过根 global.json 的 "test": { "runner": "Microsoft.Testing.Platform" } 指定 Microsoft.Testing.Platform 作为测试运行器。运行时输出和 CLI 行为与 v2 有所不同。

Headless UI 测试项目有两条必须遵守的版本锁定:

  • xunit.v3 3.2.2:Avalonia.Headless.XUnit 12.1.3 按 xunit.v3.extensibility.core 3.2.2 编译,若使用 4.x 会抛 MissingMethodException。其余 3 个测试项目当前使用 4.0.1,各 .csproj 独立管理版本(仓库没有 Directory.Packages.props)。
  • SkiaSharp.NativeAssets.Linux(4.153.1):显式引用以与托管 SkiaSharp 对齐;依赖链默认解析到旧版 Linux 原生资产时,SkiaSharpVersion.CheckNativeLibraryCompatible 会在 Linux 上崩溃。

测试项目结构

4 个测试项目位于 tests/ 目录,其中 3 个对应核心层,另 1 个为 Headless UI 测试:

SeatFlow.slnx
└── tests/
    ├── SeatFlow.Core.Tests/            195 用例 → 领域实体、策略逻辑、领域服务
    ├── SeatFlow.Application.Tests/      61 用例 → 外观模式、管道执行、命令历史
    ├── SeatFlow.Infrastructure.Tests/  125 用例 → 数据提供者、导出器、迁移、仓库
    └── SeatFlow.Presentation.Tests/     67 用例 → Headless UI、视觉基线、生命周期契约

全量 dotnet test 共发现 448 个测试(0 失败为通过标准)。

项目配置

每个测试项目启用了 <ImplicitUsings>enable</ImplicitUsings>,自动引入:

<ImplicitUsings>enable</ImplicitUsings>

自动引入的命名空间:

  • System
  • System.Collections.Generic
  • System.Linq
  • System.Threading.Tasks

项目特定的全局 using 在 Usings.cs(Application.Tests 中为 Using.cs,Presentation.Tests 中为 GlobalUsings.cs)中定义。

// Core.Tests/Usings.cs 示例
global using Xunit;
global using FluentAssertions;
global using NSubstitute;
global using SeatFlow.Core;  // 项目特定引用

SeatFlow.Presentation.Tests 通过 TestAppBuilder.cs 装配 Headless 应用:

[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]

public static class TestAppBuilder
{
    public static AppBuilder BuildAvaloniaApp() =>
        AppBuilder
            .Configure(() => new SeatFlow.Presentation.Avalonia.App(provider))
            .UseSkia()
            // false = 关闭假绘制,启用 Skia 真实绘制(视觉基线用)
            .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false })
            .LogToTrace();
}

测试复用生产 App 实例(完整加载 App.axaml 的主题/令牌/样式资源),避免测试与生产资源漂移;只注入最小 DI(语言设置读取的 IApplicationFacade 替身等)。

运行测试

运行全部测试

dotnet test

运行单个测试

dotnet test --filter "FullyQualifiedName~TestName"

按类别运行

# 按测试类名过滤
dotnet test --filter "FullyQualifiedName~Migration"

# 只运行单个测试项目
dotnet test tests/SeatFlow.Core.Tests

编写测试

测试类结构

public class FixedSeatStrategyTests
{
    [Fact]
    public void ExecuteAsync_WithValidFixedSeats_ShouldAssignStudents()
    {
        // Arrange
        var workspace = new SeatingWorkspace(/* ... */);
        var strategy = new FixedSeatStrategy();

        // Act
        await strategy.ExecuteAsync(workspace, CancellationToken.None);

        // Assert
        workspace.GetAssignedSeats()
            .Should().NotBeEmpty()
            .And.HaveCount(expectedCount);
    }
}

测试命名约定

{ClassUnderTest}_{Scenario}_{ExpectedBehavior}
部分 说明 示例
ClassUnderTest 被测试的类/方法 ExecuteAsync
Scenario 测试场景描述 WithValidFixedSeats
ExpectedBehavior 期望行为 ShouldAssignStudents

三段式结构

每个测试方法遵循 Arrange-Act-Assert 模式,用空行分隔:

[Fact]
public void Method_Scenario_ExpectedResult()
{
    // Arrange
    var sut = new SystemUnderTest();

    // Act
    var result = sut.DoSomething();

    // Assert
    result.Should().Be(expected);
}

测试特性

特性 用途
[Fact] 普通测试方法
[Theory] 参数化测试
[InlineData] [Theory] 的内联参数
[AvaloniaFact] Headless UI 测试(Presentation.Tests)
[Theory]
[InlineData(1, 2, 3)]
[InlineData(0, 0, 0)]
public void Add_WithVariousInputs_ShouldReturnCorrectSum(int a, int b, int expected)
{
    var result = a + b;
    result.Should().Be(expected);
}

使用 NSubstitute

[Fact]
public async Task GenerateSeating_WhenCalled_ShouldExecuteStrategies()
{
    // Arrange
    var mockStrategy = Substitute.For<ISeatingStrategy>();
    mockStrategy.Id.Returns("Test");
    mockStrategy.Priority.Returns(50);

    var facade = new ApplicationFacade(/* ... */);

    // Act
    await facade.GenerateSeatingAsync(/* ... */);

    // Assert
    await mockStrategy.Received(1).ExecuteAsync(Arg.Any<SeatingWorkspace>(), Arg.Any<CancellationToken>());
}

使用 FluentAssertions

// 基本断言
result.Should().Be(42);
result.Should().NotBeNull();

// 集合断言
list.Should().HaveCount(5).And.ContainSingle(x => x.Name == "Alice");

// 异常断言
action.Should().Throw<InvalidOperationException>()
    .WithMessage("*already assigned*");

// 布尔断言
isValid.Should().BeTrue();

// 字符串断言
name.Should().StartWith("A").And.EndWith("z");

Headless UI 测试(SeatFlow.Presentation.Tests)

运行在无头环境(不需要显示器),但使用 Avalonia.Skia 真实绘制,覆盖 VM/服务单测与像素级回归:

测试类 覆盖内容
SeatingCanvasTests 画布几何/命中/缩放/键盘虚拟焦点
PageLifecycleTests IPageLifecycle(OnEnterAsync/OnLeaveAsync/InitializationTask)契约
CrossCuttingServicesTests DirtyTracker、IDialogGate、IBusyScope 等横切服务
GuideSeedTargetTests IGuideSeedTarget seed/clear 幂等性
SettingsViewModelTests / StudentRowViewModelTests 页面 VM 行为
AccentButtonForegroundTests / SelectListColorTests 颜色像素回归(按钮悬停前景、列表选中底色)
VisualBaselineTests 明/暗双主题 × 7 个关键视图的渲染基线
SmokeTests Headless 应用可创建窗口并渲染、令牌资源可解析

视觉基线

VisualBaselineTests 以 window.CaptureRenderedFrame() 渲染 7 个关键视图(工作台空态/生成态、排座画布、人员名单、设置、关于、座位长名换行)在明/暗双主题下的基线,输出 PNG 到 scripts/ui-inspect/baselines/(基线 PNG 不入库,目录已 gitignore;可用 SEATFLOW_BASELINE_DIR 环境变量覆盖输出目录)。测试本身只断言可渲染性(帧非空、文件可写),人工像素对比流程见 docs/UI_REFACTOR.md 的「验证与工具链」。

测试项目结构约定

tests/SeatFlow.Core.Tests/
├── Usings.cs                    # 全局 using
├── Strategies/
│   ├── FixedSeatStrategyTests.cs
│   ├── RandomFillStrategyTests.cs
│   └── DeskMateStrategyTests.cs
├── DomainServices/
│   ├── SeatGeometryHelperTests.cs
│   └── ObstacleProcessorTests.cs
└── Workspace/
    └── SeatingWorkspaceTests.cs

tests/SeatFlow.Application.Tests/
├── Using.cs                     # 全局 using
├── Facade/
│   └── ApplicationFacadeTests.cs
└── Pipeline/
    ├── StrategyExecutionPipelineTests.cs
    └── CommandHistoryTests.cs

tests/SeatFlow.Infrastructure.Tests/
├── Usings.cs                    # 全局 using
├── Migration/
│   ├── VenueMigratorsTests.cs
│   ├── SeatSetsMigratorsTests.cs
│   └── FileMigrationServiceTests.cs
├── Providers/
│   ├── CsvStudentProviderTests.cs
│   └── XlsxStudentProviderTests.cs
├── Exporters/
│   ├── ExcelSeatingExporterTests.cs
│   └── CsvSeatingExporterTests.cs
└── Repositories/
    └── JsonVenueRepositoryTests.cs

tests/SeatFlow.Presentation.Tests/
├── GlobalUsings.cs              # 全局 using
├── TestAppBuilder.cs            # AvaloniaTestApplication + Skia 真实绘制
├── VisualBaselineTests.cs       # 明暗基线捕获
└── ...(画布、生命周期、横切服务、页面 VM)

测试约定

不依赖外部资源

测试应完全在内存中执行,不依赖文件系统、数据库或网络。对于基础设施层的文件 I/O 测试,使用临时目录:

[Fact]
public async Task LoadVenues_WithValidFile_ShouldReturnVenues()
{
    // Arrange
    var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
    Directory.CreateDirectory(tempDir);
    try
    {
        File.WriteAllText(Path.Combine(tempDir, "test.venue.json"), validJson);
        var repo = new JsonVenueRepository(tempDir);

        // Act
        var venues = await repo.LoadVenuesAsync();

        // Assert
        venues.Should().NotBeEmpty();
    }
    finally
    {
        Directory.Delete(tempDir, recursive: true);
    }
}

静态方法测试

工具类和领域服务中的静态方法直接测试:

[Fact]
public void Resolve_WithZhCN_ShouldReturnChineseValue()
{
    var dict = new Dictionary<string, string>
    {
        ["zh-CN"] = "中文",
        ["en-US"] = "English"
    };

    var result = LocalizeHelper.Resolve(dict);

    result.Should().Be("中文");
}

脚本测试

Python 脚本的测试在 scripts/tests/ 目录中:

cd scripts

# 运行所有脚本测试
python3 -m pytest tests/ -v

# 仅运行 i18n 脚本测试
python3 -m pytest tests/test_i18n.py -v

# 仅运行版本管理脚本测试
python3 -m pytest tests/test_version.py -v

# 仅运行发布编排脚本测试
python3 -m pytest tests/test_release.py -v

# 仅运行打包历史同步脚本测试
python3 -m pytest tests/test_sync_velopack_history.py -v
测试文件 测试数量 说明
tests/test_i18n.py 45 i18n 资源管理脚本测试
tests/test_version.py 25 版本管理脚本测试
tests/test_release.py 21 发布编排脚本测试(SHA256/尺寸格式化、配置与版本读取、发布说明生成、vpk 打包编排 dry-run 等)
tests/test_sync_velopack_history.py 20 Velopack 打包历史同步脚本测试(保留集、双向对账、MD5/ETag、dry-run、main 退出码)
tests/test_stage_velopack_artifacts.py 4 发布产物暂存脚本测试(复制/排除集、稳定版白名单清理、channel 文件缺失失败)

脚本测试内容

  • test_i18n.py:覆盖所有子命令(list, check, add, modify, rename, delete, sync, export, import)、安全机制(备份、dry-run、原子性)、XML 合法性验证、文件同步一致性
  • test_version.py:覆盖版本号读取校验、一致性检查、自动同步、各种 bump 操作、安全机制
  • test_release.py:覆盖发布编排的纯函数与流程分支(SHA256、文件分发过滤、版本化命名、RELEASE.md 组装、vpk 调用与 dry-run)
  • test_sync_velopack_history.py:覆盖保留集计算(全部 delta + 最新 2 个 full)、双向对账(下载/删除/字节统计)、size 与 MD5/ETag 校验、dry-run 零副作用、main() 退出码
  • test_stage_velopack_artifacts.py:覆盖产物复制与排除(assets.*.json、旧 nupkg、Portable.zip)、稳定版白名单清理、channel 文件缺失失败

测试驱动开发

推荐在以下场景使用 TDD:

  • 策略实现:先定义期望的分配行为,再编写策略逻辑
  • 迁移器:先编写旧格式的 JSON 输入和期望的新格式输出
  • 数据提供者:先定义数据解析结果,再实现解析逻辑

持续集成

每次提交前确保:

dotnet build                    # 编译通过(含双 TFM)
dotnet test                     # 全部 448 个用例通过
python3 -m pytest scripts/tests/ -v  # 脚本测试通过

也可使用根 global.json 定义的本地任务(首次先 dotnet tool restore):

dotnet r build
dotnet r test
dotnet r ci          # build + test

CI 现状:unit-tests.yml 目前只条件运行 Core / Infrastructure / Application 三个项目(Presentation.Tests 暂未纳入 CI),因此改动 UI 层后请在本地跑全量 dotnet test。涉及浏览器端(net10.0-browser)的改动,CI 会安装 WASM 工作负载并验证浏览器壳可正常构建。

相关文档

  • 主仓库 AGENTS.md(构建与测试章节)
  • 主仓库 CONTRIBUTING.md(环境搭建)
  • CI/CD 工作流