写在前面
本文是 .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 ( 250 m , 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 ( 200 m , order . Total ); // 200 是怎么来的?
// 修复:用有意义的变量
var expectedTotal = 100 m * 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 中自动运行
□ 覆盖率有门槛
□ 测试失败能快速定位问题
Licensed under CC BY-NC-SA 4.0