C#工控项目TDD实战:从硬件依赖到可测试性设计
2026/9/7 19:00:16 网站建设 项目流程

我一直在做C#和.NET相关的桌面与工控项目,这几年Test-Driven Development(TDD)这个词被反复提及,但真正到了项目现场,能坚持写测试、还能让测试帮上忙的团队其实不多。这个系列前面两篇聊了TDD的基础入门和第一个测试用例怎么落地,到了第三篇,我想聊点更贴近实战的东西——当测试撞上硬件通信、UI线程、异步任务、十万行CSV这类“非理想对象”时,到底该怎么继续推进。

这一篇适合两类人:一类是已经在用TDD,但突然发现自己负责的模块很难写测试,正卡在“代码可测性”的困境里;另一类是做上位机、工业通信、数据处理,想引入单元测试却不知道从哪下手的.NET工程师。当你把某段逻辑从具体硬件、具体UI中抽离出来,你会发现TDD的真正价值不在“好看的绿色勾”,而是逼你把系统边界理清楚。

1. 教科书里的TDD到了工控项目,为什么经常失效

我个人的体会是,TDD没失效,失效的是“只按书上的例子走路”的预期。书里教你用一个计算器类或购物车类入门,断言一串输入产生一串输出,干净利落。但现实中的C#项目往往长这样:一个WinForms窗口里直接new了一个SerialPort,扫码枪一旦触发,串口事件里直接扒字符串,弹窗提示,顺手还写了一堆日志;另一个后台采集线程每隔200毫秒读一次仪表数据,拿到的值直接往ListView里塞,界面卡到拖不动。这种代码别说写测试,连看懂都要花半天。

1.1 我接手一个扫码枪项目时的真实起点

去年我接手过一个分拣线配套的上位机,业务本身不复杂:扫码枪扫到条码后,上位机判断当前工位是否允许放行,不合法就报警并记录。但代码的耦合程度很“感人”——扫码枪事件直接在窗体里处理,条码判断逻辑里直接调用数据库、直接写日志,还顺手刷新了一个DataGridView。我尝试给“条码是否允许放行”写测试时发现一个尴尬的事实:判断逻辑依赖数据库,不连库就跑不了。

这逼着我后退一步思考:判断逻辑本身需要数据库吗?不需要。它只需要知道“当前工位有哪些条码被允许放行”这个事实。数据库只是事实来源之一,真正需要测试的,是把“条码是否在允许集合里”这个判断提出来。我先写测试,再让测试告诉我该干什么,最终把判断逻辑从窗体事件里挖了出去。

public class PassRule { private readonly HashSet<string> _allowed = new(StringComparer.OrdinalIgnoreCase); public PassRule(IEnumerable<string> allowedCodes) { foreach (var code in allowedCodes) { _allowed.Add(code); } } public bool CanPass(string barcode) { return !string.IsNullOrWhiteSpace(barcode) && _allowed.Contains(barcode.Trim()); } }

对应的测试直接、稳定、不用连库:

public class PassRuleTests { [Theory] [InlineData("123456789", true)] [InlineData("abcdef", false)] [InlineData("", false)] [InlineData(null, false)] public void CanPass_ShouldMatchExpectedResult(string barcode, bool expected) { var rule = new PassRule(new[] { "123456789", "ABCDEF" }); var result = rule.CanPass(barcode); Assert.Equal(expected, result); } }

这是我反复强调的一步:与其抱怨“老板不让用TDD”,不如先把最容易测试的“业务规则类”切成小块。判断条码、计算数量、校验格式,这些永远比串口数据收发更适合先迈出第一步。

1.2 教科书和工程实践之间的三个断层

这些年在多个项目里往返,我总结了教科书TDD和工程现实之间最常见的三个断层:

  • 断层一:教材里的对象是纯函数,现实里是硬件和线程。串口、Socket、PLC、数据库这些外部资源,天然不在测试环境的控制范围内。测试要么无声失败,要么报一堆无关异常。
  • 断层二:教材强调每步“红-绿-重构”,但碰到遗留代码时,连“红”都开始不了。因为方法体里塞满了依赖,测试根本创建不出被测对象。这时第一个重构对象不是业务代码,而是“让业务代码变得可被测试”。
  • 断层三:教材假设你有干净的分层,现实里是UI逻辑和业务逻辑的混合体。新手会特别沮丧:我明明写在窗体里的代码,怎么才能搬出去?我的结论是,不要急着一次搬完,先搬被UI缠住的功能点,从“能测一部分”逐步扩到“能测很多部分”。

这三个断层不是TDD本身的问题,而是反映了TDD对设计的要求。你不缺写测试的意愿,缺的是一次为了可测试而进行的结构性调整。

2. 让硬件和大外部依赖“让出控制权”——接口抽象与依赖注入

当程序里有个new SerialPort(),测试基本就没法写了。而“把new移开”的操作,说起来简单,做起来需要一点章法。我一般按三个标准来抽接口:

  1. 边界性:凡是直接与外部世界交互的类,就值得抽象。比如串口、TCP客户端、数据库上下文、文件系统、系统时间。
  2. 不确定性:行为结果不可控的,比如随机数、网络异常、硬件掉线。
  3. :运行很慢的对象,比如大表查询、大量Excel解析。测试跑得慢就容易被人跳过。

2.1 先画一条“业务与设备的分界线”

拿Scan枪来说,设备厂商一般给SDK,你把它的API封装到一个IScanner接口下,业务代码只跟接口打交道。真实的SDK类实现这个接口,测试时用一个Fake实现或Mock对象替换。这条分界线画得好,不仅代码可测性提升,未来换设备型号时也受益。

public interface IScanner { event EventHandler<string> CodeScanned; event EventHandler<ScannerErrorEventArgs> ErrorOccurred; void Start(); void Stop(); }

业务层面的处理器开始只依赖接口:

public class ScanSession { private readonly IScanner _scanner; private readonly PassRule _rule; private readonly ILogger _logger; public ScanSession(IScanner scanner, PassRule rule, ILogger logger) { _scanner = scanner; _rule = rule; _logger = logger; _scanner.CodeScanned += OnCodeScanned; } private void OnCodeScanned(object? sender, string code) { var allowed = _rule.CanPass(code); _logger.Info($"条码 {code} 判定结果: {(allowed ? "放行" : "拦截")}"); if (!allowed) { RaiseBlocked(code); } } public event EventHandler<string>? CodeBlocked; private void RaiseBlocked(string code) { CodeBlocked?.Invoke(this, code); } }

现在的测试可以模拟扫码枪的每一次触发,不用真的开机:

public class ScanSessionTests { [Fact] public void ScanBlockedCode_ShouldRaiseBlockedEvent() { var scanner = new FakeScanner(); var rule = new PassRule(new[] { "ABC123" }); var logger = new FakeLogger(); var session = new ScanSession(scanner, rule, logger); string? blockedCode = null; session.CodeBlocked += (_, code) => blockedCode = code; scanner.SimulateScan("XYZ789"); Assert.Equal("XYZ789", blockedCode); Assert.Contains(logger.Messages, m => m.Contains("拦截")); } } public class FakeScanner : IScanner { public event EventHandler<string>? CodeScanned; public event EventHandler<ScannerErrorEventArgs>? ErrorOccurred; public void Start() { } public void Stop() { } public void SimulateScan(string code) { CodeScanned?.Invoke(this, code); } }

这里没有用Moq等框架,直接写了一个Fake类。理由很简单:对于事件多、交互次数多的硬件接口,手写的Fake更直观,谁触发、什么时候触发,一目了然。如果你偏爱Mock框架,Moq或NSubstitute在单个方法、单一返回值的场景下确实更简洁。

2.2 Fake、Mock、Stub到底是什么关系

经常有人在群里问“Fake和Mock是不是一个东西”,我一般用一张表说清楚:

术语核心意图典型使用场景
Stub为被测代码提供预设的返回值GetTemperature()固定返回25.0
Mock验证被测代码是否按正确方式调用依赖断言_logger.Info()是否被调用
Fake提供一个简化但可用的实现内存版仓储代替真实数据库
Spy记录调用信息,之后校验记录发送了多少条数据

这套术语不需要死记,但团队沟通时统一用词,能省去很多误解。写测试时更重要的是理解:我们不是为了让系统“看起来在跑”,而是为了把“被测代码自身的行为”和“它周围环境的行为”分开验证。

2.3 抽象系统时间的细节

工控里经常要判断“这个条码是否在有效期”“设备是否已超时未响应”。如果代码里直接用DateTime.Now,每次测试跑到不同时间点,结果就飘忽不定。我的做法是封装一个ITimeProvider

public interface ITimeProvider { DateTime Now { get; } } public sealed class SystemTimeProvider : ITimeProvider { public DateTime Now => DateTime.Now; }

测试里换成固定时间戳:

var time = new FixedTimeProvider(new DateTime(2024, 6, 1, 10, 0, 0));

这类接口就是教科书里常说的“可测试性设计”,改造成本极低,收益却立竿见影。别觉得抽象一下很麻烦,往往是“等等,反正就是一次赋值”的想法积累出来一个不可测的泥潭。

3. 异步和UI线程里的测试,别再把测试写到“程序假死”

C#项目里绕不开async/await。而UI线程上的异步,是TDD新手最容易撞出事故的地方。最常见的是在测试里写asyncTask.Result.Wait(),然后整个测试连卡带锁,直到超时。

3.1 为什么.Result和.Wait()能让测试卡住

核心原因是UI测试线程有一个SynchronizationContextawait默认要回到这个上下文继续执行。如果测试方法先阻塞了UI线程(也就是当前上下文所在线程),那么await的后半段永远等不到线程空出来,形成互相等待。这不是async的问题,是我们把异步代码当同步代码用造成的。

正确做法是测试方法本身也用async方法并await

[Fact] public async Task GetDataAsync_ShouldReturnExpectedCount() { var service = new DataService(); var result = await service.GetDataAsync(); Assert.Equal(10, result.Count); }

还需要留意:如果被测库里面有“假异步”——即方法用了async但内部没有任何真正的异步操作,出现Task.RunThread.Sleep之类写法,也会拖累测试速度。工控领域的采集模块里时不时能看到这类代码,尤其和第三方SDK混在一起时。一旦发现测试特别慢但也不知道慢在哪,我会先审查被测代码里的Thread.Sleep,再考虑是不是真的存在IO。

3.2 数据循环采集和UI刷新卡顿的测试设计

热搜词里有一条是“C# 循环数据采集和UI刷新卡顿”。这在采集程序里很典型:后台线程不停拿数据,再用BeginInvokeDispatcher.Invoke往UI控件塞,塞多了,UI排大队,窗口肉眼可见地卡。要测这个逻辑,还得先把UI控件的依赖摘干净。我把刷新逻辑收敛成一个“采集-批量分发”的类:

public class ChannelDataCollector { private readonly IDataGateway _gateway; private readonly Action<IReadOnlyList<Reading>> _onBatchReady; private readonly TimeSpan _interval; private readonly CancellationTokenSource _cts = new(); public ChannelDataCollector( IDataGateway gateway, Action<IReadOnlyList<Reading>> onBatchReady, TimeSpan interval) { _gateway = gateway; _onBatchReady = onBatchReady; _interval = interval; } public async Task RunAsync(CancellationToken token) { var buffer = new List<Reading>(100); while (!token.IsCancellationRequested) { var data = await _gateway.ReadAsync(token); buffer.Add(data); if (buffer.Count >= 50) { _onBatchReady(buffer.ToArray()); buffer.Clear(); } await Task.Delay(_interval, token); } } }

这里刻意把UI刷新降级成了一个Action<IReadOnlyList<Reading>>回调,测试时根本不关心它是不是真的往ListView里塞数据,只关心批处理行为。

[Fact] public async Task RunAsync_WhenEnoughData_ShouldFlushInBatches() { var gateway = new FakeGateway(Enumerable.Range(1, 120).Select(x => new Reading(x))); var batches = new List<int>(); var collector = new ChannelDataCollector( gateway, batch => batches.Add(batch.Count), TimeSpan.FromMilliseconds(5)); using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); await collector.RunAsync(cts.Token); Assert.Contains(50, batches); Assert.Contains(20, batches); // 最后不足一批的数据也会被冲刷出来 }

这种设计把“采集频率”“批大小”“UI刷新调度”三者分开,每一块都能独立测试。真实项目里如果UI已经有了ListView之类的具体控件,我一般不会直接把它传进来,而是传一个“批量刷新显示模型”的委托,让窗体自己决定怎么渲染。

3.3 用CancellationToken控制“缓慢的外部设备”

工控环境中设备超时很常见。测试里凡是涉及“读设备可能超时”的逻辑,都要有CancellationToken。这不仅能让代码更健壮,还能让测试快速验证“设备很慢”时的行为:

[Fact] public async Task ReadAsync_WhenTokenCancelled_ShouldThrowOperationCanceled() { var gateway = new SlowGateway(TimeSpan.FromMinutes(1)); using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(10)); await Assert.ThrowsAnyAsync<OperationCanceledException>( () => gateway.ReadAsync(cts.Token)); }

“慢设备”的Fake可以由一个Task.Delaytoken简单构造,核心是让测试清晰模拟极端情况,而不是真的等一分钟。

4. 测试数据构造:从CSV十万行到Modbus报文

很多C#项目绕不开数据解析:Excel、CSV、TCP字节流。最困扰新手的不是断言怎么写,而是“要测2MB、十万行数据时,总不能每次测试都造一个真实文件”。我的答案是分层处理:单元测试用内存数据和少量样例文件,集成测试才用全量数据。

4.1 用Builder模式组装领域对象

工控里常见一个DeviceReading类,可能有几十个字段,手写初始化能写到手酸。测试数据构造器(Builder)让代码意图更清晰:

public class DeviceReadingBuilder { private string _deviceId = "DEV-001"; private double _value = 24.5; private DateTime _timestamp = new(2024, 6, 1, 8, 0, 0); private int _status = 1; public DeviceReadingBuilder WithValue(double value) { _value = value; return this; } public DeviceReadingBuilder WithDeviceId(string deviceId) { _deviceId = deviceId; return this; } public DeviceReadingBuilder WithStatus(int status) { _status = status; return this; } public DeviceReading Build() { return new DeviceReading { DeviceId = _deviceId, Value = _value, Timestamp = _timestamp, Status = _status }; } }

测试里只需要描述差异部分:

var normal = new DeviceReadingBuilder().Build(); var alarm = new DeviceReadingBuilder().WithStatus(2).WithValue(88.5).Build();

这套模式在领域模型字段多、业务重构造的场合非常有效。我发现它还有一个隐性的好处:当Builder的默认值一旦写错,多个相关测试会一起报警,迫使你维护一份“合理默认数据”,比每个测试各写各的初始化可靠得多。

4.2 十万行CSV测试的取舍策略

CSV大文件解析,我的测试策略分三层:

  • 第一层:小样本字符串测格式。直接传十几行的字符串数组,覆盖表头、逗号转义、空行、换行符、引号包裹等细节。
  • 第二层:中等文件测行数和抽样值。在TestData目录里放一个几百KB的CSV,读取后断言行数、列数、随机抽取几条记录校验解析结果。
  • 第三层:真正的十万行场景。不一定每次都跑,可以用[Trait("Category", "Stress")][Fact(Skip = "手动执行")]标记,在CI主流程里跳过,在固定的性能测试job里跑。

重要的是不要在单元测试里反复生成大文件再删除,文件IO反而成了测试瓶颈。用MemoryStream更合适:

[Fact] public async Task ParseCsvAsync_ShouldParseAllRows() { var csv = GenerateCsv(rows: 100000); using var stream = new MemoryStream(Encoding.UTF8.GetBytes(csv)); var parser = new CsvParser(); var rows = await parser.ParseAsync(stream, CancellationToken.None); Assert.Equal(100000, rows.Count); }

对于CSV这种数据密集场景,断言不用“一条条全对齐”,而是抽取几个“哨兵行”做值断言,同时用行数保证数据没有丢。这样测试跑得快,也保留了较高容错度。

4.3 Modbus数据包检索的测试切入

热搜词里有一条“dotnet SequenceReader modbus数据包检索”,其实是把System.Buffers.SequenceReader<byte>用于Modbus协议帧解析。这地方非常适合TDD,因为它本质是纯函数式的解析:一个字节序列进来,一个帧或异常出去。

处理TCP流时最常见的坑是“粘包”和“半包”。Modbus TCP请求头固定为6字节MBAP头(事务标识符、协议标识符、长度)+功能码+数据。收到TCP载荷时,需要先从缓冲区里截出完整帧,滤掉粘在一起的多个帧,并缓存残帧等待下一次数据到达。写这类解析器时,我把“从一段字节中查找并切出一个完整Modbus帧”做成独立的静态方法,用测试把边界情况钉死:

public static class ModbusFrameParser { public static bool TryExtractFrame( ReadOnlySpan<byte> buffer, out ModbusFrame frame, out int consumedBytes) { frame = default; consumedBytes = 0; if (buffer.Length < 7) { return false; // MBAP头至少6字节 + 1字节功能码 } int length = (buffer[4] << 8) | buffer[5]; int totalLength = 6 + length; if (buffer.Length < totalLength) { return false; // 当前缓冲区不完整(半包) } frame = new ModbusFrame( TransactionId: (ushort)((buffer[0] << 8) | buffer[1]), ProtocolId: (ushort)((buffer[2] << 8) | buffer[3]), Length: length, Payload: buffer.Slice(6, totalLength - 6).ToArray()); consumedBytes = totalLength; return true; } }

对应的测试可以把“完整一帧”“两帧粘在一起”“上半帧”“中间带垃圾前缀的帧”全部覆盖:

public class ModbusFrameParserTests { [Fact] public void TryExtractFrame_SingleCompleteFrame_ShouldSucceed() { var bytes = new byte[] { 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x03, 0x00 }; var ok = ModbusFrameParser.TryExtractFrame(bytes, out var frame, out var consumed); Assert.True(ok); Assert.Equal(0x0001, frame.TransactionId); Assert.Equal(2, consumed); } [Fact] public void TryExtractFrame_TwoFramesTogether_ShouldExtractFirstAndConsumeItsBytes() { var first = new byte[] { 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x03, 0x00 }; var second = new byte[] { 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x03 }; var bytes = first.Concat(second).ToArray(); var ok = ModbusFrameParser.TryExtractFrame(bytes, out var frame, out var consumed); Assert.True(ok); Assert.Equal(0x0001, frame.TransactionId); Assert.Equal(first.Length, consumed); } [Fact] public void TryExtractFrame_WhenHalfPacketArrives_ShouldReturnFalse() { var half = new byte[] { 0x00, 0x01, 0x00, 0x00, 0x00 }; var ok = ModbusFrameParser.TryExtractFrame(half, out _, out _); Assert.False(ok); } }

我这里没有用SequenceReader<byte>写完整示例,但思路是一样的:把“帧解析”从“网络接收”里剥离出来,解析器只认字节数组,接收器只负责把字节喂进来。测试一个字节数组转换器,比测试一个开着真实TCP端口的读取器要简单得多。

4.4 注意二进制数据的字节序问题

Modbus和很多工业协议都分大端小端,C#里BitConverter默认跟随操作系统,通常是x86/x64的小端,和协议要求的可能相反。测试是抓住字节序错误最好的地方。不要只断言“帧长度对”,还要断言帧内具体字段的字节序。一个常见的做法是先在测试里精确定义好“期望收到的字节序列”,然后让你的组装方法正好产出一模一样的字节。

5. 不连真实设备,也能写集成测试:本地TCP服务模拟

单元测试把“解析逻辑”和“通信逻辑”分开之后,你往往还想验证整条链路:收到字节流、组装帧、触发业务回调、再回应答帧。真实设备不一定随时在手上,这时候在测试进程里启动一个TcpListener,让被测代码用真实Socket去连,是性价比很高的集成测试方案。

5.1 在测试里启动一个“假设备”服务

我写过一个小工具类,专门在测试里临时监听随机端口:

public sealed class TcpTestServer : IDisposable { private readonly TcpListener _listener; public int Port { get; } public List<byte[]> ReceivedFrames { get; } = new(); public TcpTestServer() { _listener = new TcpListener(IPAddress.Loopback, 0); _listener.Start(); Port = ((IPEndPoint)_listener.LocalEndpoint).Port; } public async Task StartAsync(CancellationToken token) { while (!token.IsCancellationRequested) { var client = await _listener.AcceptTcpClientAsync(token); _ = Task.Run(() => HandleClientAsync(client, token), token); } } private async Task HandleClientAsync(TcpClient client, CancellationToken token) { using var stream = client.GetStream(); var buffer = new byte[1024]; while (!token.IsCancellationRequested) { var read = await stream.ReadAsync(buffer, token); if (read == 0) { break; } var chunk = buffer[..read]; ReceivedFrames.Add(chunk.ToArray()); // 回一个固定应答 var resp = new byte[] { 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x03, 0x00 }; await stream.WriteAsync(resp, token); } } public void Dispose() { _listener.Stop(); } }

端口用0让操作系统自动分配,避免几个测试并行时端口冲突。测试里,被测代码作为TCP客户端连到127.0.0.1:Port,这就是一次本地环回的真实Socket通信,不依赖任何外网或设备。

5.2 网络测试的“三层断言”

我一般按三层来断言网络交互测试:

  1. 断言收到了什么ReceivedFrames里能看见客户端发来的原始字节。
  2. 断言回包被正确处理:被测代码是否解析了服务端发的响应,是否进入了正常流程。
  3. 断言断线异常:服务端关闭连接后,客户端是否重连或上报错误。

不要一股脑地只断言“没有异常”,网络协议最怕“看起来通了但协议细节不对”。“收到了什么”的断言能帮你抓住报文内容错误。

5.3 哪些网络场景不该写进单测

本地TcpListener已经很有价值,但也不要什么网络相关都做成集成测试:

  • 涉及公网、第三方Web API的,测试里该Mock就Mock,不要依赖外网。
  • 涉及硬件设备私有协议的,能模拟就模拟,模拟不了的用契约测试或挑选少量手测场景。
  • 性能类测试和单元测试分开,放在一条单独的执行路径里。

保持“快”这个原则很重要。集成测试一旦动不动跑几十秒,团队就会想跳过它。

6. 把TDD固化进团队流程:CI与覆盖率的落地细节

个人写测试和团队都写测试,是完全不同的两件事。到了这一步,重点已经不只是“怎么写测试”,而是“如何让测试一直在跑,并且一直在保护系统”。

6.1dotnet test在CI里的标准姿势

一个典型的.NET项目,CI里跑的其实就是几行命令:

dotnet restore dotnet build --configuration Release --no-restore dotnet test --configuration Release --no-build --logger "trx;LogFileName=testresults.trx"

关键点是--no-build:先在build阶段统一编译一次,test阶段避免重复编译,能省不少时间。解决方案里有多个测试项目时,也可以指定--filter只跑想跑的类别。如果某些测试是压力测试或依赖真实数据库的集成测试,不要混在主流水线里。我的习惯是给它们打上[Trait("Category", "Integration")][Trait("Category", "Stress")],然后CI里默认排除:

dotnet test --configuration Release --filter "Category!=Integration&Category!=Stress"

6.2 覆盖率:别只盯着“百分比”

.NET平台通常用coverlet收集覆盖率。在测试项目里装一个coverlet.collector,然后执行:

dotnet test --collect:"XPlat Code Coverage" --filter "Category!=Integration"

生成的文件是cobertura格式的XML,可以用ReportGenerator转成HTML:

dotnet tool install -g dotnet-reportgenerator-globaltool reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:"coveragereport" -reporttypes:Html

覆盖率不能只看总百分比。我自己的标准是:核心业务规则层覆盖率尽量在80%以上,基础设施层(比如ORM仓储、设备SDK的薄封装)不强制堆数字,但要求“关键异常路径”有测试覆盖。把覆盖率文件嵌进CI的PR评论里,让每次提交都能看到哪一行没被覆盖,比月末看一次报表有用得多。

6.3 把测试当作“代码评审的第二双眼睛”

团队合作时,最有价值的不是告诉新人“你要用AAA模式”,而是让他们养成一个习惯:代码评审看到一段逻辑时,先问“这个行为有测试吗?”没有就提出来,而不是靠口头保证“我用调试器验证过了”。TDD真正的收益是长期可持续修改。只要代码好改,需求再变化也不怕;代码不好改,就算现在全是绿勾,改一次需求也能红一片。

我在内部推进时有三个具体操作:

  • Pull Request必须包含测试文件。没有测试变更的功能PR,默认不合并。
  • 新人上手从“修一个被测试报出来的bug”开始。让新人通过TDD流程理解系统行为,比读十篇文档有用。
  • 每月挑一个最复杂的解析方法,做一次“变异测试”式重构。故意改坏一个判断条件,看测试能不能发现。发现不了,说明这个测试只是摆设。

这三条改变了团队对“绿勾”的看法——绿勾不是终点,是“这段行为被保护住了”的信号。

7. 个人的一点体会:别贪多,先让测试稳稳覆盖住最痛的模块

真要我给一个TDD落地的“最小可行起步”建议,我会这么说:不要一上来就要求所有模块都100%测试覆盖,这样团队会陷入“为了写测试而写测试”的泥潭。先选一个你最害怕改动、最担心回归的功能模块——通常是解析器、业务规则、通信协议帧处理——从它开始,把这块用测试钉死。你每次改代码,测试都能给你快速反馈,你对这个模块的信心立刻不一样。

我现在负责的项目里,设备通信这块已经被测试“罩住”了。扫码枪SDK换过、仪表型号换过、Modbus协议版本升过级,但业务层和解析层的测试几乎不用大动。这正是当年从“给条码判断逻辑写第一个测试”开始的连锁反应。大家常争论TDD到底是不是银弹,我的经验是:它未必是银弹,但它确实是最容易逼你思考边界的设计工具。

第三篇就聊到这里。如果你手上恰好有一个很难测的模块,我的建议是别急着拿Mock框架怼上去,先退一步,试着把那条“业务和设备的边界”画出来。等你画清楚了,测试就自然长出来了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询