很多C#开发者在接触测试驱动开发时,最容易卡住的不是单元测试怎么写,而是“异步代码怎么测”、“外部依赖怎么隔离”、“Mock对象什么时候该用”。系列第四篇,我决定集中聊这些进阶场景:从async/await的测试、测试替身的正确姿势,到文件系统、数据库、时间这些外部依赖的隔离方案,最后再说说怎么用测试驱动开发把一个完整的小特性从需求推到落地。
这篇适合已经写过几个基础单元测试、但对TDD在真实项目中怎么落地还缺经验的开发者。我会带完整的可运行示例、参数取舍思路,以及这几年在项目里踩过的坑,争取让你看完就能抄到自己的代码库里去用。
1. 异步代码的TDD:Task、async/await的测试打法
1.1 为什么异步代码会让新手测试抓狂
很多刚把TDD用起来的人,第一次在异步方法上写测试就懵了。明明返回值拿到的Task,断言却是在Task完成之前执行的,裸跑还能过,一上构建服务器就偶发失败。这个问题本质上不是测试框架的锅,而是对async/await的执行模型理解不够。
在.NET里,Task代表一个可能尚未完成的操作。当你调用一个async方法但没有await时,代码会继续往下走,断言会在异步操作完成前执行。所以测试异步代码的第一条铁律就是:永远不要对未等待的Task做断言。你可以await方法后再断言,或者直接断言返回的Task的完成状态,但绝不能在裸调用后立刻断言结果。
看个最简单但很典型的例子,有一个异步方法:
public class OrderService { public async Task<int> GetTotalAsync(int orderId) { await Task.Delay(100); return 100; } }错误测法:
[TestMethod] public void Bad_Test() { var service = new OrderService(); var task = service.GetTotalAsync(1); Assert.AreEqual(100, task.Result); // 可能卡死,也可能通过,看运气 }在单元测试里用.Result或者.Wait()是一个隐患很大的写法。它会把异步操作变成同步阻塞,一旦前面有SynchronizationContext的编排,很可能直接死锁。xUnit里会直接建议你用async Task类型的测试方法,MSTest从VS 2012开始也支持,NUnit同样支持。统一用await来等待,语义清晰,也不会卡线程池。
[TestMethod] public async Task GetTotalAsync_Should_Return_100() { var service = new OrderService(); var total = await service.GetTotalAsync(1); Assert.AreEqual(100, total); }这段代码核心就一个点:在测试方法签名上加上async Task,然后在断言前await被测方法。做法简单,但能规避掉异步测试里80%的随机失败问题。
1.2 实战:测一个带超时控制的异步服务
异步测试最容易出需求细节的地方,其实是超时控制。我们经常写这种服务:给一个任务设置超时,超时了就返回默认值或者抛异常。这种逻辑用TDD来推,思路会非常清晰。
假设需求是这样的:从远程配置中心拉取配置,如果2秒内没拿到,就返回本地缓存的配置,同时记录一次超时日志。用TDD,先写失败测试:
[Fact] public async Task GetConfigAsync_WhenRemoteTimeout_ShouldReturnLocalCache() { var remote = new FakeRemoteConfigProvider { Delay = TimeSpan.FromSeconds(5) }; var localCache = new Dictionary<string, string> { ["feature"] = "cached" }; var service = new ConfigService(remote, localCache); var result = await service.GetConfigAsync("feature", TimeSpan.FromSeconds(1)); Assert.Equal("cached", result); }这里注意,我没把超时写死在服务里,而是作为参数传进方法。这个设计决定是刻意做的:把时间因素做成可注入的依赖,是让异步测试稳定跑起来的前提之一。如果服务内部写死Task.Delay(1000),测试里就要等真实时间,慢不说,偶尔还跟你抢CPU资源。
再看实现,怎么做到超时返回缓存:
public async Task<string> GetConfigAsync(string key, TimeSpan timeout) { var remoteTask = _remote.GetAsync(key); var completedTask = await Task.WhenAny(remoteTask, Task.Delay(timeout)); if (completedTask == remoteTask) { return await remoteTask; } _logger.Warn($"Remote timeout for {key}"); return _localCache[key]; }Task.WhenAny的语义是“谁先完成返回谁”。如果远程Task先完成,就正常返回远程结果;如果Timer先完成,说明超时了,走本地缓存。这是处理超时逻辑的经典模式,不依赖CancellationToken也能把超时切断,但要注意远程Task并不会被取消,它还会继续跑,只是我们不等待它了。
这种测试跑起来非常快,因为FakeRemoteConfigProvider里的延迟是可控的,你甚至可以在测试里设成无限延迟,确保超时路径一定走到。
public class FakeRemoteConfigProvider : IRemoteConfigProvider { public TimeSpan Delay { get; set; } public async Task<string> GetAsync(string key) { await Task.Delay(Delay); return "remote-value"; } }1.3 异步测试的常见误区和坑
第一个坑是ConfigureAwait(false)被滥用。在类库里,async方法内部使用ConfigureAwait(false)可以避免上下文捕获,提升性能,这个没问题。但很多人在测试方法里也写xxx.ConfigureAwait(false),这是个没有意义的行为,因为测试代码里根本不涉及UI线程,SynchronizationContext基本是空的。ConfigureAwait在测试里唯一的作用是帮某些第三方库跑通,别把它当习惯性写法。
第二个坑是断言异步集合。假设服务端返回一个List<Task<int>>,你挨个await和拿到全部结果再断言,结果虽然一致,但等待策略不同。推荐用Task.WhenAll先合并,再统一断言:
var tasks = Enumerable.Range(1, 10).Select(id => service.GetAsync(id)); var results = await Task.WhenAll(tasks); Assert.Equal(10, results.Length); Assert.All(results, r => Assert.True(r > 0));第三个坑是测试超时设置。很多测试框架允许给整个测试用例加超时,比如xUnit的Fact(Timeout = 5000),NUnit是Timeout(5000)。我的建议是能用这种方式兜底就用,因为异步死锁在测试里一旦发生,没有超时保护,整个测试集合会卡在那里,CI直接挂掉。你可能会说“我的测试写得很标准,不可能会死锁”,但一个第三方库的行为在你控制范围之外,有超时保护等于给测试集上了一道保险。
2. 测试替身实战:Mock、Stub、Fake的正确姿势
2.1 四个替身概念的界限
测试替身这个概念源自Gerard Meszaros,他定义了一套词汇表,把替身分成Stub、Mock、Fake、Spy、Dummy五类。很多人只听过Mock,上来就说“我要Mock这个类”,其实大多数情况下你真正需要的是Stub或Fake。
简单区分一下:
- Dummy:只用来填参数,不会被真正调用。比如传一个
null,或者一个空的实现。 - Stub:给指定的输入返回预设的输出,目的是让被测对象走到特定分支,不验证行为。
- Fake:一个轻量级的可用实现,比如内存版数据库、内存版消息队列。逻辑是真的,只是没有外部副作用。
- Mock:不仅提供预设行为,还会验证被测对象是否正确调用了它的方法。
- Spy:包装一个真实对象,记录调用信息,事后断言。和Mock的区别在于,Spy用的是真对象,Mock完全是替身。
在TDD实践中,我跑得最多的是Stub和Mock。Stub用于控制输入,Mock用于验证交互。Fake通常在集成测试里用,Dummy和Spy相对少。
2.2 用Moq做行为验证
Moq是.NET生态里用得最多的Mock库,语法简单,而且它对Lambda表达式的支持很自然。比如我们要测一个“发送通知”的服务:
public interface INotifier { void Send(string message); } public class NotificationService { private readonly INotifier _notifier; public NotificationService(INotifier notifier) { _notifier = notifier; } public void NotifyUser(string userId) { var message = $"Hello {userId}"; _notifier.Send(message); } }对应的Mock验证:
[Fact] public void NotifyUser_Should_SendMessage() { var notifier = new Mock<INotifier>(); var service = new NotificationService(notifier.Object); service.NotifyUser("alice"); notifier.Verify(x => x.Send("Hello alice"), Times.Once); }这个测试验证的不是返回值,而是“用户ID为alice时,通知器必须收到一条内容为Hello alice的消息”。如果NotifyUser里忘记调Send,测试直接红;如果调了两次,也会红。这就是行为验证的威力。
设置Stub则用Setup方法:
var repo = new Mock<IRepository>(); repo.Setup(x => x.GetById(42)).Returns(new Order { Id = 42 });这里不验证GetById是否被调用,只保证调用时返回一个固定的Order。核心原则是:Stub驱动分支,Mock验证交互。把两者混在一起用,测试读起来会非常累。
2.3 替身选型:什么时候用真实现、什么时候用替身
这个问题我在很多Code Review里都会问:“你这里用了Mock,能不能改用真实现?”其实没有一个通用答案,但有两条判断标准。
第一,如果依赖是一个你完全控制的小接口,而且真实现很快,优先用Fake。比如你们项目里的配置接口,直接用内存字典来当Fake,测试跑起来又快又简单,不用维护一堆Mock的Setup表达式。
第二,如果依赖外部I/O,比如发邮件、调用第三方API、访问文件系统,一定用Mock或Stub。这些操作在CI环境里不稳定,你没法保证第三方服务在构建时正好可用。用Mock把外部依赖挡在测试之外,让测试只验证你代码里的决策逻辑。
我见过最痛苦的项目是底层数据库用真库跑单元测试,每个测试跑之前都要初始化Schema,跑完还要清理数据。一改表结构,几十个测试全挂,大家都在补测试脚本而不是写业务。后来切到内存数据库加Repository模式的Fake,测试快了不只一个量级。
2.4 过度Mock的教训
我用过一个自己都觉得荒谬的测试:被测方法里总共10行代码,Mock了9个依赖,每个依赖都Verify了一遍。测试本身绿了,但改任何一行代码它都红,因为Mock的期望被精确匹配到了具体调用顺序和参数值上。这种测试叫“测试与实现耦合”,它不是保护重构,而是阻碍重构。
过度Mock的典型信号:
- 测试里出现大量
Setup和Verify,比被测代码还长。 - 改生产代码时,明明行为没变,测试却一片红。
- 测试只能证明“我的Mock按我预期工作了”,而不是“我的代码按需求工作了”。
正确的方向是让测试聚焦在“被测对象的输出和副作用”,而不是“它内部怎么调用别人”。能不用Mock就不用,依赖关系通过构造函数注入,测试里传入真实的小对象或Fake,才能让测试保持稳定。
3. 隔离外部依赖:文件、数据库、网络、时间
3.1 接缝是隔离的前提
想隔离外部依赖,先要让代码有“接缝”。所谓接缝,就是代码里可以替换实现的位置。在C#里,最典型的接缝是接口和委托。如果一个类直接new了一个FileStream,你没法在测试里换掉文件系统。但如果这个类依赖一个IFileReader接口,测试里就可以传入一个内存版实现。
所以TDD在真实项目中能不能快速落地,很大程度上取决于有没有设计好接缝。构造函数注入就是最简单有效的接缝方案。如果一个类需要的外部依赖超过三四个,就该考虑参数对象或聚合服务了。
3.2 文件系统依赖的测试策略
文件系统是单元测试里最不好模拟的依赖之一。即使你能在测试里创建临时目录,但文件系统的行为在不同操作系统上有细微差别,比如路径分隔符、文件锁、权限问题。用真实文件系统跑单元测试,测试速度还特别慢。
所以我的习惯是抽象一个IFileSystem接口,包含读取、写入、判断存在的方法:
public interface IFileSystem { bool Exists(string path); string ReadAllText(string path); void WriteAllText(string path, string content); }生产环境用真实的FileSystem实现,测试里用内存版:
public class InMemoryFileSystem : IFileSystem { private readonly Dictionary<string, string> _files = new(); public bool Exists(string path) { return _files.ContainsKey(Normalize(path)); } public string ReadAllText(string path) { return _files[Normalize(path)]; } public void WriteAllText(string path, string content) { _files[Normalize(path)] = content; } private string Normalize(string path) => path.Replace('\\', '/').ToLowerInvariant(); }有了这个Fake,测试文件读写逻辑就变成纯内存操作,秒跑完,不会出现“测试删了文件却因为句柄没释放导致CI失败”这种坑。
3.3 数据库与仓储模式的TDD策略
数据库依赖是让TDD推行受阻的头号原因,很多人觉得“TDD嘛,那数据库怎么测”。
我的建议分两层:
第一层,对于纯业务逻辑的单元测试,用Fake实现仓储接口。你定义IRepository<T>,测试里传入内存实现的仓储,业务逻辑就能被完整验证,不依赖数据库环境。
第二层,对于涉及SQL语句、ORM映射、存储过程的代码,做集成测试,用真实数据库,但只测关键路径。集成测试和单元测试分开文件夹、分开运行,不会因为数据库没启动就拖垮整个测试集。
拿EF Core举例,如果你用仓储模式包了一层,单元测试可以很自然地换成EF Core提供的InMemoryprovider或者Sqlite InMemory模式。但EF Core InMemory有个坑:它不走真实的SQL生成,所以不能验证某些数据库特有的约束和索引行为。要用它验证业务查询逻辑没问题,但要验证SQL映射正确,还是得用真实数据库。
3.4 时间依赖的测试:注入时钟
时间依赖是最容易被忽略的外部依赖。很多代码里直接用DateTime.Now,一旦测试里需要固定时间就很麻烦。比如你想测试“用户登录后30天密码过期”,如果代码写死DateTime.Now.AddDays(30),测试跑起来必须等待真实时间,根本不可能。
解决办法是注入时钟。定义一个IClock接口:
public interface IClock { DateTime Now { get; } }生产实现:
public class SystemClock : IClock { public DateTime Now => DateTime.Now; }测试里用一个可控制的Fake:
public class FakeClock : IClock { public DateTime Now { get; set; } = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); }被测类里不再直接用DateTime.Now,而是通过_clock.Now。测试时把FakeClock的Now设成任意值,再把TestContext的时间偏移设定好,就能毫秒级验证“30天过期”的逻辑。
在我待过的项目里,统一用IClock而不是DateTime.Now,带来的最大收益不是测试好写了,而是代码的语义变得更清晰:能看到哪里依赖了“当前时间”。这可帮我们抓出过好几个时差Bug。
3.5 网络调用与重试逻辑的测试
网络调用是另一个典型的“测试杀手”。如果你在单元测试里直接去请求真实的HTTP接口,CI一旦断网或者接口限流,测试就红色一片,而且很难快速定位是代码问题还是网络问题。
处理思路是用HttpClient时把消息处理器替换成可控的Fake。比如测一个带重试逻辑的服务:
public class RetryHttpHandler : DelegatingHandler { private readonly int _maxRetries; public RetryHttpHandler(int maxRetries) { _maxRetries = maxRetries; } protected override async Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { for (int i = 0; i <= _maxRetries; i++) { var response = await base.SendAsync(request, cancellationToken); if (response.IsSuccessStatusCode) { return response; } } return new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError); } }测试这个重试逻辑时,用MockHttpMessageHandler替代真实网络:
public class MockHttpMessageHandler : HttpMessageHandler { private readonly Queue<HttpResponseMessage> _responses; public MockHttpMessageHandler(params HttpResponseMessage[] responses) { _responses = new Queue<HttpResponseMessage>(responses); } protected override Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { var response = _responses.Count > 0 ? _responses.Dequeue() : new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError); return Task.FromResult(response); } }测试代码:
[Fact] public async Task RetryHttpHandler_Should_Retry_On_Failure() { var handler = new MockHttpMessageHandler( new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError), new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError), new HttpResponseMessage(System.Net.HttpStatusCode.OK) ); var retryHandler = new RetryHttpHandler(2) { InnerHandler = handler }; var client = new HttpClient(retryHandler); var response = await client.GetAsync("http://example.com/api/test"); Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); Assert.Equal(3, handler.CallCount); }这个方式把网络请求的行为完全控制住了,不仅可以模拟失败-重试-成功,还能模拟各种异常状态码。测试通篇不碰任何真实网络连接,稳定性和速度都非常好。
4. 从测试到特性:特性驱动开发的工程实践
4.1 TDD和FDD不是二选一
很多人说起TDD,会误以为它只关心单元测试。实际上,我推进TDD落地的时候,通常会和特性驱动开发(Feature-Driven Development,FDD)结合着用。FDD强调“按业务特性去规划开发节奏”,而TDD强调“测试先行驱动代码实现”,两者结合在一起,业务特性和测试用例能形成紧密对应关系,而不是让测试流于形式。
一个特性的开发可以拆成五个步骤:领域建模、特性列表、特性计划、特性设计、特性构建。TDD可以贯穿“特性构建”这个环节,把一个特性再拆分成若干可测试的交付物,每个交付物都从测试开始。
4.2 一个完整的小特性:从需求到红绿重构
我拿之前做过的一个真实小需求来完整走一遍TDD流程。需求是这样的:用户在下单时可以申请优惠券,但一个订单最多只能用一个优惠券,而且优惠券如果不存在、已过期、或已被使用,则不能应用。
这个需求看着简单,但边界条件不少。我用TDD来推:
第一步,写失败测试。最核心的规则是“一个订单只能用一个优惠券”:
[Fact] public void ApplyCoupon_WhenOneCouponApplied_ShouldThrow() { var order = new Order(); var coupon = new Coupon { Code = "SAVE10", Expired = false, Used = false }; order.ApplyCoupon(coupon); var anotherCoupon = new Coupon { Code = "SAVE20", Expired = false, Used = false }; Assert.Throws<InvalidOperationException>(() => order.ApplyCoupon(anotherCoupon)); }运行测试,红。因为Order类还不存在。然后写最小实现:
public class Order { private readonly List<Coupon> _coupons = new(); public void ApplyCoupon(Coupon coupon) { if (_coupons.Count > 0) { throw new InvalidOperationException("Order already has a coupon."); } _coupons.Add(coupon); } }再跑测试,绿。按TDD的想法,第二个测试,过期优惠券不能应用:
[Fact] public void ApplyCoupon_WhenCouponExpired_ShouldThrow() { var order = new Order(); var expiredCoupon = new Coupon { Code = "SAVE10", Expired = true, Used = false }; Assert.Throws<InvalidOperationException>(() => order.ApplyCoupon(expiredCoupon)); }新增失败后,修改实现:
public void ApplyCoupon(Coupon coupon) { if (coupon.Expired) { throw new InvalidOperationException("Coupon expired."); } if (coupon.Used) { throw new InvalidOperationException("Coupon already used."); } if (_coupons.Count > 0) { throw new InvalidOperationException("Order already has a coupon."); } _coupons.Add(coupon); }继续第三个、第四个测试,分别覆盖优惠券已被使用、正常路径等。这个过程的核心价值是:每个测试都对应一个具体的业务规则,测试即文档。后续维护时,哪怕需求描述丢了,看测试也能快速理解一个优惠券在订单里的行为边界。
4.3 遗留代码的测试拯救
现实项目里不太可能每个新特性都从零开始。遇到遗留代码,不写测试直接重构,风险太高;但完全没有测试的情况下,甚至都没有办法用TDD去推进。我的做法是先做“黄金测试”:用一个能代表当前行为的测试套件,把现有行为锁住,然后在这个“安全网”上做重构。
看一个经典例子,一个很长的业务方法里直接new了DateTime.Now:
public bool IsLate(DateTime orderDate) { return (DateTime.Now - orderDate).TotalDays > 30; }没有测试,不敢动。我先写一个当前行为的黄金测试:
[Fact] public void IsLate_WhenOver30Days_ShouldReturnTrue() { var orderDate = DateTime.Now.AddDays(-31); var result = new OrderValidator().IsLate(orderDate); Assert.True(result); }跑绿后,再重构:
public bool IsLate(DateTime orderDate, DateTime now) { return (now - orderDate).TotalDays > 30; }最后把测试改写为:
[Fact] public void IsLate_WhenOver30Days_ShouldReturnTrue() { var validDate = new DateTime(2025, 1, 1); var now = validDate.AddDays(31); var result = new OrderValidator().IsLate(validDate, now); Assert.True(result); }这样的重构一次只挪一个接缝,比直接大改安全很多。遗留代码的TDD不是推翻重来,而是在现有行为和新的可测试性之间找到平衡。
5. 常见问题与排查技巧实录
5.1 测试不稳定(Flaky Test)的排查
最让人头疼的就是“这次挂了,重跑又过了”。这种不稳定测试通常来自几个来源:共享状态、未等待的异步操作、时间依赖、外部系统调用、测试并行冲突。
排除思路我总结成四步:
先看有没有共享静态变量或全局单例。两个测试并发跑时,一个测试修改了静态配置,另一个测试马上就受到影响。解决办法是每个测试独立初始化自己的实例,或者禁用共享上下文。
再看有没有直接操作真实文件、数据库、网络。有就换Fake或Mock。
再看有没有依赖系统时间,比如DateTime.Now.AddSeconds之类。有就注入时钟。
最后用框架的并行开关排查,把并行关闭,如果测试稳定了,就是并发冲突。这时候要在“测试之间共享的东西”上做隔离,而不是简单关掉并行,否则测试集慢得让人崩溃。
5.2 并行测试冲突问题
xUnit天生是并行跑测试集合的,NUnit和MSTest默认不并行,但也可以开启。并行带来的问题很多,比如测试共享同一个临时目录,A测试在写文件时B测试在删文件,目录就废了。解决办法是每个测试使用唯一的路径,比如Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()),用完再清理。千万别在测试里用固定路径,这是个极其隐蔽、但是极其容易踩到的坑。
另一个冲突点是端口冲突。有些集成测试会起一个HttpListener或WebApplication,如果两个测试绑定同一个端口,其中一个必然失败。建议用GetRandomPort()或者直接依赖绑定端口为0来让系统自动分配。
5.3 测试代码的维护成本
测试代码也是代码,它需要被维护。我见过很多项目,测试代码数量越来越多,但执行速度越来越慢,最后大家干脆不跑测试了。要避免这个结局,有两件事值得做:
第一,快慢分离。单元测试必须秒级跑完,集成测试可以分钟级,但不混在同一个命令里。CI的“快速验证”任务只跑单元测试,集成测试单独调度。
第二,测试的命名要读得像故事。比如ApplyCoupon_WhenOneCouponApplied_ShouldThrow,一看就懂。但如果你命名成Test1、Test2,维护成本高到让人崩溃。
5.4 工具链与CI集成建议
如果工作里还在用.NET Framework,推荐用NUnit或MSTest搭配Visual Studio的测试窗口。如果已经切到.NET 6及以上,xUnit是更主流的选择。
Mock库方面,Moq还是社区占有率最高的,但它最近的版本在商业许可上有变化,如果公司对这个有顾虑,可以看看NSubstitute或FakeItEasy,语法更偏自然语言。
覆盖率工具上,coverlet配合ReportGenerator很好用,它能生成HTML报告,能直观看到哪些分支没被覆盖。CI里可以加一条任务:覆盖率低于阈值(比如80%)就构建失败,强制团队保持测试数量。
注意:覆盖率这个指标,过了门槛就好,不用死磕100%。追求100%覆盖率的代价通常是过度的Mock和脆弱的测试,性价比很低。
5.5 常见问题速查表
| 问题 | 典型原因 | 解决方案 |
|---|---|---|
| 异步测试偶发失败 | 没有await异步方法、使用.Result阻塞 | 测试方法改为async Task并await被测方法 |
| 测试并行互相干扰 | 共享静态变量、固定路径、固定端口 | 每个测试独立初始化,使用Guid命名路径,端口随机 |
| 测试跑得慢 | 频繁访问文件/数据库/网络 | 用Fake或Mock隔离外部依赖 |
| 测试和实现强耦合 | 过度Mock、验证调用细节过多 | 减少Verify,尽量用Stub控制输入 |
| 测试环境不稳定 | 依赖真实外部服务 | 用可控的MockHttpMessageHandler拦截网络调用 |
| 改一行代码测试红一片 | 测试过度依赖内部实现 | 聚焦业务行为,而不是内部调用顺序 |
写在最后
我个人在项目里推行TDD时最大的体会是:测试驱动不是为了“有测试”(解决回归问题),而是为了逼你把代码设计成可测试的形态。一个写不出测试的类,大概率是职责不清晰、依赖没有接缝、时间或外部依赖直接硬编码。这些缺陷平时藏在生产代码里不容易爆发,一旦遇到需求变动就会变成技术债。而TDD迫使你在写实现之前先考虑“这个行为怎么被验证”,这个过程本身就能把设计问题暴露出来。
第四篇里说的这些进阶技巧,都是我踩过不少坑后才总结出来的。异步测试先学会不留着Task.Delay在测试里跑真等待,Mock替身先分清楚你要的是验证交互还是控制输入,外部依赖能隔离就隔离,特别是时间,一定要用可注入的时钟。最后再分享一个我自己的小习惯:每周抽一点时间检查一下测试执行时间,如果某个测试超过100毫秒,就停下来问问它“我能不能绕过这个外部依赖”。坚持下来,你的测试集跑得越来越快,你也就越来越愿意在改代码的时候跑一遍测试。