首页 / C# 入门教程 / 单元测试与代码组织

C# 入门教程

单元测试与代码组织

本教程共 100 篇 · 第 98 篇 · 更新于 2026-07-31 · 约 11 分钟阅读

C#C# 入门教程编程语言单元测试xUnitNUnit代码组织

98. 单元测试与代码组织

本节目标:学完能写第一个 xUnit 测试(断言 + [Fact]),理解测试项目的结构,并知道代码该怎么分层、怎么命名才清晰。

写完功能不代表写对了。你改一处,别处悄悄坏掉,是最常发生的事。单元测试(unit test)就是给代码买的一份「自动体检」:每次改动后跑一遍,哪里崩了一目了然。

一、为什么要写测试

设想一个 Calculator.Add 方法。你手工在 Main 里打印结果、肉眼核对,一次两次还行,方法一多就顾不过来。测试把「期望结果」写进代码,由机器反复核对:

  • 回归保护:旧功能被改坏,测试立刻红。
  • 活文档:测试就是用法示例,比注释靠谱。
  • 倒逼设计:难测的代码往往耦合太重,逼你写得更干净。
Note

单元测试只测「最小单元」(通常一个方法),不碰数据库、网络等外部依赖。那些交给集成测试。新手先守住单元这一层就够了。

为什么测试值得你现在就学?因为它把「对不对」这件事从「人脑记」变成「机器查」。人会疲惫、会漏看、会忘记上个月写的约定;测试不会。改完代码敲一下 dotnet test,几秒内全项目的行为是否正确就有答案。这种「秒级反馈」是手工验证永远给不了的。

不过也别走极端:一次性脚本、临时验证想法的小程序,没必要上测试,那只会拖慢你。测试最划算的地方,是「会被反复修改、且逻辑重要的代码」——它是为长期维护买单,不是为一次性玩具买单。

二、xUnit 入门

xUnit 是 .NET 社区最主流的测试框架。先建测试项目(见第 97 章):

dotnet new xunit -o MyApp.Tests

测试项目默认已经引用了 xunitxunit.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));
    }
}

对照一下区别:

概念xUnitNUnitMSTest
测试标记[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):核心模型与业务规则,如 CalculatorOrder。不依赖任何框架。
  • 应用层(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,逼着你把核心逻辑写成可独立调用的样子——这正是测试「倒逼好设计」的落点。

六、命名与可读性最佳实践

命名是「不用注释就能读懂」的关键:

  1. 类名 PascalCase,且见名知义CalculatorOrderService,别叫 HelperUtils 这种糊信封。
  2. 方法名用动词开头CalculateTotalSendEmail,清楚表达「做什么」。
  3. 变量 camelCase,避免缩写totalCounttc 好懂。
  4. 布尔变量像提问isEnabledhasPermission,读起来像一句话。
  5. 测试名讲清场景与预期Add_两个正数_返回和 远胜 Test1
  6. 命名空间反映层级MyApp.DomainMyApp.Infrastructure,一眼看出归属,避免类挤在全局命名空间。
  7. 常量用 PascalCase 首字母大写MaxRetryCountmaxRetry 更醒目,和变量区分开。
Tip

一个方法超过三四十行,往往该拆了。一个类职责超过「用一句话能说清」,往往该分了。短小、单一职责,比「聪明但绕」可贵。

七、好测试的标尺:F.I.R.S.T

记住五个字,测试质量就有了准绳:

  • Fast(快):一个测试毫秒级,全项目几秒跑完,你才愿意常跑。
  • Independent(独立):测试之间互不依赖、谁先谁后都行。
  • Repeatable(可重复):连跑十次结果一样,不靠运气、不靠网络。
  • Self-validating(自校验):测试自己说「过」或「挂」,不用你肉眼看输出。
  • Timely(及时):写功能时顺手写,别等半年后代码糊成一团再补。

八、新手踩坑

  1. 在测试里依赖真实数据库/文件,导致测试慢且时好时坏。单元测应隔绝外部依赖。
  2. 一个测试方法里塞十几个断言,一旦前面失败,后面看不清。一个测试聚焦一个行为。
  3. 测试名用 TestAdd1TestAdd2,半年后自己都忘了测的啥。
  4. 把业务逻辑写进 Main/控制器,导致无法测试。核心逻辑放独立类,才好测。
  5. 断言写反了「实际、期望」顺序(xUnit 是 Equal(期望, 实际)),报错信息会反着读,养成固定习惯。
  6. 测试之间共享可变状态(同一个静态字段),导致「单独跑绿、一起跑红」。每个测试自己准备数据。
Warning

别用测试去「锁死实现细节」。比如测了「内部先调了 A 再调 B 的顺序」,一旦你重构了内部步骤,测试就无辜变红。测「输入输出行为」,别测「内部怎么走」。

九、本节小结

测试不是「额外负担」,而是给代码上的保险。xUnit 用 [Fact] 标记用例、Assert 做断言,[Theory] 用多组数据复用同一逻辑;NUnit 用 [Test]/Assert.That 与之对应。配合按职责分层、清晰的命名,以及 F.I.R.S.T 标尺,你的工程会从「能跑」走向「好维护」。

下一章开始,我们用两章把 C# 8 到 14 的重要特性做一次总览,先把 8–11 的精华串起来。

动手练一练

  1. dotnet new xunit 项目,给上一章的 Calculator.Add 写一个 [Fact] 测试并跑绿。
  2. [Theory] + [InlineData(1,2,3)] 风格,让同一个加法测试覆盖三组数据。
  3. 把一个「30 行以上、又算又打印」的方法,拆成「纯计算」+「打印」两个方法,体会可测试性的差别。