Skip to Content
Week 12Day 3 - 测试补齐

Day 3 - 测试补齐

建议用时:180-210 分钟

你将学会什么

  • 用测试代码验证业务规则
  • 区分正常测试、边界测试、异常测试、回归测试
  • 用 xUnit 写 FactTheory
  • Assert.EqualAssert.ThrowsAssert.Contains 判断结果是否正确

本页开始写真实测试项目。测试不是为了多写代码,而是为了把“这个规则到底对不对”固定下来。以后改代码时,只要跑测试,就能知道有没有把旧功能改坏。

本页固定顺序

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

今天只抓住 3 件事

  1. 测试不是测界面好不好看,而是固定业务规则。
  2. 一个测试只验证一个明确场景,失败时才能快速定位。
  3. 正常、边界、异常、回归这四类场景都要覆盖。

学习衔接

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

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

这一部分只读,不敲代码。

为什么要写测试

程序越来越大以后,靠眼睛检查会变得不可靠。

比如商品规则里有一句“价格必须大于 0”。今天你写对了,三天后你改保存逻辑时,可能无意中让 0 也能保存。界面不一定马上暴露问题,但测试会立刻失败。

测试的核心作用是:

  • 把规则写死,避免以后被改坏。
  • 把边界情况列出来,减少漏处理。
  • 出问题时快速定位,是价格规则坏了,还是库存规则坏了。
  • 交付前可以一键跑完整检查。

测试的 4 类场景

类型验证什么商品例子
正常测试合法输入能得到正确结果199 * 3 = 597
边界测试刚好卡在边界的输入库存 0 是允许的
异常测试非法输入会被拒绝价格 0 会抛异常
回归测试旧功能不能被新修改破坏改价格失败后旧价格还在

xUnit 里的常用名词

名词直接解释例子
Fact一个固定场景的测试测试名称为空会失败
Theory同一个测试跑多组数据测试 0-1 都不是合法价格
InlineDataTheory 传入一组数据[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