Skip to Content
Week 05Day 5 - 单元测试

Day 5 - 单元测试

建议用时:260-320 分钟

你将学会什么

  • 单元测试到底在测什么
  • Arrange、Act、Assert 三步怎么写
  • 为什么业务规则应该放进容易测试的类
  • 如何先用手写断言理解测试
  • 如何创建 xUnit 测试项目
  • FactTheoryInlineDataAssert.EqualAssert.Throws 怎么用
  • 如何用假的依赖测试 Service

单元测试不是测试界面好不好看,而是用代码验证业务规则有没有被破坏。测试越靠近 Core,越稳定、越容易写。

本页固定顺序

  1. 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
  2. 再学第二部分:把刚学的知识组合成一个完整例子。
  3. 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
  4. 最后做第四部分:先独立完成作业,再用完整答案检查。

学习衔接

上一页学习的是“日志体系”,今天继续学习“单元测试”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

今天的最低通过线

第一次学习不要求背完整页。完成下面 3 项,就可以继续:

  • 能用自己的话说明“单元测试”解决什么问题。
  • 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
  • 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。

第一部分:先学原理和最小知识

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。测试的核心是:用代码证明规则还对。

1. 什么是单元测试

单元测试是用代码验证一个很小的功能单元。

常见测试对象:

  1. 一个方法。
  2. 一个类。
  3. 一个业务规则。
  4. 一个服务的某个动作。

不适合一开始就测:

  1. 整个界面。
  2. 真数据库。
  3. 真第三方接口。
  4. 很多系统串起来的大流程。

2. 为什么要写测试

测试能帮你发现:

  1. 改代码后公式被改错。
  2. 边界条件忘了处理。
  3. 异常规则被破坏。
  4. 重构后行为变了。

没有测试时,你只能靠手动运行和肉眼检查。

3. Arrange、Act、Assert

一个测试通常分三段:

阶段含义
Arrange准备对象、输入、假的依赖
Act调用被测试的方法
Assert判断结果是否符合预期

例子:

Arrange: 创建 ProductService 和输入数据 Act: 调用 Create Assert: 检查结果或异常

4. 什么是断言

断言就是验证结果。

常见断言:

断言含义
Assert.Equal(expected, actual)实际值应该等于期望值
Assert.True(condition)条件应该为 true
Assert.False(condition)条件应该为 false
Assert.Throws<T>(...)代码应该抛出指定异常

手写断言是为了理解原理,真实项目通常用测试框架。

5. 什么是 xUnit

xUnit 是 .NET 常用测试框架之一。

常见标记:

标记含义
[Fact]一个没有参数的测试
[Theory]一个带多组数据的测试
[InlineData]给 Theory 提供一组输入

6. 测试命名怎么写

测试名要说清楚:

被测试方法_场景_预期结果

例如:

CalculateTotal_ValidInput_ReturnsPriceMultiplyCount Create_EmptyName_ThrowsArgumentException

测试名不是越短越好,而是要让失败时一眼知道哪个规则坏了。

7. 为什么测试要用假的依赖

如果测试商品服务时真的写文件:

  1. 测试会变慢。
  2. 文件可能残留。
  3. 失败原因可能是磁盘权限,不是业务规则。

更好的做法是:

ProductService 依赖 IProductRepository 测试时传 FakeProductRepository

这样测试只关注业务逻辑。

xUnit 常用 API 速查

需求写法说明
固定场景测试[Fact]一个测试方法
多组数据测试[Theory] + [InlineData]同规则多输入
判断相等Assert.Equal(expected, actual)最常用
判断为真Assert.True(value)条件必须成立
判断为假Assert.False(value)条件必须不成立
判断包含Assert.Contains(text, value)文本或集合包含
判断单个元素Assert.Single(items)集合只有一项
判断异常Assert.Throws<ExceptionType>(...)错误规则

测试常用结构:

Arrange:准备对象和数据 Act:执行方法 Assert:判断结果

例子:

[Fact] public void ChangePrice_ShouldRejectZero() { Product product = new("Keyboard", 199m); Assert.Throws<ArgumentException>(() => product.ChangePrice(0)); }

第二部分:把知识组合成完整例子

前面已经学过最小知识。现在把它们组合起来,先读懂执行顺序,再完整敲一遍。今天最终要能用测试代码验证业务代码。

先看效果:手写最小测试

decimal CalculateTotal(decimal price, int count) { return price * count; } void AssertEqual(decimal expected, decimal actual) { if (expected == actual) { Console.WriteLine("测试通过"); } else { Console.WriteLine($"测试失败:期望 {expected},实际 {actual}"); } } decimal total = CalculateTotal(199m, 2); AssertEqual(398m, total);

这个例子里有测试的三步:

步骤代码
Arrange 准备199m2、期望值 398m
Act 执行CalculateTotal(199m, 2)
Assert 断言AssertEqual(398m, total)

第三部分:跟着敲代码

从这里开始动手。先用控制台理解断言,再用 xUnit 做真实测试。

动手前先做这 3 件事

  1. 先跑通手写断言,理解测试在做什么。
  2. 再创建 xUnit 项目。
  3. 每写一个测试就运行一次 dotnet test

例子 1:手写断言测试正常计算

decimal CalculateTotal(decimal price, int count) { return price * count; } void AssertEqual(decimal expected, decimal actual) { if (expected == actual) { Console.WriteLine("测试通过"); } else { Console.WriteLine($"测试失败:期望 {expected},实际 {actual}"); } } decimal total = CalculateTotal(10m, 3); AssertEqual(30m, total);

这是最小测试思想:给输入,算结果,比对期望。

例子 2:手写断言测试异常

void ChangePrice(decimal price) { if (price <= 0) { throw new ArgumentException("价格必须大于 0"); } } AssertThrows<ArgumentException>(() => ChangePrice(0m)); void AssertThrows<TException>(Action action) where TException : Exception { try { action(); Console.WriteLine("测试失败:没有抛出异常"); } catch (TException) { Console.WriteLine("测试通过"); } catch (Exception ex) { Console.WriteLine($"测试失败:异常类型不对,实际是 {ex.GetType().Name}"); } }

测试异常时,不是让程序崩溃,而是验证“应该抛的异常确实抛了”。

例子 3:创建 xUnit 测试项目

dotnet new sln -n ProductApp --format sln mkdir -p src tests dotnet new classlib -n ProductApp.Core -o src/ProductApp.Core dotnet new xunit -n ProductApp.Tests -o tests/ProductApp.Tests dotnet sln ProductApp.sln add src/ProductApp.Core/ProductApp.Core.csproj dotnet sln ProductApp.sln add tests/ProductApp.Tests/ProductApp.Tests.csproj dotnet add tests/ProductApp.Tests/ProductApp.Tests.csproj reference src/ProductApp.Core/ProductApp.Core.csproj dotnet test ProductApp.sln

这组命令创建一个 Core 类库和一个 xUnit 测试项目。

例子 4:xUnit 测试正常计算

把下面内容放进测试项目里的 CalculatorTests.cs

using Xunit; public class CalculatorTests { [Fact] public void CalculateTotal_ValidInput_ReturnsPriceMultiplyCount() { decimal total = Calculator.CalculateTotal(10m, 3); Assert.Equal(30m, total); } } public static class Calculator { public static decimal CalculateTotal(decimal price, int count) { return price * count; } }

运行:

dotnet test

例子 5:xUnit 测试异常

using Xunit; public class ProductTests { [Fact] public void ChangePrice_ZeroPrice_ThrowsArgumentException() { Product product = new Product("键盘", 199m); Assert.Throws<ArgumentException>(() => product.ChangePrice(0m)); } } public class Product { public Product(string name, decimal price) { Name = name; Price = price; } public string Name { get; } public decimal Price { get; private set; } public void ChangePrice(decimal price) { if (price <= 0) { throw new ArgumentException("价格必须大于 0"); } Price = price; } }

这个测试验证:价格为 0 时,业务规则会拒绝。

例子 6:Theory 多组数据

using Xunit; public class AgeRuleTests { [Theory] [InlineData(18, true)] [InlineData(17, false)] [InlineData(30, true)] public void IsAdult_DifferentAges_ReturnsExpectedResult(int age, bool expected) { bool actual = AgeRule.IsAdult(age); Assert.Equal(expected, actual); } } public static class AgeRule { public static bool IsAdult(int age) { return age >= 18; } }

Theory 适合一条规则、多组输入。

例子 7:测试 Service 时使用假的 Repository

using Xunit; public class ProductServiceTests { [Fact] public void Create_ValidProduct_SavesProduct() { FakeProductRepository repository = new FakeProductRepository(); ProductService service = new ProductService(repository); Product product = service.Create("键盘", 199m); Assert.Equal("键盘", product.Name); Assert.Single(repository.Saved); } } public class ProductService { private readonly IProductRepository _repository; public ProductService(IProductRepository repository) { _repository = repository; } public Product Create(string name, decimal price) { Product product = new Product(name, price); _repository.Save(product); return product; } } public interface IProductRepository { void Save(Product product); } public class FakeProductRepository : IProductRepository { public List<Product> Saved { get; } = new List<Product>(); public void Save(Product product) { Saved.Add(product); } } public class Product { public Product(string name, decimal price) { if (string.IsNullOrWhiteSpace(name)) { throw new ArgumentException("商品名称不能为空"); } if (price <= 0) { throw new ArgumentException("商品价格必须大于 0"); } Name = name.Trim(); Price = price; } public string Name { get; } public decimal Price { get; } }

这个测试没有写真实文件,只验证 ProductService 是否按规则保存商品。

常见错误和修法

错误为什么错修法
测试只验证能运行没有检查业务结果每个测试写明确 Assert
测试依赖真实文件路径换机器容易失败使用临时目录或抽象文件访问
一个测试改坏另一个测试测试之间共享状态每个测试独立准备数据
只测正常情况边界和异常容易漏补空值、负数、重复数据
测试名不清楚失败时定位慢Method_ShouldExpected_WhenCondition 这类命名

小白重复敲写训练

测试代码要故意写错一次,看到红色失败,再修成绿色。

训练 1:最小事实测试

[Fact] public void Add_TwoNumbers_ReturnsSum() { int result = 2 + 3; Assert.Equal(5, result); }

先把期望值改成 6,运行失败后再改回 5。

训练 2:测试业务方法

[Fact] public void Total_PriceAndCount_ReturnsAmount() { decimal result = PriceCalculator.Total(20m, 3); Assert.Equal(60m, result); }

改动任务:增加价格 19.9、数量 2 的案例。

训练 3:参数化测试

[Theory] [InlineData(1, 2, 3)] [InlineData(10, 5, 15)] [InlineData(-1, 1, 0)] public void Add_ManyCases(int a, int b, int expected) { Assert.Equal(expected, a + b); }

第三遍自己增加两组数据,其中一组故意失败。

每日小测

做完本页后,用这 5 题检查是否真的掌握。

1. 判断题

本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。

答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。

2. 填空题

本页主题是:单元测试。今天至少要掌握的 3 个点是:

1. 单元测试到底在测什么 2. Arrange、Act、Assert 三步怎么写 3. 为什么业务规则应该放进容易测试的类

答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。

3. 流程题

遇到本页相关功能时,先按什么顺序处理?

答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。

4. 找错误题

如果本页代码运行失败,第一步应该做什么?

答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。

5. 改需求题

在本页完整例子跑通后,至少改一个小需求。

可选改法:

  • 改一个字段名称。
  • 多加一个校验条件。
  • 多输出一行结果。
  • 把固定数据改成用户输入。
  • 把一次处理改成多条数据处理。

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:单元测试到底在测什么。

上位机专项练习

测试能保证换算、报警和解析规则修改后仍然正确。先测试纯业务逻辑,再测试外部通信。

下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。

专项例子 1:测试温度换算

public static double Scale(int raw) => raw / 10.0; [Fact] public void Scale_253_Returns25Point3() { Assert.Equal(25.3, Scale(253)); }

运行结果或界面效果:

测试通过

改动任务: 增加 raw=0 的测试。

专项例子 2:测试报警边界

public static bool IsAlarm(double value, double limit) => value >= limit; [Theory] [InlineData(79.9, false)] [InlineData(80.0, true)] [InlineData(80.1, true)] public void IsAlarm_ChecksBoundary(double value, bool expected) { Assert.Equal(expected, IsAlarm(value, 80)); }

运行结果或界面效果:

3 组边界测试全部通过

改动任务: 增加 -10 的测试。

专项例子 3:用假读取器测试服务

class FakeReader : IReader { public double Read() => 88; } [Fact] public void Monitor_WhenValueIs88_RaisesAlarm() { var monitor = new Monitor(new FakeReader(), 80); Assert.True(monitor.HasAlarm()); }

运行结果或界面效果:

不连接真实设备也能测试报警

改动任务: 让 FakeReader 返回 70 并断言 false。

第四部分:作业完整答案

这一部分给出当天作业的完整答案。建议先跑手写版,再跑 xUnit 版。

作业 1:手写测试完整答案

要求:

  1. CalculateTotal
  2. AssertEqual
  3. 测试正常数量和数量为 0。

完整答案

decimal CalculateTotal(decimal price, int count) { return price * count; } void AssertEqual(decimal expected, decimal actual) { if (expected == actual) { Console.WriteLine("测试通过"); } else { Console.WriteLine($"测试失败:期望 {expected},实际 {actual}"); } } AssertEqual(398m, CalculateTotal(199m, 2)); AssertEqual(0m, CalculateTotal(199m, 0));

作业 2:xUnit 计算测试完整答案

using Xunit; public class CalculatorTests { [Fact] public void CalculateTotal_CountIsTwo_ReturnsDoublePrice() { decimal total = Calculator.CalculateTotal(199m, 2); Assert.Equal(398m, total); } [Theory] [InlineData(199, 0, 0)] [InlineData(199, 2, 398)] [InlineData(10, 3, 30)] public void CalculateTotal_DifferentInputs_ReturnsExpected(decimal price, int count, decimal expected) { decimal actual = Calculator.CalculateTotal(price, count); Assert.Equal(expected, actual); } } public static class Calculator { public static decimal CalculateTotal(decimal price, int count) { return price * count; } }

作业 3:xUnit 商品规则完整答案

using Xunit; public class ProductTests { [Fact] public void Constructor_EmptyName_ThrowsArgumentException() { Assert.Throws<ArgumentException>(() => new Product("", 199m)); } [Fact] public void Constructor_ZeroPrice_ThrowsArgumentException() { Assert.Throws<ArgumentException>(() => new Product("键盘", 0m)); } [Fact] public void Constructor_ValidInput_CreatesProduct() { Product product = new Product(" 键盘 ", 199m); Assert.Equal("键盘", product.Name); Assert.Equal(199m, product.Price); } } public class Product { public Product(string name, decimal price) { if (string.IsNullOrWhiteSpace(name)) { throw new ArgumentException("商品名称不能为空"); } if (price <= 0) { throw new ArgumentException("商品价格必须大于 0"); } Name = name.Trim(); Price = price; } public string Name { get; } public decimal Price { get; } }

作业验收

完成后检查:

  1. 能说出 Arrange、Act、Assert。
  2. 能写一个正常结果测试。
  3. 能写一个异常测试。
  4. 能用 Theory 测多组数据。
  5. 能用假的 Repository 测 Service。
  6. 能运行 dotnet test

本页最后要记住

  1. 单元测试用代码验证代码。
  2. 测试一般分 Arrange、Act、Assert。
  3. 正常场景、边界场景、错误场景都要测。
  4. Fact 是单场景测试。
  5. Theory 适合多组输入。
  6. Assert.Throws 用来验证应该抛异常的规则。
  7. 测业务规则时,优先用假的依赖,避免依赖真实文件和数据库。