1. 核心理念 (Why & What)
1.1 什么是单元测试?
单元测试是对软件中的最小可测试单元(通常是类中的方法)进行检查和验证。
- 目标: 确保逻辑在隔离环境中按预期运行。
- 特点: 不依赖数据库、文件系统、网络或其他外部环境。
1.2 FIRST 原则 (黄金法则)
所有优秀的单元测试都必须遵守 FIRST 原则:
- F (Fast) 快速: 运行速度必须极快(毫秒级)。
- I (Isolated) 隔离: 测试之间互不干扰,不依赖执行顺序。
- R (Repeatable) 可重复: 在任何环境、任何时间运行结果一致(不依赖
DateTime.Now或随机数)。 - S (Self-validating) 自验证: 测试结果只有 Pass 或 Fail,不需要人工查看日志。
- T (Timely) 及时: 随代码编写同时完成,不要等到项目结束再补。
2. 技术栈与工具 (Tools)
在现代 .NET / C# 开发中,推荐使用以下组合:
| 组件 | 推荐工具 | 说明 |
|---|---|---|
| 测试框架 | xUnit | 现代、简洁、社区支持最强(优于 MSTest)。 |
| 模拟框架 | Moq | 用于模拟接口行为(Mocking)。 |
| 断言库 | xUnit 自带 / FluentAssertions | 用于验证结果(Assert.Equal)。 |
| 运行工具 | Visual Studio Test Explorer | 快捷键 Ctrl + E, T。 |
3. 编写规范 (How to Write)
3.1 3A 模式 (代码结构)
每个测试方法内部必须清晰地分为三部分:
- Arrange (准备): 初始化对象、设置 Mock 行为、准备数据。
- Act (执行): 调用被测试的方法。
- Assert (断言): 验证返回值是否正确,或是否调用了预期的方法。
3.2 命名规范
测试方法的名称应当像“句子”一样清晰,描述 [测什么] + [什么条件下] + [期望结果] 。
- 格式:
MethodName_StateUnderTesting_ExpectedBehavior - 示例:
Register_WithExistingEmail_ThrowsException
3.3 Mock (模拟) 的边界
这是区分“单元测试”与“集成测试”的关键。
-
需要 Mock 的(外部依赖):
- 数据库操作 (
Repository,DbContext) - 第三方 API 调用 (
HttpClient) - 文件 I/O、系统时间、随机数
- 数据库操作 (
-
不需要 Mock 的(内部逻辑):
- DTO / Entity 实体对象
- LINQ 查询(内存中)
- 私有方法(通过测试 Public 方法覆盖)
- 工具类方法(如
String.Split)
4. 测试策略与范围 (Strategy)
4.1 测试粒度
-
重点测试: Service 层 的 Public 方法。这里是业务逻辑最密集的地方。
-
轻量测试/不测试:
- Controller 层: 仅验证 HTTP 状态码转换,不测业务逻辑。
- Repository 层: 通常由集成测试覆盖。
- 纯 CRUD: 如果方法只是简单的一行透传,不值得测试。
4.2 覆盖率与 ROI (投入产出比)
-
不要追求 100% 覆盖率。 推荐目标:核心业务模块 80%+ 。
-
必测路径:
- Happy Path: 一切正常的流程。
- Edge Cases: 关键的边界条件(如空值、负数、重复数据)。
-
效率神器: 使用 xUnit 的
[Theory]和[InlineData]来合并重复的测试逻辑。
5. 实战代码速查表 (Cheat Sheet)
场景:测试一个用户注册服务
using Xunit;
using Moq;
using MyProject.Services;
using MyProject.Interfaces;
public class UserServiceTests
{
// 定义 Mocks (模拟对象)
private readonly Mock<IUserRepository> _mockRepo;
private readonly Mock<IEmailService> _mockEmail;
// 定义被测对象
private readonly UserService _service;
public UserServiceTests()
{
// Arrange: 初始化 (会在每个测试运行前执行)
_mockRepo = new Mock<IUserRepository>();
_mockEmail = new Mock<IEmailService>();
_service = new UserService(_mockRepo.Object, _mockEmail.Object);
}
// 案例 1: 快乐路径 (Happy Path)
[Fact]
public async Task Register_ValidUser_ReturnsTrueAndSendsEmail()
{
// Arrange
var email = "new@test.com";
// 模拟:数据库里找不到这个邮箱 (返回 null)
_mockRepo.Setup(r => r.GetByEmail(email)).ReturnsAsync((User)null);
// Act
var result = await _service.Register(email);
// Assert
Assert.True(result); // 验证返回值
// 验证:是否确实调用了数据库保存方法?(验证行为)
_mockRepo.Verify(r => r.Add(It.IsAny<User>()), Times.Once);
// 验证:是否确实发了邮件?
_mockEmail.Verify(e => e.SendWelcome(email), Times.Once);
}
// 案例 2: 异常路径 (Edge Case) - 邮箱已存在
[Fact]
public async Task Register_ExistingEmail_ThrowsException()
{
// Arrange
var email = "exist@test.com";
// 模拟:数据库里已经有这个用户了
_mockRepo.Setup(r => r.GetByEmail(email)).ReturnsAsync(new User());
// Act & Assert
// 验证是否抛出了特定异常
await Assert.ThrowsAsync<InvalidOperationException>(() => _service.Register(email));
// 验证:确保没有调用保存,也没有发邮件
_mockRepo.Verify(r => r.Add(It.IsAny<User>()), Times.Never);
_mockEmail.Verify(e => e.SendWelcome(It.IsAny<string>()), Times.Never);
}
// 案例 3: 参数化测试 (多组数据)
[Theory]
[InlineData("")]
[InlineData(null)]
public async Task Register_InvalidInput_ThrowsArgumentException(string invalidEmail)
{
await Assert.ThrowsAsync<ArgumentException>(() => _service.Register(invalidEmail));
}
}
6. 自检清单 (Checklist)
在提交代码前,问自己几个问题:
- 我的测试跑得快吗?(是否连了真实数据库?)
- 测试名能让我一眼看懂在测什么吗?
- 我是否只测试了 Public 方法?
- 我是否覆盖了“成功”和“失败”两种主要情况?
- 如果我重构代码内部实现(不改功能),测试会挂吗?(如果不改功能测试却挂了,说明测试写得太关注细节了)。
7. 测试策略
1. 企业一般的要求标准是什么?
在大多数正规的软件开发团队(非只有 1-2 人的初创团队)中,通常有以下硬性或软性指标:
A. 代码覆盖率 (Code Coverage) —— 核心指标
这是最直观的数据。Visual Studio 企业版或 DevOps 平台(如 Azure DevOps, Jenkins)都会统计这个数字。
-
及格线:60% - 70%
- 这通常意味着覆盖了主要的业务逻辑(Happy Path)。
-
行业标准/优秀线:80%
- 这是大多数成熟互联网公司和外企追求的目标。如果有 CI/CD 流水线,低于 80% 的覆盖率可能会导致代码合并被拒绝。
-
金融/医疗/航空:90% - 100%
- 这些领域极其严苛,但这通常不适用于普通商业项目。
B. 必测范围
- 核心业务 (Core Domain): 涉及金钱计算、订单状态流转、权限校验的代码,必须有测试。
- 公共库 (Shared Libraries): 给其他团队用的工具类,必须高覆盖率。
2. 每个 Public 方法都要写一个测试吗?
答案是:绝对不需要。 盲目追求“每个方法都有测试”是低效的源头。
❌ 不需要测试的方法(低价值)
-
琐碎代码 (Trivial Code):
- 简单的 Getter/Setter 属性。
- 没有任何逻辑判断,只是把数据从 A 传给 B 的方法(纯透传)。
- 构造函数(除非里面有复杂的初始化逻辑)。
-
框架生成的代码: 比如 Entity Framework 的 Migration 文件,或者自动生成的 DTO。
-
简单的 CRUD: 如果你的 Service 方法只是调用了一下
Repository.GetById()然后直接返回,不要测它。你是在测微软的编译器,这没有意义。
✅ 必须测试的方法(高价值)
- 有条件分支的: 包含
if,switch,while的方法。 - 有计算逻辑的: 包含
+ - * /或字符串处理的方法。 - 有异常抛出的: 业务规则校验(例如:余额不足抛出异常)。
3. 如何平衡开发效率与测试详细度?
如果你觉得写测试太慢,通常是因为你试图覆盖“所有可能”,或者测试写得太难维护。
策略一:遵循 "80/20 法则" (重要!)
只写两个维度的测试,就能覆盖 80% 的风险:
- Happy Path (快乐路径): 输入完全正确,流程一切正常,最后返回成功。这是必须要有的。
- Edge Cases (关键边缘路径): 最容易出错的 1-2 种情况(例如输入为 null,ID 不存在,金额为负数)。
放弃什么?
放弃那些极低概率发生的边界情况,或者需要极其复杂的 Mock 才能模拟出来的场景。
策略二:使用参数化测试 (Parameterized Tests)
这是 C# xUnit 提升效率的神器。不要为每种输入都复制粘贴写一个新方法。
低效写法:
C#
[Fact] void Test_Age_10_Return_False() { ... }
[Fact] void Test_Age_20_Return_True() { ... }
[Fact] void Test_Age_18_Return_True() { ... }
高效写法 (使用 [Theory] 和 [InlineData]):
写一个测试方法,测 5 种情况,代码量几乎不增加。
C#
[Theory]
[InlineData(10, false)] // 输入10,期望 false
[InlineData(17, false)]
[InlineData(18, true)] // 临界点
[InlineData(20, true)]
public void CheckAdult_ReturnsExpectedResult(int age, bool expected)
{
// Arrange
var service = new UserService();
// Act
var result = service.IsAdult(age);
// Assert
Assert.Equal(expected, result);
}
策略三:避免测试“实现细节”
这是导致测试难以维护、开发效率低下的最大原因。
- 糟糕的测试: 验证“Service 是否先读取了缓存,缓存没有再读取数据库,然后把数据写入缓存”。(如果以后你删除了缓存逻辑,测试就挂了,虽然功能没变)。
- 好的测试: 验证“我输入 ID,Service 是否返回了正确的用户”。(不管你内部怎么折腾,只要结果对就行)。
原则:像用户一样测试你的接口,而不是像做手术一样去测试内部变量。
总结:给你的行动指南
如果你现在还在“从 0 到 1”的阶段,我建议你采取**“防御性测试策略”**:
- 新功能必测: 以后新写的核心 Service 方法,必须写一个 Happy Path 测试。
- Bug 修复必测: 每次修复一个 Bug,先写一个测试复现这个 Bug,修复后测试通过。这能防止 Bug 复发(回归测试)。
- 旧代码随缘: 不要试图回头去给几年前的旧代码补测试,除非你要重构它。