.NET 单元测试(七):测试工程化

写在前面

本文是 .NET 单元测试系列的最后一篇,介绍测试的工程化实践:命名规范、项目组织、代码覆盖率、TDD 工作流和 CI/CD 集成。前置知识:集成测试(第六篇)。


一、命名规范

1.1 测试方法命名

1
2
3
4
5
6
7
8
推荐格式:MethodName_Scenario_Expected

示例:
Add_TwoPositiveNumbers_ReturnsSum
Divide_ByZero_ThrowsDivideByZeroException
CreateOrder_EmptyItems_ThrowsArgumentException
GetUser_NonExistingId_ReturnsNull
ProcessPayment_InsufficientFunds_ReturnsFailed

1.2 测试类命名

1
2
3
4
被测类 + Tests
UserService → UserServiceTests
OrderService → OrderServiceTests
Calculator → CalculatorTests

1.3 测试项目命名

1
2
3
MyApp.Services        → MyApp.Services.Tests          (单元测试)
MyApp.Services        → MyApp.Services.IntegrationTests(集成测试)
MyApp.Web             → MyApp.Web.Tests

1.4 文件组织

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
tests/
├── MyApp.Services.Tests/
│   ├── Services/
│   │   ├── UserServiceTests.cs
│   │   ├── OrderServiceTests.cs
│   │   └── PaymentServiceTests.cs
│   ├── Validators/
│   │   ├── UserValidatorTests.cs
│   │   └── OrderValidatorTests.cs
│   └── Helpers/
│       └── TestDataBuilder.cs
├── MyApp.Services.IntegrationTests/
│   ├── Repository/
│   │   └── UserRepositoryTests.cs
│   └── Infrastructure/
│       └── DatabaseMigrationTests.cs
└── MyApp.Web.Tests/
    ├── Controllers/
    │   └── UserControllerTests.cs
    └── Middleware/
        └── ExceptionHandlingMiddlewareTests.cs

二、测试分类

2.1 按速度分类

1
2
3
4
5
单元测试(Unit)        — 毫秒级,不依赖外部
集成测试(Integration) — 秒级,依赖数据库/文件
端到端测试(E2E)       — 十秒级,完整用户流程

按比例:70% 单元 / 20% 集成 / 10% E2E

2.2 用 Trait 标记分类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
[Trait("Category", "Unit")]
public class CalculatorTests
{
    [Fact, Trait("Category", "Unit")]
    public void Add_ReturnsSum() { }
}

[Trait("Category", "Integration")]
public class UserRepositoryTests
{
    [Fact, Trait("Category", "Integration")]
    public void Save_WritesToDatabase() { }
}

[Trait("Category", "Slow")]
public class PerformanceTests
{
    [Fact, Trait("Category", "Slow")]
    public void LargeDataset_ProcessesWithinTimeout() { }
}

2.3 按分类运行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 只跑单元测试
dotnet test --filter "Category=Unit"

# 只跑集成测试
dotnet test --filter "Category=Integration"

# 排除慢测试
dotnet test --filter "Category!=Slow"

# 组合条件
dotnet test --filter "Category=Unit&FullyQualifiedName~UserService"

2.4 分项目组织

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
src/
├── MyApp.Core/
├── MyApp.Services/
└── MyApp.Web/

tests/
├── MyApp.Core.Tests/                 # 单元测试
├── MyApp.Services.Tests/             # 单元测试
├── MyApp.Services.IntegrationTests/  # 集成测试
└── MyApp.Web.IntegrationTests/       # API 集成测试
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!-- 目录结构约定:让单元测试和集成测试分开 -->
<!-- Unit Tests csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
    <PackageReference Include="xunit" Version="2.*" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
    <PackageReference Include="Moq" Version="4.*" />
  </ItemGroup>
</Project>

<!-- Integration Tests csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
    <PackageReference Include="xunit" Version="2.*" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.*" />
  </ItemGroup>
</Project>

三、测试数据构建

3.1 Builder 模式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class UserBuilder
{
    private int _id = 1;
    private string _name = "测试用户";
    private string _email = "test@example.com";
    private int _age = 25;
    private MemberLevel _level = MemberLevel.Regular;

    public UserBuilder WithId(int id) { _id = id; return this; }
    public UserBuilder WithName(string name) { _name = name; return this; }
    public UserBuilder WithEmail(string email) { _email = email; return this; }
    public UserBuilder WithAge(int age) { _age = age; return this; }
    public UserBuilder AsVIP() { _level = MemberLevel.VIP; return this; }

    public User Build() => new()
    {
        Id = _id,
        Name = _name,
        Email = _email,
        Age = _age,
        Level = _level
    };

    // 静态工厂方法
    public static UserBuilder Typical() => new();
    public static UserBuilder VIP() => new UserBuilder().AsVIP();
}

// 使用
[Fact]
public void Test()
{
    var user = UserBuilder.Typical()
        .WithName("张三")
        .WithAge(30)
        .Build();

    var vip = UserBuilder.VIP()
        .WithEmail("vip@test.com")
        .Build();
}

3.2 Order Builder

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public class OrderBuilder
{
    private readonly List<OrderItem> _items = new();
    private string _customerEmail = "customer@test.com";
    private PaymentMethod _payment = PaymentMethod.CreditCard;

    public OrderBuilder WithItem(decimal price, int quantity = 1)
    {
        _items.Add(new OrderItem { Price = price, Quantity = quantity });
        return this;
    }

    public OrderBuilder WithCustomerEmail(string email)
    {
        _customerEmail = email;
        return this;
    }

    public OrderBuilder WithPayment(PaymentMethod payment)
    {
        _payment = payment;
        return this;
    }

    public OrderBuilder Empty()
    {
        _items.Clear();
        return this;
    }

    public Order Build() => new()
    {
        Id = Guid.NewGuid(),
        CustomerEmail = _customerEmail,
        PaymentMethod = _payment,
        Items = _items.ToList()
    };
}

// 使用
[Fact]
public void CalculateTotal_MultipleItems_SumsCorrectly()
{
    var order = new OrderBuilder()
        .WithItem(100, 2)   // 200
        .WithItem(50, 1)    // 50
        .Build();

    var total = service.CalculateTotal(order);

    Assert.Equal(250m, total);
}

[Fact]
public void CreateOrder_EmptyOrder_ThrowsException()
{
    var order = new OrderBuilder().Empty().Build();

    Assert.Throws<ArgumentException>(() => service.CreateOrder(order));
}

3.3 Object Mother 模式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 预定义的常用测试数据
public static class TestData
{
    public static User TypicalUser => new()
    {
        Id = 1, Name = "张三", Email = "zhang@test.com", Age = 25
    };

    public static User VIPUser => new()
    {
        Id = 2, Name = "李四", Email = "li@test.com", Age = 30, Level = MemberLevel.VIP
    };

    public static Order TypicalOrder => new OrderBuilder()
        .WithItem(100, 2)
        .Build();

    public static Order EmptyOrder => new OrderBuilder().Empty().Build();
}

// 使用
[Fact]
public void Test() => service.Process(TestData.TypicalUser);

四、代码覆盖率

4.1 收集覆盖率

1
2
3
4
5
6
7
8
# 安装工具
dotnet tool install --global dotnet-coverage

# 收集覆盖率
dotnet-coverage collect "dotnet test" --output-format cobertura --output coverage.xml

# 或者用 coverlet.msbuild
dotnet test --collect:"XPlat Code Coverage"

4.2 添加 coverlet 包

1
2
3
4
5
6
<ItemGroup>
  <PackageReference Include="coverlet.collector" Version="6.*">
    <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
    <PrivateAssets>all</PrivateAssets>
  </PackageReference>
</ItemGroup>
1
2
# 收集并生成报告
dotnet test --collect:"XPlat Code Coverage" --results-directory ./coverage

4.3 生成可视化报告

1
2
3
4
5
6
7
8
# 安装 ReportGenerator
dotnet tool install --global dotnet-reportgenerator-globaltool

# 生成 HTML 报告
reportgenerator \
  -reports:coverage/**/coverage.cobertura.xml \
  -targetdir:coverage/report \
  -reporttypes:Html

4.4 覆盖率配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<!-- 在 csproj 或 runsettings 中配置 -->
<!-- runsettings.xml -->
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
  <DataCollectionRunSettings>
    <DataCollectors>
      <DataCollector friendlyName="XPlat code coverage">
        <Configuration>
          <Format>cobertura</Format>
          <Include>[MyApp.*]*</Include>              <!-- 只统计自己的代码 -->
          <Exclude>[MyApp.*Tests]*</Exclude>         <!-- 排除测试代码 -->
          <ExcludeByAttribute>ObsoleteAttribute</Exclude>
          <ExcludeByFile>**/Migrations/**</Exclude>  <!-- 排除迁移文件 -->
          <Threshold>80</Threshold>                  <!-- 覆盖率低于 80% 失败 -->
        </Configuration>
      </DataCollector>
    </DataCollectors>
  </DataCollectionRunSettings>
</RunSettings>
1
dotnet test --settings runsettings.xml

4.5 覆盖率的意义和误区

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
覆盖率的意义:
  ✓ 发现未测试的代码路径
  ✓ 衡量测试充分度的参考指标
  ✓ CI 中设置最低门槛

覆盖率的误区:
  ✗ 100% 覆盖率 ≠ 没有Bug
  ✗ 只说明代码被执行了,不说明测试有效
  ✗ 不要为了数字而写无意义测试
  ✗ getter/setter 不需要测

五、TDD 简介

5.1 红-绿-重构

1
2
3
4
5
6
TDD 流程:
1. 红(Red)    — 先写一个失败的测试
2. 绿(Green)  — 写最少的代码让测试通过
3. 重构(Refactor)— 优化代码,保持测试通过

循环往复,逐步完善功能。

5.2 TDD 实战示例

1
2
3
4
5
6
需求:实现一个密码验证器
- 长度至少8位
- 包含大写字母
- 包含小写字母
- 包含数字
- 包含特殊字符
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 第1轮:测试长度
[Fact]
public void Validate_ShortPassword_ReturnsFalse()
{
    var result = PasswordValidator.Validate("Ab1!");
    Assert.False(result.IsValid);
    Assert.Contains("长度至少8位", result.Errors);
}

// 实现最简代码
public static class PasswordValidator
{
    public static ValidationResult Validate(string password)
    {
        var errors = new List<string>();
        if (password.Length < 8) errors.Add("长度至少8位");
        return new ValidationResult { IsValid = errors.Count == 0, Errors = errors };
    }
}

// 第2轮:测试大写字母
[Fact]
public void Validate_NoUpperCase_ReturnsFalse()
{
    var result = PasswordValidator.Validate("abcdefg1!");
    Assert.False(result.IsValid);
    Assert.Contains("需包含大写字母", result.Errors);
}

// 补充实现
if (!password.Any(char.IsUpper)) errors.Add("需包含大写字母");

// 第3轮:测试小写字母
// ... 逐步添加测试和实现

// 第4轮:测试数字
// ...

// 第5轮:测试特殊字符
// ...

// 最终:测试有效密码
[Fact]
public void Validate_ValidPassword_ReturnsTrue()
{
    var result = PasswordValidator.Validate("Abcdefg1!");
    Assert.True(result.IsValid);
    Assert.Empty(result.Errors);
}

5.3 TDD 的优缺点

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
优点:
  - 迫使你先想清楚接口设计
  - 测试覆盖率高
  - 代码天然可测试
  - 重构有安全感

缺点:
  - 上手门槛高,需要练习
  - 开发初期速度慢
  - 需求频繁变化时维护成本高
  - 不是所有场景都适合(如 UI、探索性代码)

建议:
  - 核心/复杂业务逻辑用 TDD
  - 简单 CRUD 不需要 TDD
  - 先学会写测试,再学 TDD

六、测试坏味道

6.1 常见坏味道和修复

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 坏味道1:测试之间有依赖(顺序敏感)
[Fact]
public void Test1_Insert() { /* 插入数据 */ }
[Fact]
public void Test2_Query() { /* 查询 Test1 插入的数据 */ }
// 修复:每个测试独立准备数据

// 坏味道2:一个测试测太多
[Fact]
public void UserTest()
{
    // 测试创建
    // 测试查询
    // 测试更新
    // 测试删除
    // 一次测试全部 CRUD
}
// 修复:拆分为多个独立的测试方法

// 坏味道3:魔法数字
Assert.Equal(200m, order.Total); // 200 是怎么来的?
// 修复:用有意义的变量
var expectedTotal = 100m * 2; // 单价 * 数量
Assert.Equal(expectedTotal, order.Total);

// 坏味道4:过度 Mock
mockRepo.Setup(r => r.GetAll()).Returns(users);
mockRepo.Setup(r => r.GetById(1)).Returns(user);
mockRepo.Setup(r => r.Save(It.IsAny<User>()));
mockRepo.Setup(r => r.Delete(It.IsAny<int>()));
// 用了 10 个 Setup,但只测了一个方法
// 修复:只 Mock 当前测试需要的

// 坏味道5:测试不确定性(随机失败)
[Fact]
public void RandomTest()
{
    var result = service.GetRandomUser();
    // 有时通过有时不通过
}
// 修复:固定输入,断言确定的输出

// 坏味道6:sleep 等待
[Fact]
public async Task BadSleep()
{
    service.Start();
    Thread.Sleep(5000); // 等待5秒
    Assert.True(service.IsReady);
}
// 修复:用轮询 + 超时
[Fact]
public async Task GoodPolling()
{
    service.Start();
    await WaitUntil(() => service.IsReady, timeout: TimeSpan.FromSeconds(10));
    Assert.True(service.IsReady);
}

6.2 坏味道速查

1
2
3
4
5
6
7
测试代码重复         → 提取公共方法或基类
测试太长             → 拆分为多个测试
测试太慢             → 减少 I/O,多用 Mock
测试脆弱(经常挂)   → 减少对实现细节的依赖
测试名不清晰         → 用 MethodName_Scenario_Expected 格式
断言不足             → 每个 Act 至少一个 Assert
断言太多             → 可能在测多件事,考虑拆分

七、CI/CD 中的测试

7.1 GitHub Actions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
name: Test

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 8.0.x

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build --no-restore

      - name: Unit Tests
        run: dotnet test --no-build --filter "Category=Unit" --logger "trx"

      - name: Integration Tests
        run: dotnet test --no-build --filter "Category=Integration"

      - name: Coverage
        run: |
          dotnet test --collect:"XPlat Code Coverage" \
            --settings runsettings.xml \
            --results-directory ./coverage

      - name: Coverage Report
        uses: danielpalme/ReportGenerator-GitHub-Action@5
        with:
          reports: coverage/**/coverage.cobertura.xml
          targetdir: coverage/report
          reporttypes: 'HtmlInline;Cobertura'

      - name: Upload Coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/report

7.2 分层运行策略

1
2
3
4
5
6
7
8
# 快速反馈:PR 提交时跑单元测试(秒级)
- name: Unit Tests
  run: dotnet test --filter "Category=Unit"

# 完整验证:合并到 main 时跑所有测试(分钟级)
- name: All Tests
  if: github.ref == 'refs/heads/main'
  run: dotnet test

7.3 测试报告

1
2
3
4
5
# 生成 TRX 报告(Visual Studio 格式)
dotnet test --logger "trx;LogFileName=test_results.trx"

# 生成 JUnit 格式报告(CI 兼容)
dotnet test --logger "junit;LogFileName=test_results.xml"

八、dotnet test 常用命令

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 基本运行
dotnet test

# 详细输出
dotnet test --verbosity normal

# 只跑某个项目
dotnet test tests/MyApp.Services.Tests

# 按名称过滤
dotnet test --filter "FullyQualifiedName~UserService"

# 按分类过滤
dotnet test --filter "Category=Unit"
dotnet test --filter "Category!=Slow"

# 组合过滤
dotnet test --filter "Category=Unit&FullyQualifiedName~Add"

# 运行指定方法
dotnet test --filter "FullyQualifiedName=MyApp.Tests.CalculatorTests.Add_TwoNumbers_ReturnsSum"

# 监视模式(文件变化自动重跑)
dotnet test --watch

# 不构建直接跑(需要先构建)
dotnet test --no-build

# 指定运行设置
dotnet test --settings runsettings.xml

# 指定框架
dotnet test --framework net8.0

# 并行运行
dotnet test --parallel

# 超时控制
dotnet test --blame-hang --blame-hang-timeout 60s

九、开发者测试工作流

9.1 日常开发流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
写新功能:
  1. 先写测试用例(至少覆盖正常和异常)
  2. 运行测试 → 红色(失败)
  3. 写实现代码
  4. 运行测试 → 绿色(通过)
  5. 重构代码
  6. 再跑一遍测试 → 仍然绿色

修 Bug:
  1. 先写一个复现 Bug 的测试 → 红色
  2. 修复代码
  3. 测试变绿 → Bug 确认修复
  4. 回归测试

重构:
  1. 确保现有测试全绿
  2. 重构代码
  3. 跑测试确认没有破坏
  4. 如有必要更新测试

9.2 什么时候写测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
必须写:
  - 核心业务逻辑
  - 复杂算法和计算
  - 支付/金额相关
  - 权限和安全检查

建议写:
  - Service 层的公开方法
  - 数据验证逻辑
  - 状态转换逻辑

可以不写:
  - 简单的 CRUD
  - 纯 UI 代码
  - 第三方库的包装(没有自定义逻辑)
  - getter/setter

十、系列总结

10.1 知识体系回顾

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
第一篇:单元测试基础
  - xUnit 环境搭建、AAA 模式、断言详解

第二篇:xUnit 进阶
  - 数据驱动测试、共享上下文、并行控制

第三篇:Mock 与隔离
  - Moq 框架、Test Double 分类、Mock 反模式

第四篇:依赖注入与可测试性
  - 接口设计、构造函数注入、重构不可测代码

第五篇:难测场景实战
  - 数据库、HTTP、时间、文件系统、配置

第六篇:集成测试
  - WebApplicationFactory、认证授权、中间件测试

第七篇:测试工程化
  - 命名规范、覆盖率、TDD、CI/CD

10.2 核心原则

1
2
3
4
5
6
7
8
1. 测行为,不测实现
2. 隔离外部依赖
3. 每个测试只测一件事
4. 测试命名就是文档
5. 保持测试简单可读
6. 不要为了覆盖率写无意义测试
7. 先学会写测试,再学 TDD
8. 把测试当作投资,不是负担

10.3 检查清单

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
代码质量:
  □ 所有公共方法有测试覆盖
  □ 正常路径和异常路径都测了
  □ 边界条件有测试

测试质量:
  □ 测试之间互相独立
  □ 测试命名清晰
  □ 测试运行快速(秒级)

工程化:
  □ 测试在 CI 中自动运行
  □ 覆盖率有门槛
  □ 测试失败能快速定位问题