ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C# TDD实战指南:从测试替身到数据库与上位机测试

C# TDD实战指南:从测试替身到数据库与上位机测试 写测试这件事前两篇我们把TDD的基本流程、断言写法、测试命名这些基础都过了一遍。有朋友留言说理论都懂一回到真实项目就卡壳——依赖数据库、依赖文件、依赖外部接口甚至依赖系统时间测试根本写不下去。这期就专门来啃这些硬骨头。这一篇内容比较多我按实际项目中遇到的频率来排先说测试替身怎么选型再说时间、随机数、文件这些“不听话”的依赖怎么处理然后聊数据库测试的几种方案和取舍接着是异步代码测试、遗留代码改造最后补一块很多C#开发者在做的东西——上位机串口程序怎么搞TDD。每一块我都会给出可以直接抄的代码也会把踩过的坑一并交代清楚。1. 单元测试的基石——测试替身到底怎么选1.1 Stub、Mock、Fake、Spy别再傻傻分不清先把这个最基础也最容易被搞混的问题说清楚。测试替身Test Double是个总称下面分了好几种写测试之前必须知道自己要的是哪一种。Stub桩是最简单的它只负责返回预设的数据不关心方法被调用了几次、参数传了什么。打个比方Stub就像一台自动售货机你投币它出货你问它今天卖了多少瓶它不知道。Mock模拟对象就不一样了它不但能返回数据还会验证调用关系——比如某个方法必须被调用一次必须用指定的参数调用不满足就算测试失败。Fake伪造对象是一个有真实实现但用简化的方式工作的对象比如用一个内存版的订单仓库替代数据库里的那张表。Spy间谍则是真实对象的包装它记录调用信息测试结束后你去查这些记录来验证行为。写单元测试时最常用的就是Stub和Mock。我的经验是验证返回值用Stub验证行为用Mock能用Stub就别用Mock。很多新手喜欢把所有的依赖都写成Mock一个测试里mock了三四个对象断言写了一长串——测试确实过了但重构的时候稍微动一下内部实现测试就红一大片。这种脆弱的测试本质上是在测实现细节而不是测行为。记住一个原则好的单元测试应该像黑盒测试不关心内部怎么实现的只关心输入输出和对外行为。1.2 选Mock框架前的三个关键考量.NET生态里Mock框架有好几个Moq、NSubstitute、FakeItEasy还有一个比较特殊的是微软官方出的Fake。我的建议是新项目直接用NSubstitute老项目用Moq也完全没问题两个都值得掌握。为什么我更推荐NSubstitute它的API设计更符合人的直觉。Moq写mock.Setup(x x.GetUser(It.IsAnyint())).Returns(user)NSubstitute写sub.GetUser(Arg.Anyint()).Returns(user)后者明显更接近自然语言。而且在处理out参数、ref参数、事件订阅、异步方法时NSubstitute的语法也更简洁。不过Moq生态更成熟网上资料多遇到问题好查团队如果都熟悉Moq也不一定要换。选框架还有一个考量能不能mock非虚方法。Moq和NSubstitute底层用的都是Castle DynamicProxy只能处理接口和虚方法。如果你的依赖是个具体类的非虚方法这两个框架直接歇菜。这时候要么把依赖改成接口要么用微软的Fake框架——Fake用修改CLR的方式能“篡改”任意方法但配置起来比较复杂而且只在Visual Studio企业版里好用。说句实在话在真实项目里优先用接口而不是具体类作为依赖这本身就是写好可测试代码的第一步。依赖接口不但让测试好写也让业务代码更清晰。后面讲遗留代码改造的时候我会再展开说。2. 那些“不听话”的依赖——时间、随机数、文件系统2.1 时间依赖把DateTime.Now关进笼子里写业务代码时最坑的就是直接调用DateTime.Now。看下面这个例子public class DiscountService { public decimal CalculateDiscount(Order order) { if (DateTime.Now.DayOfWeek DayOfWeek.Friday) { return order.Total * 0.9m; } return order.Total; } }这个类的问题在于周五测试全绿周六跑同样的测试就挂。你没法控制“现在”是什么时候测试就变得不确定。解决思路很简单——把“获取当前时间”这个动作抽象出来。public interface IClock { DateTime Now { get; } } public class SystemClock : IClock { public DateTime Now DateTime.Now; } public class DiscountService { private readonly IClock _clock; public DiscountService(IClock clock) { _clock clock; } public decimal CalculateDiscount(Order order) { if (_clock.Now.DayOfWeek DayOfWeek.Friday) { return order.Total * 0.9m; } return order.Total; } }测试里只需要注入一个固定时间的假时钟[Test] public void CalculateDiscount_OnFriday_ReturnsDiscountedPrice() { var clock Substitute.ForIClock(); clock.Now.Returns(new DateTime(2025, 5, 9, 14, 30, 0)); // 假设当天是周五 var service new DiscountService(clock); var result service.CalculateDiscount(new Order { Total 100m }); Assert.That(result, Is.EqualTo(90m)); }我习惯把这种接口放在和业务代码同层级的目录里而不是放在某个“公共”项目里减少依赖关系。如果你的代码里已经有MediatR或者类似框架也完全可以定义一个IClock注册到容器里用处是一样的。2.2 随机数和Guid可预测才是可测试的随机数和Guid本质上是同一类问题——它们的值不可预测所以测试里没法写“预期值”。先看随机数public class CouponGenerator { private readonly Random _random new Random(); public string GenerateCode() { return $COUPON-{_random.Next(1000, 9999)}; } }这个测试怎么写你总不能断言它等于某个具体值。有些人会写正则去匹配格式这算是个替代方案但如果有业务逻辑依赖这个随机数——比如优惠金额在10到50元之间随机——那正则就搞不定了。更好的做法是把随机源抽象出来public interface IRandom { int Next(int min, int max); } public class SystemRandom : IRandom { private readonly Random _random new Random(); public int Next(int min, int max) _random.Next(min, max); } public class CouponGenerator { private readonly IRandom _random; public CouponGenerator(IRandom random) { _random random; } public string GenerateCode() { return $COUPON-{_random.Next(1000, 9999)}; } }测试里就可以注入一个固定返回1234的假随机源断言生成结果是COUPON-1234。这样不但测了格式还测了随机数和业务逻辑的交互关系。Guid也类似。比较常见的是创建实体的ID直接Guid.NewGuid()这本身没问题但如果后续逻辑依赖这个ID比如在事件消息里引用它你就没法构造一个特定的ID来断言。方案同样是抽象一层IIdGenerator或者干脆把Guid.NewGuid()作为参数传进来。2.3 文件系统临时目录是好朋友文件读写是最常见的外部依赖之一。做文件操作的时候我建议用Path.GetTempPath()和Path.Combine来建临时文件测试结束在TearDown里清理。这样有几个好处不污染项目目录、CI环境里也能跑、不同测试之间互不干扰。[Test] public void ReadFile_WhenFileExists_ReturnsContent() { var tempFile Path.Combine(Path.GetTempPath(), $test_{Guid.NewGuid()}.txt); File.WriteAllText(tempFile, hello world); try { var reader new FileReader(); var content reader.ReadFile(tempFile); Assert.That(content, Is.EqualTo(hello world)); } finally { File.Delete(tempFile); } }注意不要用固定的临时文件名多测试并行跑的时候会互相覆盖。用Guid.NewGuid()拼名字大概率能避开冲突。还有一种更彻底的方案是封装IFileSystem接口把File.ReadAllText、File.Exists、Directory.GetFiles这些静态方法都包进去测试时用内存实现。微软官方有个System.IO.Abstractions库就是干这个的省得自己写。想要彻底就上这个库想要轻量就临时目录我一般根据项目规模来选。3. 数据库相关的测试怎么做才靠谱3.1 EF Core跑测试的三种路线数据库测试是争议最多的一个话题。我的态度是能用内存尽量用内存但内存解决不了的问题就得上真数据库别硬撑。具体到EF Core上有三条路第一条InMemory Provider。它模拟的是EF Core的逻辑模型不走真实数据库引擎。它最大的问题是不执行真实的SQL所以凡是依赖数据库特定行为的功能比如OnModelCreating里配置的级联删除、索引约束测不出来。它适合测仓储层的增删改查逻辑因为速度极快几乎不消耗额外时间。第二条SQLite In-Memory。SQLite的内存模式跑起来很快而且它是真正的SQL数据库能执行SQL、能触发约束。但要注意SQLite和SQL Server的语法并不完全一致EF Core生成的SQL在两边未必一样——比如某些函数、分页方式确实能重现一些SQL Server的行为但也不能100%保证。它最大的价值是用真实SQL执行比InMemory更接近生产环境速度又比重启一个SQL Server快得多。第三条Testcontainers。这个方案最重但它是最接近真实环境的。Testcontainers用Docker起一个SQL Server容器测试跑完容器销毁。它能完整验证SQL兼容性、约束、索引、存储过程……但代价是单测变慢、需要Docker环境、构建服务器上配置多一点。它适合做集成测试不适合做普通单元测试。我的建议是分两层单元测试用InMemory或SQLite集成测试用Testcontainers。如果项目规模不大、没有很复杂的SQL逻辑直接用SQLite也够用如果公司已经有测试数据库或者有Docker基础那就直接上Testcontainers。3.2 一个完整的仓储层测试示例以SQLite内存模式为例写一个仓储测试[TestFixture] public class OrderRepositoryTests { private AppDbContext _dbContext; private OrderRepository _repository; [SetUp] public void SetUp() { var options new DbContextOptionsBuilderAppDbContext() .UseSqlite(DataSource:memory:) .Options; _dbContext new AppDbContext(options); _dbContext.Database.OpenConnection(); _dbContext.Database.EnsureCreated(); _repository new OrderRepository(_dbContext); } [TearDown] public void TearDown() { _dbContext.Dispose(); } [Test] public async Task GetOrderById_WhenOrderExists_ReturnsOrder() { var order new Order { CustomerName 张三, Total 100m }; _dbContext.Orders.Add(order); await _dbContext.SaveChangesAsync(); var result await _repository.GetOrderById(order.Id); Assert.That(result, Is.Not.Null); Assert.That(result.CustomerName, Is.EqualTo(张三)); } }这里有几个细节要注意。OpenConnection()之前必须调用因为内存数据库是跟连接生命周期绑定的连接一关数据就没了。EnsureCreated()负责建表——它跟Migrate()不一样不会走迁移文件的历史而是按当前的模型直接创建。用它来测试干净利落但也意味着它测不到迁移脚本本身的问题。如果你用InMemory Provider测试几乎可以一条一条快速跑完但记得要给TestDbContext加一个特殊的构造函数——比如AppDbContext(DbContextOptionsAppDbContext options)并且把无参构造删掉否则InMemory用不了。我在不少项目里见过有人把DbContext无参构造函数留着结果测试配置的时候编译器报错坑了一堆人。3.3 测试数据怎么生成既简单又可控生成测试数据我的建议是不要硬编码一长串SQL插入也不要每个测试都手动new一堆实体对象。现在主流的做法是用一个简单的Builder模式或者工厂方法。小而够用就够没必要上很重的现成库。public static class TestDataFactory { public static Order CreateOrder(int id 1, string customerName 张三, decimal total 100m) { return new Order { Id id, CustomerName customerName, Total total, Status OrderStatus.Pending }; } }测试里用命名参数覆盖默认值既简洁又灵活。如果项目里已经有Bogus这类的假数据生成库用起来也可以但我个人觉得业务实体这种用默认参数就完全够用了不逼着自己引入额外的依赖。需要强调一点数据库测试的断言要写清理也别忘了。每个测试用例之间数据必须隔离。最简单的方案是每个测试都重新建一个数据库用SetUp/TearDown来创建和销毁。在SQLite内存模式下成本很低但在SQL Server下成本就高了——这就是我说为什么集成测试要谨慎设计有时候不得不用事务回滚或者每次清表的方式。4. 异步代码的测试坑与应对4.1 异步测试的三个基础写法.NET里的异步代码到处都是写不好测试很崩溃。先看三个基础写法[Test] public async Task GetUserAsync_WhenUserExists_ReturnsUser() { var service new UserService(Substitute.ForIUserRepository()); var result await service.GetUserAsync(1); Assert.That(result, Is.Not.Null); } [Test] public void GetUserAsync_WhenUserExists_ReturnsUser_WithResult() { var service new UserService(Substitute.ForIUserRepository()); var result service.GetUserAsync(1).Result; Assert.That(result, Is.Not.Null); } [Test] public async Task GetUserAsync_WhenUserExists_ReturnsUser_WithValueTask() { var user new User { Id 1, Name 张三 }; var repo Substitute.ForIUserRepository(); repo.GetUserAsync(Arg.Anyint()).Returns(user); // 自动适配Task或ValueTask var service new UserService(repo); var result await service.GetUserAsync(1); Assert.That(result.Name, Is.EqualTo(张三)); }第一段是标准的async Task测试最推荐。第二段用.Result同步阻塞异步方法这种方式在测试里偶尔能用但会有死锁风险特别是如果还调用了ConfigureAwait(false)处理不当的场景尽量别用。第三段展示了NSubstitute处理异步方法返回值时的一个细节——不用写Returns(Task.FromResult(user))直接Returns(user)就够了框架会自动包装。4.2 死锁问题别在UI线程上阻塞异步如果你在写WinForm或WPF的测试比如在测试上位机界面逻辑时要特别小心死锁。经典场景是这样的// 在UI线程的上下文里调用 var result service.GetDataAsync().Result; // 可能会死锁原因是异步方法未完成时它会尝试回到原来的SynchronizationContext而UI线程正被.Result阻塞着等它完成回调——两边互等死锁就发生了。解决方式有三种一是测试方法本身用async Task并且await不要在测试里同步阻塞异步调用二是在被测代码里所有内部等待都用ConfigureAwait(false)但这个方法在UI相关逻辑里很难贯彻三是专门写集成测试的时候把SynchronizationContext替换成无锁版本比如用NUnit的默认上下文就没事。在命令行的测试运行器里比如dotnet test默认是没有SynchronizationContext的所以这个问题在纯单元测试层面反而不太会出现。真正危险的是把测试宿主跑在某个UI线程上或者某些测试框架提供了UI消息循环支持的时候。4.3 超时与取消别忘了测试超时时间异步测试里最容易出现“测试挂起”。某些异步操作没有按预期完成或者Mock配置漏了测试就卡在那里不结束。建议是在每个异步测试上设置超时时间比如NUnit里的[Timeout(5000)][Test, Timeout(5000)] public async Task ProcessDataAsync_WhenTimeout_Throws() { var service new DataProcessor(); var task service.ProcessDataAsync(); Assert.ThrowsAsyncTimeoutException(async () await task); }如果Mock的异步方法没有配置返回值用NSubstitute时会返回null然后调用处就NullReferenceException了测试会快速失败——这其实也是好事能让你快点发现问题。稍微提一个常见面试话题Task.Run和Task.FromResult的区别。在Mock异步方法返回值时如果直接用Task.FromResult(user)返回已完成的任务那是完全没问题的但如果是模拟一个真正耗时或调度过的操作用Task.Run就有点误导了。生产代码里能少用Task.Run就少用它只是把一个同步工作丢到线程池去执行并不能解决什么异步问题。5. 遗留代码的可测试性改造5.1 找接缝让老代码变得可测试真实项目里你接手的一般都是已经跑了几年的老代码里面大量依赖关系是“写死”的某个业务类直接用new创建仓储、直接访问静态配置、直接调File.WriteAllText。这时候想在功能改动前先写测试第一个要解决的就是——怎么把依赖取出来。方法其实很简单核心就一个字缝seam。在代码里找那么一个位置可以替换依赖的实现这就是接缝。最常见的做法就是构造函数注入。看这个例子// 改造前 public class OrderProcessor { private readonly EmailService _emailService new EmailService(); public void Process(Order order) { // ...业务处理 _emailService.Send(order.CustomerEmail, 订单已创建); } } // 改造后 public class OrderProcessor { private readonly IEmailService _emailService; public OrderProcessor(IEmailService emailService) { _emailService emailService; } public void Process(Order order) { // ...业务处理 _emailService.Send(order.CustomerEmail, 订单已创建); } }如果EmailService不是接口而且没有被设计成可继承的你还要把一个具体类改成接口或虚方法。这是最常见的一步也是最危险的一步——改完以后要跑一遍原有功能确认没破坏。还有一种接缝是提取方法。如果一个方法里有一大段逻辑里面某一步依赖外部资源你可以把这段提取成internal virtual方法然后在测试里继承这个类、重写那个方法。这个技巧在没法改构造函数时要救命。5.2 特性开关新代码老代码共存TDD面对遗留代码时有个很常见的困境项目太大不能一次改完。这时候有一个公认好用的模式——特性开关Feature Toggle或叫功能开关。思路是新代码和老代码同时存在用一个布尔开关决定走哪条路。public class OrderService { private readonly INewOrderRepository _newRepo; private readonly ILegacyOrderRepository _legacyRepo; private readonly bool _useNewRepo; public OrderService(INewOrderRepository newRepo, ILegacyOrderRepository legacyRepo, bool useNewRepo) { _newRepo newRepo; _legacyRepo legacyRepo; _useNewRepo useNewRepo; } public async Task CreateOrderAsync(Order order) { if (_useNewRepo) { await _newRepo.SaveAsync(order); } else { await _legacyRepo.Save(order); } } }这样做的好处是每次改动控制在一个小范围内改动都有测试保护而且可以随时通过配置来回切换生产环境出问题可以立刻回退。坏处是代码里会有一些临时分支但比起一次性大重构的风险这个代价完全值得。我在实际项目里是把开关放在IConfiguration里动态读取的方便不用改代码就能切换。你如果不想依赖配置系统也可以在构造函数里传bool单元测试反而更好写——直接分别传true和false来测两条路径。5.3 先锁行为再加新功能给遗留代码写第一个测试我倾向用“特征测试”的思路——就是照着现有行为写测试先把行为锁定下来。具体操作是先花半天时间把某个模块的输入输出、边界情况摸清楚写一批测试让它们全部通过然后再开始加新功能。没有特征测试就动手重构就像没有降落伞就跳飞机——事故迟早要发生。我见过太多人信心满满地重构一个老模块自认为把逻辑“等价重写”了结果上线后一堆诡异的bug——原因就是老代码里藏着很多极端的边角逻辑文档里没有注释里也没有只有运行的时候你才知道“哦原来这块还处理过这种情况”。写特征测试的时候有一个小技巧先用真实输入跑一遍老代码拿到真实输出再把输出作为期望值写进测试。这个过程未必能完全确定所有边界但能抓到的先抓到后续再跑回归的时候这些测试就是你的护城河。6. 上位机与串口场景的TDD思路6.1 串口通信怎么抽象成可测试的接口热度和提问频率都很高的一个领域是C#上位机开发。很多人以为TDD只适合纯业务代码串口这种硬件交互就没办法测。其实不然——串口程序完全可以做到大部分逻辑可测试、只有很小一部分不能测。先看最典型的反模式业务逻辑里直接创建SerialPort设置参数然后读写。这导致测试时被真实串口卡住——没有设备就啥也干不了。改进思路是抽象一个ISerialPort接口public interface ISerialPort : IDisposable { event EventHandlerbyte[] DataReceived; void Open(); void Close(); void Write(byte[] data); bool IsOpen { get; } } public class SerialPortAdapter : ISerialPort { private readonly SerialPort _port; public SerialPortAdapter(string portName, int baudRate) { _port new SerialPort(portName, baudRate); _port.DataReceived (sender, args) DataReceived?.Invoke(this, ReadAvailableBytes()); } public event EventHandlerbyte[] DataReceived; public void Open() _port.Open(); public void Close() _port.Close(); public void Write(byte[] data) _port.Write(data, 0, data.Length); public bool IsOpen _port.IsOpen; private byte[] ReadAvailableBytes() { var count _port.BytesToRead; var buffer new byte[count]; _port.Read(buffer, 0, count); return buffer; } }把SerialPort包了一层之后业务层协议解析、指令生成、数据校验依赖的就是ISerialPort接口测试的时候用一个模拟的串口来替换真正硬件。SerialPortAdapter这一层是整个系统里最薄的层它不包含业务逻辑——只要硬件和系统串口API能工作它就不需要测试或者只用手动测一次。6.2 协议解析器的纯逻辑测试不依赖硬件上位机最核心的部分其实是协议解析和指令生成——这部分是纯逻辑完全可以做严格的单元测试。比如一个简单的Modbus协议读取public class ModbusRequestBuilder { public byte[] BuildReadHoldingRegisters(byte slaveAddress, ushort startAddress, ushort quantity) { var frame new Listbyte { slaveAddress, 0x03 }; frame.AddRange(new[] { (byte)(startAddress 8), (byte)(startAddress 0xFF) }); frame.AddRange(new[] { (byte)(quantity 8), (byte)(quantity 0xFF) }); var crc Crc16.Compute(frame.ToArray()); frame.AddRange(new[] { (byte)(crc 0xFF), (byte)(crc 8) }); return frame.ToArray(); } }这个类没有任何硬件依赖可以直接测。需要验证的就是字节序对不对、CRC校验值对不对、长度对不对。我一般会拿一帧真实设备抓包得到的数据当“黄金样本”写进测试里当期望值。再看解析方向。设备返回的数据来了之后要解码同样可以做成纯逻辑类。比如一个温湿度传感器的数据帧第3到第6字节是温度第7到第10字节是湿度带补码。这个解析函数完全可以在没有硬件的情况下用构造好的字节数组做测试。6.3 上位机的模拟设备测试注入数据验证响应高阶玩法是做“虚拟设备”——让模拟串口主动往DataReceived事件里塞数据看看业务层怎么反应。这比人肉拿真实设备调试要快得多也更稳定。用一个在测试中触发事件的假串口public class FakeSerialPort : ISerialPort { public event EventHandlerbyte[] DataReceived; public Listbyte[] WrittenData { get; } new Listbyte[](); public bool IsOpen { get; private set; } public void Open() IsOpen true; public void Close() IsOpen false; public void Write(byte[] data) WrittenData.Add(data); public void SimulateDataReceived(byte[] data) { DataReceived?.Invoke(this, data); } }有了FakeSerialPort就可以验证收到完整一帧数据时解析结果是否正确收到半个帧时业务层是否在缓冲、等待下一段拼接收到错误校验的数据时有没有触发错误处理。这些场景如果在真实设备上复现可能要等很久甚至根本等不到——用模拟器几分钟就能全部验证完。再进一步你甚至可以写一个“环形回环测试”——模拟串口把收到什么指令就回复什么数据像一个简化版的设备响应。这对调试上位机的控制流程特别有效测试里跑一次就能验证完整的“发指令—收响应—解析—更新UI”链路。7. 测试代码的坏味道与重构技巧7.1 断言过度绑定改内部实现就挂单元测试最常见的问题就是断言绑定了实现细节。看一个典型的坏味道processor.Process(order); repo.Received(1).Save(Arg.IsOrder(o o.Id 123));如果这个Save被调用的先后顺序不重要那这个断言就过度绑定了。重构时你只是把Process里的实现从“先Save再Log”改成“先Log再Save”逻辑完全没变测试却红了。正确的做法是只断言最终结果——订单被保存了、日志里有记录而不必关心它们谁先谁后。另一个常见问题是Mock配置了没有被调用的方法。这通常说明Mock被过度配置了——你在模拟对象上定义了一大堆行为结果被测代码只用了其中一两个。多余的配置不但让人困惑还会让人误以为这些行为是必须的。我的习惯是Mock只配置被测方法会用到的成员如果某些Mock成员竟然没被调用说明被测代码和测试预期不一致要么是代码冗余要么是测试设计有问题。7.2 测试代码的可读性像业务代码一样认真对待很多团队的测试代码质量堪忧——变量名全是a、b、c断言条件写了一大堆没注释测试失败了你得读半天才知道它在测什么。测试代码和业务代码一样需要认真对待否则维护成本反而会拖垮迭代速度。我比较推崇简单的结构一个测试方法就三步——准备Arrange、执行Act、断言Assert三段之间用空行隔开不要混在一起。这是经典AAA模式看的人一眼就知道每段在干嘛。[Test] public void Calculate_WhenFriday_AppliesDiscount() { // Arrange var clock Substitute.ForIClock(); clock.Now.Returns(new DateTime(2025, 5, 9)); var service new DiscountService(clock); // Act var result service.Calculate(100m); // Assert Assert.That(result, Is.EqualTo(90m)); }还有一个经常被忽视的原则测试里不要写条件分支。如果测试里出现if通常意味着你一次测试了两个甚至更多个场景应该拆开。测试代码一旦有分支失败时你很难判断是哪个分支出了问题。7.3 测试命名的可读性习惯命名是测试的一门小艺术。你写的每个测试本质上是项目的一份可执行文档。我建议用“方法_场景_预期”这个结构也就是中文里说的“行为_条件_结果”比如CalculateDiscount_OnFriday_Returns90PercentCreateOrder_WhenProductOutOfStock_ThrowsExceptionParseResponse_WhenCrcInvalid_IgnoresFrame这种命名的好处是看名字就能知道行为契约一个测试挂了光看名字就能判断是哪个业务规则出了问题。很多团队习惯用数字编号或者随便起的名字测试失败后根本没法一眼定位排查起来痛苦无比。8. 常见问题与排查技巧实录8.1 测试之间互相影响检查这四处有些测试单个跑全绿一起跑就红这种问题第一反应是测试之间共享了状态。我在各种项目里排查过很多次最常见的原因有以下几类按出现频率排第一静态字段或单例被改了没有还原。比如某个类有一个静态配置项一个测试把它改了另一个测试还在用旧值。解决方案是用[SetUp]和[TearDown]把静态状态统一保存和还原。第二数据库没有隔离。测试A往订单表里插了一条数据测试B统计订单总数结果多了一条。解决方案是每个测试用独立数据库或者在测试之间清空所有表或者用事务包裹每个测试。第三时间依赖。某个测试依赖DateTime.Now跨到凌晨或调了系统时间后行为就变了。解决方案就是前面讲的IClock抽象。第四并发测试。NUnit默认会并行跑不同Fixture里的测试如果它们共享了某个文件或端口就会互相干扰。解决方案是给Fixture加上[NonParallelizable]特性或者让它们使用不同的资源。如果你发现测试本来好好的加了一个新测试后就时不时挂优先怀疑就是共享状态导致的——尤其检查有没有全局静态变量和文件、数据库这类外部资源。8.2 Mock框架返回null引发的连锁故障用NSubstitute时如果方法没有配置返回值默认会返回null或默认值。这在很多情况下是好事因为测试失败得快但有一种情况特别坑——某个Mock方法返回null但被测代码里没有判空直接调用了null对象的成员于是你看到的错误不是“返回值不符合预期”而是莫名其妙的NullReferenceException。排查的时候先看异常堆栈指向的是哪个Mock对象然后在测试里给这个方法加上返回值配置。如果你用的是Moq默认行为其实略有不同——Moq默认返回默认值而不是null对引用类型返回的是默认值不同版本行为有差异很多习惯了Moq的人换到NSubstitute后在这上面栽跟头。8.3 异步测试超时和永远不会完成的任务异步测试里另一个常见问题是Mock的异步方法没有配置返回null然后调用处直接抛NullReferenceException但如果调用处把结果继续传给另一个异步方法就可能因为null导致异步任务永远不完成——测试就一直挂着直到超时。排查思路是给异步测试加上超时特性然后在测试里逐步查看每个异步方法的返回值。这里提供一个诊断技巧在Mock里对异步方法设置返回值时不要偷懒不要省略直接明确返回值repo.GetUserAsync(Arg.Anyint()).Returns(new User { Id 1, Name 张三 });这样就能避免null在异步链路里扩大化。如果被测代码里存在Task.WhenAll之类的聚合逻辑直接返回已完成的任务Task.FromResult一定是最稳妥的。8.4 一个实用的排查速查表下面这张表是我排查测试问题时的固定清单看完基本上能定位九成的问题症状大概率原因建议排查点单个测试绿整体跑红共享状态/全局变量静态字段、单例、数据库、文件测试跑得特别慢真实I/O、数据库、网络调用是否用了Mock、是否访问真实数据库改业务代码后大量测试挂断言绑定了实现细节是否过度用了Received()、是否绑定了调用顺序测试偶发挂、不稳定并发、时间和随机数未控制IClock是否抽象、是否有并发测试干扰异步测试挂起直到超时Mock返回null或Task未完成异步方法是否配置了返回值用Mock的类方法报错具体类的非虚方法无法被Mock是否改成了接口或虚方法测试前没问题重构后挂了缺少特征测试是否先锁定了老代码的行为再动手8.5 测试运行速度太慢怎么办测试慢是一个很现实的问题。如果dotnet test跑一次要十几分钟没人愿意频繁跑TDD就废了。解决办法是分层单元测试层不访问数据库、不访问文件、不访问网络只测内存中的逻辑。这层要快几千个测试应当在一两分钟内跑完。如果某天你发现单元测试跑得慢了第一个念头应该是“哪个测试偷偷访问了外部资源”。集成测试层可以访问真实数据库、真实HTTP接口但数量不要太多挑核心链路的几个场景来做。可以用Testcontainers或专门的测试环境。这层跑得慢没关系可以放到CI流程里每次提PR自动跑。UI测试层这层最贵我用得最少。如果是WPF/WinForm上位机UI自动化测试通常只覆盖最核心的主流程而且基本放在发版前回归。如果你发现测试全部跑起来太慢先别急着优化测试代码——先看看是不是有测试把生产环境的服务也带起来了比如某个Fixture里启动了Kestrel监听真实端口。这种“大而全”的测试设计是性能杀手。9. 测试金字塔和CI/CD的落地9.1 怎么分层单元、集成、端到端测试金字塔是个老概念但放到今天依然管用底层是大量的单元测试中间是适量的集成测试顶层是少量的端到端测试。写测试之前想清楚这个测试属于哪一层能避免很多错误。用订单系统举个例子单元测试DiscountService.CalculateDiscount的纯逻辑不依赖数据库和网络毫秒级。集成测试OrderRepository.GetOrderById要用SQLite或TestcontainersOrderService.CreateOrderAsync可能依赖仓储、消息队列可以用容器跑。端到端测试整个系统启动起来模拟用户从页面下单到确认收货的完整流程。我见过很多测试做反了——全项目就几个测试还都是端到端重流程一旦某个组件挂了所有测试全红新手看不出问题在哪老手也头疼。金字塔的意义就在于每一层测试都有明确的职责上层挂了不是下层的问题而是用下层测试快速定位。9.2 CI流水线里测试怎么跑、怎么防抖现在dotnet test已经足够好用了但有两个问题在CI里必须处理一是测试的稳定性二是测试的隔离。稳定性上如果集成测试用了TestcontainersCI环境必须有Docker如果没有Docker可以在CI配置里提前装好SQL Server服务或者把集成测试拆到独立的阶段让它们在特定的Runner上跑。说到底CI跑测试的目的是快速反馈不是证明测试一定能过。隔离上同一时间只跑一个构建能省掉很多麻烦但成本高更好的做法是尽量让你自己的测试不依赖外部环境的共享状态——数据库用容器重新建、端口不要写死、文件用临时目录。这样即使并行的多个构建同时跑互不影响。有一个小技巧值得推荐在CI里把测试结果xml文件统一收集起来用JaCoCo或者Coverlet统计覆盖率跑完自动生成报告。覆盖率不一定要死磕100%但看趋势很重要——如果上一周是75%这一周发布新功能后掉了10个百分点那就有问题了说明新功能没写测试。9.3 团队落地TDD的实操建议最后聊一聊从个人会写测试到团队真正跑起来TDD的落地问题这部分是我自己带团队过程中踩出来的经验很值得分享。第一先选定一个模块试点。别指望一上来全团队都遵循严格的“红-绿-重构”节奏。挑一个业务难度适中、改动频繁的模块让大家在这个模块上练习。等习惯了节奏自动就会扩散到其他模块。第二Code Review里要扣测试。不是看测试数量而是看测试质量和断言合理性。我review别人代码时会重点看这个测试在验证行为还是验证实现细节如果重构以后这个测试会挂就说明它写得太细了。第三别鼓励100%覆盖率。这会让团队成员为了凑指标写出一堆没有意义的测试比如给Getter/Setter写测试。更合理的做法是定义“核心模块覆盖率不低于80%”非核心模块降一点没问题。第四每周留时间维护测试。测试也是代码也会腐化。你不维护它它就会变成团队的负担而不是资产。定期清理冗余测试、统一测试风格比写新功能更需要自律。10. 最后一件事测试代码也是要评审的这一篇从测试替身讲到数据库、异步、遗留代码、上位机最后又回到CI和团队落地覆盖了TDD在真实项目里最常遇到的几个关卡。但怎么说呢方法论归方法论真正决定TDD成败的还是你有没有把测试当成一套需要长期维护的资产而不是写完业务代码之后应付差事补上去的“装饰品”。我在实际项目里最深的一个体会是测试代码比业务代码更需要被评审因为它会活很久而且它决定了团队改代码时有没有安全感。我见过太多团队业务代码写得漂漂亮亮测试代码却乱七八糟——描述不清、断言混乱、跑起来还慢。这种测试不但没有保护作用反而成为新人的负担最后被大家集体放弃。如果你只记住一句话我想是这句**测试代码是项目的第二份说明书它应当像业务代码一样被认真对待。**希望这篇能对你有用去写点好测试吧。
返回列表