单元测试与代码组织
本教程共 100 篇 · 第 98 篇 · 更新于 2026-07-31 · 约 11 分钟阅读
98. 单元测试与代码组织
本节目标:学完能写第一个 xUnit 测试(断言 + [Fact]),理解测试项目的结构,并知道代码该怎么分层、怎么命名才清晰。
写完功能不代表写对了。你改一处,别处悄悄坏掉,是最常发生的事。单元测试(unit test)就是给代码买的一份「自动体检」:每次改动后跑一遍,哪里崩了一目了然。
一、为什么要写测试
设想一个 Calculator.Add 方法。你手工在 Main 里打印结果、肉眼核对,一次两次还行,方法一多就顾不过来。测试把「期望结果」写进代码,由机器反复核对:
- 回归保护:旧功能被改坏,测试立刻红。
- 活文档:测试就是用法示例,比注释靠谱。
- 倒逼设计:难测的代码往往耦合太重,逼你写得更干净。
Note单元测试只测「最小单元」(通常一个方法),不碰数据库、网络等外部依赖。那些交给集成测试。新手先守住单元这一层就够了。
为什么测试值得你现在就学?因为它把「对不对」这件事从「人脑记」变成「机器查」。人会疲惫、会漏看、会忘记上个月写的约定;测试不会。改完代码敲一下 dotnet test,几秒内全项目的行为是否正确就有答案。这种「秒级反馈」是手工验证永远给不了的。
不过也别走极端:一次性脚本、临时验证想法的小程序,没必要上测试,那只会拖慢你。测试最划算的地方,是「会被反复修改、且逻辑重要的代码」——它是为长期维护买单,不是为一次性玩具买单。
二、xUnit 入门
xUnit 是 .NET 社区最主流的测试框架。先建测试项目(见第 97 章):
dotnet new xunit -o MyApp.Tests
测试项目默认已经引用了 xunit 和 xunit.runner.visualstudio,并引用了被测项目(只要你按模板建且目录相邻)。
写测试前,假设有个被测算的类:
namespace CSharpDemo;
public class Calculator
{
public int Add(int a, int b) => a + b;
public int Divide(int a, int b) => a / b;
}
测试文件长这样:
using Xunit;
using CSharpDemo;
namespace CSharpDemo.Tests;
public class CalculatorTests
{
[Fact]
public void Add_两个正数_返回和()
{
var calc = new Calculator();
int result = calc.Add(2, 3);
Assert.Equal(5, result);
}
}
几个关键点:
[Fact]:标记这是一个「无参数、独立」的测试用例。xUnit 会逐一发现并运行带它的方法。Assert.Equal(期望, 实际):断言(assertion)。期望 5、实际是result,不符就失败。- 命名
Add_两个正数_返回和:用「方法_场景_预期」三段式,一看就知道测什么。
这段测试藏着业界常用的 AAA 模式:var calc = ... 是 Arrange(准备);calc.Add(2,3) 是 Act(执行);Assert.Equal 是 Assert(断言)。把「准备—执行—断言」三段分开写,测试读起来一目了然。
跑测试:
dotnet test
全绿表示通过,红了会指出哪个断言不符。
Tip除了
[Fact],xUnit 还有[Theory]+[InlineData(...)],能用多组数据跑同一个测试,避免复制粘贴多个相似方法。
using Xunit;
using CSharpDemo;
namespace CSharpDemo.Tests;
public class CalculatorTheoryTests
{
[Theory]
[InlineData(2, 3, 5)]
[InlineData(-1, 1, 0)]
[InlineData(0, 0, 0)]
public void Add_多组数据_返回和(int a, int b, int expected)
{
var calc = new Calculator();
Assert.Equal(expected, calc.Add(a, b));
}
}
一个测试方法,三组输入,省下两遍重复代码。参数越多、边界越多,[Theory] 越香。
三、NUnit 对照
NUnit 是另一套老牌框架,风格略有不同:
using NUnit.Framework;
using CSharpDemo;
namespace CSharpDemo.Tests;
[TestFixture]
public class CalculatorNUnitTests
{
[Test]
public void Add_两个正数_返回和()
{
var calc = new Calculator();
Assert.That(calc.Add(2, 3), Is.EqualTo(5));
}
}
对照一下区别:
| 概念 | xUnit | NUnit | MSTest |
|---|---|---|---|
| 测试标记 | [Fact] | [Test] | [TestMethod] |
| 断言 | Assert.Equal(期望, 实际) | Assert.That(实际, Is.EqualTo(期望)) | Assert.AreEqual(期望, 实际) |
| 类标记 | 不需要 | [TestFixture](新版本也可省) | [TestClass] |
三者能力相当,MSTest 则是 Visual Studio 自带的那一套。选哪个看团队习惯,本书示例以 xUnit 为主。
Tip框架只是工具,别在「选哪个」上纠结太久。真正决定测试质量的是「测什么、怎么组织」,而不是用哪套断言 API。
四、测试该测什么
好测试关注「行为」而非「实现」。仍以 Calculator 为例,除了正常相加,还该测边界:
[Fact]
public void Add_含负数_返回正确值()
{
var calc = new Calculator();
Assert.Equal(-1, calc.Add(2, -3));
}
[Fact]
public void Divide_除以零_抛出异常()
{
var calc = new Calculator();
Assert.Throws<DivideByZeroException>(() => calc.Divide(1, 0));
}
Assert.Throws<T> 验证「该抛的异常确实抛了」。把正常、异常、边界都覆盖,信心才足。
一张实用的检查清单:正常输入、边界值(0、最大最小、空集合)、非法输入(null、越界)、以及「该抛异常时抛异常」。这四档凑齐,一个方法基本就测透了。
五、项目分层:别把一切塞进一个类
随着代码变多,单文件会失控。常见做法按「职责」拆项目:
- 领域层(Domain):核心模型与业务规则,如
Calculator、Order。不依赖任何框架。 - 应用层(Application):编排用例,调用领域层。
- 基础设施层(Infrastructure):文件、数据库、网络等具体实现。
- 表现层(Presentation):控制台、Web 接口等入口。
新手不必严格照搬,但心里要有一根弦:容易变的外围(IO、UI)和稳定的核心(业务逻辑)要分开。这样哪天从控制台换成 Web,核心逻辑一行不用改。
# 一个典型分层骨架
dotnet new classlib -o MyApp.Domain
dotnet new classlib -o MyApp.Infrastructure
dotnet new console -o MyApp.Cli
dotnet new xunit -o MyApp.Tests
分层有个铁律:依赖只能向内指,不能向外。表现层可以调用应用层,应用层可以调用领域层;反过来不允许——领域层绝不引用控制台或数据库细节。这样核心逻辑才能独立测试、独立演进。新手常犯的错是把「读文件」「连数据库」直接写进领域类,结果想测个纯计算也要先备好真实磁盘,测试又慢又脆。
Tip不必一开始就把四层全建出来。小工具一个项目足矣;等某块逻辑明显变多、或被多处复用,再把它抽成独立类库。架构是「长出来」的,不是「预先堆」的。
测试项目应只引用「要测的那一层」。这样测试天然隔离了数据库和 UI,逼着你把核心逻辑写成可独立调用的样子——这正是测试「倒逼好设计」的落点。
六、命名与可读性最佳实践
命名是「不用注释就能读懂」的关键:
- 类名 PascalCase,且见名知义:
Calculator、OrderService,别叫Helper、Utils这种糊信封。 - 方法名用动词开头:
CalculateTotal、SendEmail,清楚表达「做什么」。 - 变量 camelCase,避免缩写:
totalCount比tc好懂。 - 布尔变量像提问:
isEnabled、hasPermission,读起来像一句话。 - 测试名讲清场景与预期:
Add_两个正数_返回和远胜Test1。 - 命名空间反映层级:
MyApp.Domain、MyApp.Infrastructure,一眼看出归属,避免类挤在全局命名空间。 - 常量用 PascalCase 首字母大写:
MaxRetryCount比maxRetry更醒目,和变量区分开。
Tip一个方法超过三四十行,往往该拆了。一个类职责超过「用一句话能说清」,往往该分了。短小、单一职责,比「聪明但绕」可贵。
七、好测试的标尺:F.I.R.S.T
记住五个字,测试质量就有了准绳:
- Fast(快):一个测试毫秒级,全项目几秒跑完,你才愿意常跑。
- Independent(独立):测试之间互不依赖、谁先谁后都行。
- Repeatable(可重复):连跑十次结果一样,不靠运气、不靠网络。
- Self-validating(自校验):测试自己说「过」或「挂」,不用你肉眼看输出。
- Timely(及时):写功能时顺手写,别等半年后代码糊成一团再补。
八、新手踩坑
- 在测试里依赖真实数据库/文件,导致测试慢且时好时坏。单元测应隔绝外部依赖。
- 一个测试方法里塞十几个断言,一旦前面失败,后面看不清。一个测试聚焦一个行为。
- 测试名用
TestAdd1、TestAdd2,半年后自己都忘了测的啥。 - 把业务逻辑写进
Main/控制器,导致无法测试。核心逻辑放独立类,才好测。 - 断言写反了「实际、期望」顺序(xUnit 是
Equal(期望, 实际)),报错信息会反着读,养成固定习惯。 - 测试之间共享可变状态(同一个静态字段),导致「单独跑绿、一起跑红」。每个测试自己准备数据。
Warning别用测试去「锁死实现细节」。比如测了「内部先调了 A 再调 B 的顺序」,一旦你重构了内部步骤,测试就无辜变红。测「输入输出行为」,别测「内部怎么走」。
九、本节小结
测试不是「额外负担」,而是给代码上的保险。xUnit 用 [Fact] 标记用例、Assert 做断言,[Theory] 用多组数据复用同一逻辑;NUnit 用 [Test]/Assert.That 与之对应。配合按职责分层、清晰的命名,以及 F.I.R.S.T 标尺,你的工程会从「能跑」走向「好维护」。
下一章开始,我们用两章把 C# 8 到 14 的重要特性做一次总览,先把 8–11 的精华串起来。
动手练一练
- 建
dotnet new xunit项目,给上一章的Calculator.Add写一个[Fact]测试并跑绿。 - 用
[Theory]+[InlineData(1,2,3)]风格,让同一个加法测试覆盖三组数据。 - 把一个「30 行以上、又算又打印」的方法,拆成「纯计算」+「打印」两个方法,体会可测试性的差别。