Day 5 - 单元测试
建议用时:260-320 分钟
你将学会什么
- 单元测试到底在测什么
- Arrange、Act、Assert 三步怎么写
- 为什么业务规则应该放进容易测试的类
- 如何先用手写断言理解测试
- 如何创建 xUnit 测试项目
Fact、Theory、InlineData、Assert.Equal、Assert.Throws怎么用- 如何用假的依赖测试 Service
单元测试不是测试界面好不好看,而是用代码验证业务规则有没有被破坏。测试越靠近 Core,越稳定、越容易写。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“日志体系”,今天继续学习“单元测试”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 3 项,就可以继续:
- 能用自己的话说明“单元测试”解决什么问题。
- 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
- 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。
第一部分:先学原理和最小知识
这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。测试的核心是:用代码证明规则还对。
1. 什么是单元测试
单元测试是用代码验证一个很小的功能单元。
常见测试对象:
- 一个方法。
- 一个类。
- 一个业务规则。
- 一个服务的某个动作。
不适合一开始就测:
- 整个界面。
- 真数据库。
- 真第三方接口。
- 很多系统串起来的大流程。
2. 为什么要写测试
测试能帮你发现:
- 改代码后公式被改错。
- 边界条件忘了处理。
- 异常规则被破坏。
- 重构后行为变了。
没有测试时,你只能靠手动运行和肉眼检查。
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. 为什么测试要用假的依赖
如果测试商品服务时真的写文件:
- 测试会变慢。
- 文件可能残留。
- 失败原因可能是磁盘权限,不是业务规则。
更好的做法是:
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 准备 | 199m、2、期望值 398m |
| Act 执行 | CalculateTotal(199m, 2) |
| Assert 断言 | AssertEqual(398m, total) |
第三部分:跟着敲代码
从这里开始动手。先用控制台理解断言,再用 xUnit 做真实测试。
动手前先做这 3 件事
- 先跑通手写断言,理解测试在做什么。
- 再创建 xUnit 项目。
- 每写一个测试就运行一次
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:手写测试完整答案
要求:
- 写
CalculateTotal。 - 写
AssertEqual。 - 测试正常数量和数量为 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; }
}作业验收
完成后检查:
- 能说出 Arrange、Act、Assert。
- 能写一个正常结果测试。
- 能写一个异常测试。
- 能用
Theory测多组数据。 - 能用假的 Repository 测 Service。
- 能运行
dotnet test。
本页最后要记住
- 单元测试用代码验证代码。
- 测试一般分 Arrange、Act、Assert。
- 正常场景、边界场景、错误场景都要测。
Fact是单场景测试。Theory适合多组输入。Assert.Throws用来验证应该抛异常的规则。- 测业务规则时,优先用假的依赖,避免依赖真实文件和数据库。