Day 3 - 测试补齐
建议用时:180-210 分钟
你将学会什么
- 用测试代码验证业务规则
- 区分正常测试、边界测试、异常测试、回归测试
- 用 xUnit 写
Fact和Theory - 用
Assert.Equal、Assert.Throws、Assert.Contains判断结果是否正确
本页开始写真实测试项目。测试不是为了多写代码,而是为了把“这个规则到底对不对”固定下来。以后改代码时,只要跑测试,就能知道有没有把旧功能改坏。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
今天只抓住 3 件事
- 测试不是测界面好不好看,而是固定业务规则。
- 一个测试只验证一个明确场景,失败时才能快速定位。
- 正常、边界、异常、回归这四类场景都要覆盖。
学习衔接
上一页学习的是“体验优化”,今天继续学习“测试补齐”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
第一部分:先学原理和最小知识
这一部分只读,不敲代码。
为什么要写测试
程序越来越大以后,靠眼睛检查会变得不可靠。
比如商品规则里有一句“价格必须大于 0”。今天你写对了,三天后你改保存逻辑时,可能无意中让 0 也能保存。界面不一定马上暴露问题,但测试会立刻失败。
测试的核心作用是:
- 把规则写死,避免以后被改坏。
- 把边界情况列出来,减少漏处理。
- 出问题时快速定位,是价格规则坏了,还是库存规则坏了。
- 交付前可以一键跑完整检查。
测试的 4 类场景
| 类型 | 验证什么 | 商品例子 |
|---|---|---|
| 正常测试 | 合法输入能得到正确结果 | 199 * 3 = 597 |
| 边界测试 | 刚好卡在边界的输入 | 库存 0 是允许的 |
| 异常测试 | 非法输入会被拒绝 | 价格 0 会抛异常 |
| 回归测试 | 旧功能不能被新修改破坏 | 改价格失败后旧价格还在 |
xUnit 里的常用名词
| 名词 | 直接解释 | 例子 |
|---|---|---|
Fact | 一个固定场景的测试 | 测试名称为空会失败 |
Theory | 同一个测试跑多组数据 | 测试 0 和 -1 都不是合法价格 |
InlineData | 给 Theory 传入一组数据 | [InlineData(-1)] |
Assert.Equal | 判断实际结果是否等于期望结果 | Assert.Equal(597m, amount) |
Assert.Throws | 判断某段代码是否抛出指定异常 | Assert.Throws<ArgumentException>(...) |
Assert.Contains | 判断文字里是否包含某段内容 | 错误消息里包含“价格” |
AAA 结构
测试代码通常按 AAA 写:
| 步骤 | 全称 | 作用 |
|---|---|---|
| Arrange | 准备 | 创建对象、准备输入 |
| Act | 执行 | 调用要测试的方法 |
| Assert | 判断 | 判断结果是否符合预期 |
示例:
[Fact]
public void CalculateAmount_ShouldReturnPriceMultiplyCount()
{
Product product = new("Keyboard", 199m, 10);
decimal amount = product.CalculateAmount(3);
Assert.Equal(597m, amount);
}第一行创建商品是 Arrange。
第二行调用 CalculateAmount 是 Act。
第三行判断结果是 Assert。
测试名为什么要写长一点
测试名要能说明三件事:
- 测哪个方法。
- 在什么情况下。
- 应该得到什么结果。
所以 Test1 这种名字没有价值。
更好的名字是:
ChangePrice_ShouldRejectZero()这句话读出来就是:修改价格时,应该拒绝 0。
第二部分:把知识组合成完整例子
今天不写界面,专门测试商品规则。
商品规则先定成这样:
- 商品名称不能为空。
- 商品名称前后多余空格要去掉。
- 商品名称不能超过 30 个字符。
- 价格必须大于 0。
- 库存不能小于 0。
- 计算金额时,数量不能小于 0。
- 修改价格失败时,原来的价格不能被破坏。
这些规则如果只靠手动点界面检查,会很容易漏。测试代码的价值就是:把这些规则写成可重复运行的检查。
先看一个测试长什么样
[Fact]
public void ChangePrice_ShouldRejectZero()
{
Product product = new("Keyboard", 199m, 10);
ArgumentException exception = Assert.Throws<ArgumentException>(
() => product.ChangePrice(0));
Assert.Contains("价格", exception.Message);
}这段测试分成三步:
- 准备:创建一个商品。
- 执行:把价格改成
0。 - 判断:必须抛出
ArgumentException,并且错误消息里要包含“价格”。
第三部分:跟着敲代码
从这里开始动手。今天创建一个独立测试项目,先把测试跑通,再理解每个断言。
第 1 步:创建测试项目
在一个练习目录里执行:
dotnet new xunit -n ProductRules.Tests
cd ProductRules.Tests创建后先跑一次:
dotnet test能看到测试命令执行成功后,再继续替换代码。
第 2 步:写商品规则类
新建 Product.cs,写入下面完整代码。
namespace ProductRules.Tests;
public sealed class Product
{
public Product(string name, decimal price, int stock)
{
ChangeName(name);
ChangePrice(price);
ChangeStock(stock);
}
public string Name { get; private set; } = "";
public decimal Price { get; private set; }
public int Stock { get; private set; }
public void ChangeName(string name)
{
string trimmedName = name.Trim();
if (string.IsNullOrWhiteSpace(trimmedName))
{
throw new ArgumentException("商品名称不能为空。");
}
if (trimmedName.Length > 30)
{
throw new ArgumentException("商品名称不能超过 30 个字符。");
}
Name = trimmedName;
}
public void ChangePrice(decimal price)
{
if (price <= 0)
{
throw new ArgumentException("价格必须大于 0。");
}
Price = price;
}
public void ChangeStock(int stock)
{
if (stock < 0)
{
throw new ArgumentException("库存不能小于 0。");
}
Stock = stock;
}
public decimal CalculateAmount(int count)
{
if (count < 0)
{
throw new ArgumentException("数量不能小于 0。");
}
return Price * count;
}
}这段业务代码故意不依赖界面、不依赖文件、不依赖数据库。这样测试可以直接创建 Product,直接调用方法,速度快,也容易定位问题。
第 3 步:写测试代码
删除模板自带的 UnitTest1.cs,新建 ProductTests.cs,写入下面完整代码。
namespace ProductRules.Tests;
public sealed class ProductTests
{
[Fact]
public void Constructor_ShouldTrimName_WhenNameHasSpaces()
{
Product product = new(" Keyboard ", 199m, 10);
Assert.Equal("Keyboard", product.Name);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void ChangeName_ShouldRejectEmptyName(string name)
{
Product product = new("Keyboard", 199m, 10);
ArgumentException exception = Assert.Throws<ArgumentException>(
() => product.ChangeName(name));
Assert.Contains("名称", exception.Message);
}
[Fact]
public void ChangeName_ShouldRejectNameLongerThanThirtyCharacters()
{
Product product = new("Keyboard", 199m, 10);
string longName = "1234567890123456789012345678901";
ArgumentException exception = Assert.Throws<ArgumentException>(
() => product.ChangeName(longName));
Assert.Contains("30", exception.Message);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void ChangePrice_ShouldRejectZeroAndNegativePrice(int invalidPrice)
{
Product product = new("Keyboard", 199m, 10);
ArgumentException exception = Assert.Throws<ArgumentException>(
() => product.ChangePrice(invalidPrice));
Assert.Contains("价格", exception.Message);
}
[Fact]
public void ChangePrice_ShouldKeepOldPrice_WhenNewPriceIsInvalid()
{
Product product = new("Keyboard", 199m, 10);
Assert.Throws<ArgumentException>(() => product.ChangePrice(0));
Assert.Equal(199m, product.Price);
}
[Theory]
[InlineData(0)]
[InlineData(5)]
public void ChangeStock_ShouldAcceptZeroAndPositiveStock(int stock)
{
Product product = new("Keyboard", 199m, 10);
product.ChangeStock(stock);
Assert.Equal(stock, product.Stock);
}
[Fact]
public void ChangeStock_ShouldRejectNegativeStock()
{
Product product = new("Keyboard", 199m, 10);
ArgumentException exception = Assert.Throws<ArgumentException>(
() => product.ChangeStock(-1));
Assert.Contains("库存", exception.Message);
}
[Theory]
[InlineData(0, 0)]
[InlineData(3, 597)]
public void CalculateAmount_ShouldReturnPriceMultiplyCount(int count, int expectedAmount)
{
Product product = new("Keyboard", 199m, 10);
decimal amount = product.CalculateAmount(count);
Assert.Equal(expectedAmount, amount);
}
[Fact]
public void CalculateAmount_ShouldRejectNegativeCount()
{
Product product = new("Keyboard", 199m, 10);
ArgumentException exception = Assert.Throws<ArgumentException>(
() => product.CalculateAmount(-1));
Assert.Contains("数量", exception.Message);
}
}第 4 步:运行测试
执行:
dotnet test正确结果应该包含类似信息:
已通过! - 失败: 0,通过: 13数字可能因为 SDK 语言和版本显示略有不同,重点看:
失败: 0通过: 13
第 5 步:故意改错一次
把 Product.cs 里的价格判断临时改成:
if (price < 0)再执行:
dotnet test这时 ChangePrice_ShouldRejectZeroAndNegativePrice 应该失败,因为价格 0 被错误地放过了。
验证完以后,把代码改回:
if (price <= 0)这一步很重要:测试的价值不是“永远通过”,而是能在规则被改坏时明确失败。
xUnit 常用操作速查
| 需求 | 写法 | 作用 |
|---|---|---|
| 创建测试项目 | dotnet new xunit -n Product.Tests | 新建 xUnit 项目 |
| 引用业务项目 | dotnet add reference ../Product/Product.csproj | 测试项目访问业务代码 |
| 运行测试 | dotnet test | 执行全部测试 |
| 单个固定场景 | [Fact] | 不需要传参的测试 |
| 多组输入 | [Theory] + [InlineData(...)] | 同一规则跑多组数据 |
| 判断相等 | Assert.Equal(expected, actual) | 比较期望和实际 |
| 判断为真 | Assert.True(value) | 验证布尔结果 |
| 判断异常 | Assert.Throws<ArgumentException>(...) | 验证非法输入会失败 |
| 判断文本包含 | Assert.Contains("价格", message) | 验证错误消息 |
常见错误和修法
| 错误 | 为什么错 | 修法 |
|---|---|---|
| 一个测试里测太多规则 | 失败时不知道哪个规则坏了 | 一个测试只测一个重点 |
| 测试只写正常输入 | 边界和非法输入容易漏 | 补 0、负数、空字符串、超长文本 |
| 只看测试通过,不看断言 | 测试可能没有真正验证规则 | 每个测试必须有清楚的 Assert |
| 业务代码和测试项目没引用 | 测试项目找不到类型 | 用 dotnet add reference 引用业务项目 |
| 异常类型写错 | 测试失败但原因不清楚 | 按业务约定统一异常类型和消息 |
小白重复敲写训练
补测试先覆盖最重要的正常、边界、错误三类。
训练 1:正常案例
[Fact]
public void Total_NormalInput_ReturnsAmount()
{
Assert.Equal(60m, PriceCalculator.Total(20m, 3));
}训练 2:边界案例
[Fact]
public void Total_ZeroCount_ReturnsZero()
{
Assert.Equal(0m, PriceCalculator.Total(20m, 0));
}训练 3:错误案例
[Fact]
public void Create_EmptyName_Throws()
{
Assert.Throws<ArgumentException>(() => service.Create("", 20m));
}第三遍给同一个方法再补一个超大数值案例,并运行 dotnet test。
每日小测
做完本页后,用这 5 题检查是否真的掌握。
1. 判断题
本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。
答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。
2. 填空题
本页主题是:测试补齐。今天至少要掌握的 3 个点是:
1. 用测试代码验证业务规则
2. 区分正常测试、边界测试、异常测试、回归测试
3. 用 xUnit 写 `Fact` 和 `Theory`答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。
3. 流程题
遇到本页相关功能时,先按什么顺序处理?
答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。
4. 找错误题
如果本页代码运行失败,第一步应该做什么?
答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。
5. 改需求题
在本页完整例子跑通后,至少改一个小需求。
可选改法:
- 改一个字段名称。
- 多加一个校验条件。
- 多输出一行结果。
- 把固定数据改成用户输入。
- 把一次处理改成多条数据处理。
答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:用测试代码验证业务规则。
上位机专项练习
测试要覆盖业务规则、ViewModel 命令、配置读写和关键界面流程。
下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。
专项例子 1:报警规则单元测试
[Fact]
public void Check_ValueEqualsLimit_CreatesAlarm()
{
Alarm? alarm = service.Check("Temperature", 80, 80);
Assert.NotNull(alarm);
}运行结果或界面效果:
边界值等于上限时产生报警改动任务: 增加低于上限不报警测试。
专项例子 2:ViewModel 命令测试
[Fact]
public void AcknowledgeCommand_MarksSelectedAlarm()
{
vm.SelectedAlarm = new AlarmItem("高温");
vm.AcknowledgeCommand.Execute(null);
Assert.True(vm.SelectedAlarm.IsAcknowledged);
}运行结果或界面效果:
不启动窗口也能测试命令改动任务: 增加未选择报警时不崩溃测试。
专项例子 3:配置往返测试
[Fact]
public async Task SaveThenLoad_ReturnsSameDeviceName()
{
await store.SaveAsync(new Settings { DeviceName = "PLC-01" });
Settings loaded = await store.LoadAsync();
Assert.Equal("PLC-01", loaded.DeviceName);
}运行结果或界面效果:
保存再读取后数据一致改动任务: 增加端口和周期断言。
第四部分:作业完整答案
作业要求
给商品服务 ProductService 补测试,规则如下:
- 正常新增商品后,列表数量应该增加。
- 商品名称重复时,不能新增。
- 名称重复判断忽略前后空格。
- 删除不存在的商品时,返回
false。 - 删除存在的商品时,返回
true,并且列表数量减少。
完整答案:ProductService.cs
新建 ProductService.cs。
namespace ProductRules.Tests;
public sealed class ProductService
{
private readonly List<Product> products = new();
public IReadOnlyList<Product> Products => products;
public bool Add(Product product)
{
bool nameExists = products.Any(existingProduct =>
existingProduct.Name.Equals(product.Name.Trim(), StringComparison.OrdinalIgnoreCase));
if (nameExists)
{
return false;
}
products.Add(product);
return true;
}
public bool DeleteByName(string name)
{
string trimmedName = name.Trim();
Product? product = products.FirstOrDefault(existingProduct =>
existingProduct.Name.Equals(trimmedName, StringComparison.OrdinalIgnoreCase));
if (product is null)
{
return false;
}
products.Remove(product);
return true;
}
}完整答案:ProductServiceTests.cs
新建 ProductServiceTests.cs。
namespace ProductRules.Tests;
public sealed class ProductServiceTests
{
[Fact]
public void Add_ShouldIncreaseCount_WhenProductIsNew()
{
ProductService service = new();
Product product = new("Keyboard", 199m, 10);
bool added = service.Add(product);
Assert.True(added);
Assert.Single(service.Products);
}
[Fact]
public void Add_ShouldRejectDuplicateName()
{
ProductService service = new();
service.Add(new Product("Keyboard", 199m, 10));
bool added = service.Add(new Product("Keyboard", 299m, 5));
Assert.False(added);
Assert.Single(service.Products);
}
[Fact]
public void Add_ShouldRejectDuplicateName_WhenNameHasExtraSpaces()
{
ProductService service = new();
service.Add(new Product("Keyboard", 199m, 10));
bool added = service.Add(new Product(" Keyboard ", 299m, 5));
Assert.False(added);
Assert.Single(service.Products);
}
[Fact]
public void DeleteByName_ShouldReturnFalse_WhenProductDoesNotExist()
{
ProductService service = new();
service.Add(new Product("Keyboard", 199m, 10));
bool deleted = service.DeleteByName("Mouse");
Assert.False(deleted);
Assert.Single(service.Products);
}
[Fact]
public void DeleteByName_ShouldReturnTrueAndDecreaseCount_WhenProductExists()
{
ProductService service = new();
service.Add(new Product("Keyboard", 199m, 10));
service.Add(new Product("Mouse", 99m, 20));
bool deleted = service.DeleteByName("Keyboard");
Assert.True(deleted);
Assert.Single(service.Products);
Assert.Equal("Mouse", service.Products[0].Name);
}
}正确结果
再次执行:
dotnet test完整结果应该是:
- 商品规则测试通过。
- 商品服务测试通过。
- 总失败数为
0。 - 总通过数为
18。