news 2026/10/14 3:16:36

从零搭建灌装监控系统(十九):单元测试实战,xUnit + 内存SQLite + 反射测试私有方法

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零搭建灌装监控系统(十九):单元测试实战,xUnit + 内存SQLite + 反射测试私有方法

单元测试实战:xUnit + 内存SQLite + 反射测试私有方法

这是「从零搭建灌装监控系统」系列第19篇。前面十八篇写完,每次改动都得手动点一遍界面才能确认没坏。这篇搭起测试工程:用临时 SQLite 文件做数据隔离,用 fixture 共享数据库生命周期,再用反射把私有方法拉出来测。


改一行导出逻辑,手动验了半小时

CSV 导出那里我改了一个条件——把时间范围从闭区间改成半开区间。

改完要验证,我的流程是:启动软件 → 连设备 → 扫码造几条记录 → 打开导出 → 选日期 → 检查文件 → 用 Excel 打开看编码。一套下来二十分钟,而且每次都要重新造数据。

改了三行代码,验证花半小时。这个比例一旦出现,人就会开始偷懒——“这次改动很小,应该没问题”。

然后就有了那个经典的 bug:导出函数在处理空结果集时返回了0条但没关文件句柄,测试数据看不出来,现场连续导出几次之后文件被占用。

手动验证最大的问题不是慢,是不可重复。测试工程要解决的正是这个。


测试工程怎么搭

先加一个测试项目,引用主工程:

<ProjectSdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0-windows</TargetFramework><Nullable>enable</Nullable><IsPackable>false</IsPackable></PropertyGroup><ItemGroup><PackageReferenceInclude="coverlet.collector"Version="6.0.4"/><PackageReferenceInclude="Microsoft.NET.Test.Sdk"Version="17.14.1"/><PackageReferenceInclude="xunit"Version="2.9.3"/><PackageReferenceInclude="xunit.runner.visualstudio"Version="3.1.4"/></ItemGroup><ItemGroup><ProjectReferenceInclude="..\FillTrack\FillTrack.csproj"/></ItemGroup></Project>

四个包各有分工:

  • xunit— 测试框架本体
  • Microsoft.NET.Test.Sdk—dotnet test的驱动
  • xunit.runner.visualstudio— 让测试能在 IDE 里被发现
  • coverlet.collector— 覆盖率收集

WPF 项目做测试工程时,TargetFramework要跟主工程一致(net8.0-windows),否则引用会失败。


数据库测试:用临时文件,不用共享库

被测的DataService依赖全局的DbProvider.Fsql。测试如果直接用正式数据库,会污染现场数据;如果用同一个文件跑所有测试,测试之间会互相干扰。

做法是每个测试类拿到一个独立的临时数据库文件:

publicclassTestDbFixture:IDisposable{publicstringDbPath{get;}publicTestDbFixture(){DbPath=Path.Combine(Path.GetTempPath(),$"test_{Guid.NewGuid():N}.db");DbProvider.Initialize($"Data Source={DbPath}");}publicvoidDispose(){DbProvider.Fsql?.Dispose();GC.Collect();GC.WaitForPendingFinalizers();if(File.Exists(DbPath))File.Delete(DbPath);}publicasyncTaskSeedAsync(){// 清空表,保证测试隔离(首次可能表还不存在,忽略错误)try{DbProvider.Fsql.Ado.ExecuteNonQuery("DELETE FROM ProductionRecord");}catch{}varrecords=newList<ProductionRecord>{new(){Time=newDateTime(2026,6,1,10,0,0),BatchNo="B001",ActualCount=100,TargetCount=120,SettingTemp=25.5,ActualTemp=25.3,CycleTime=1.5,IsNG=false},new(){Time=newDateTime(2026,6,1,10,2,0),BatchNo="B003",ActualCount=99,TargetCount=120,SettingTemp=25.5,ActualTemp=25.1,CycleTime=1.4,IsNG=true},new(){Time=newDateTime(2026,6,2,11,0,0),BatchNo="B004",ActualCount=102,TargetCount=120,SettingTemp=26.0,ActualTemp=26.0,CycleTime=1.5,IsNG=false},};awaitDbProvider.Fsql.Insert(records).ExecuteAffrowsAsync();}}

为什么要GC.Collect和WaitForPendingFinalizers

这是这段代码里最容易忽略、也最容易出问题的地方。

SQLite 是文件数据库,File.Delete在文件句柄没释放时会抛IOException。而FreeSql内部持有连接池,Dispose()之后句柄未必立刻释放——可能要等 GC 回收。

所以顺序是:先Dispose释放 FreeSql,再GC.Collect()强制回收,WaitForPendingFinalizers()等终结器跑完,最后才删文件。

漏掉这两行的话,测试会在清理阶段随机失败,报“文件正被另一个进程使用”。这种偶发失败最消耗人的耐心——因为它不是每次都复现。

为什么用文件而不是:memory:

SQLite 支持内存数据库,但:memory:有个特点:每个连接是独立的内存库。FreeSql 用连接池,不同操作可能走不同连接,于是:memory:下建的表在下一个连接里就不存在了。

要绕开得用Data Source=file:memdb1?mode=memory&cache=shared这种共享缓存写法,配置更绕。临时文件方案虽然多一次磁盘 IO,但行为和正式环境一致,反而更可靠。

测试环境跟生产环境越像,测试的置信度越高。


用 IClassFixture 共享数据库

有了 fixture,测试类通过泛型参数拿到它:

publicclassDataServiceTests:IClassFixture<TestDbFixture>{privatereadonlyTestDbFixture_db;publicDataServiceTests(TestDbFixturedb){_db=db;}[Fact]publicasyncTask QueryRecordAsync_范围内查询_返回正确条数(){await_db.SeedAsync();varresult=awaitDataService.QueryRecordAsync(newDateTime(2026,6,1),newDateTime(2026,6,3));Assert.Equal(3,result.Count);}[Fact]publicasyncTask QueryRecordAsync_单日查询_返回当天数据(){await_db.SeedAsync();varresult=awaitDataService.QueryRecordAsync(newDateTime(2026,6,1),newDateTime(2026,6,2));Assert.Equal(2,result.Count);}}

IClassFixture<T>让 xUnit 在整个测试类里共享同一个fixture 实例——数据库只初始化一次,每个测试方法各自SeedAsync重置数据。

这就是SeedAsync里那句DELETE FROM ProductionRecord的用处:fixture 共享,但数据必须隔离。不清理的话,第二个测试看到的就是第一个测试留下的数据。


反射测试私有方法

有些逻辑非常值得测,但它是private的——比如协议里寄存器数组转字符串、密码哈希。

把方法改成public只是为了测试,是设计污染。用反射可以在不改可见性的前提下测到:

publicclassPlcServiceTests{// 反射调用私有方法 — 测试专用privatestaticstringConvertRegisters(ushort[]regs){varmethod=typeof(PlcService).GetMethod("ConverRegisterToString",BindingFlags.NonPublic|BindingFlags.Static)!;return(string)method.Invoke(null,newobject[]{regs})!;}[Fact]publicvoidConverRegisterToString_AB字符串_返回AB(){ushort[]regs={0x0041,0x0042,0x0000};Assert.Equal("AB",ConvertRegisters(regs));}[Fact]publicvoidConverRegisterToString_空数组_返回空字符串(){Assert.Equal("",ConvertRegisters(Array.Empty<ushort>()));}[Fact]publicvoidConverRegisterToString_全零_返回空(){Assert.Equal("",ConvertRegisters(newushort[]{0x0000}));}[Fact]publicvoidConverRegisterToString_数字123_返回123(){ushort[]regs={0x0031,0x0032,0x0033,0x0000};Assert.Equal("123",ConvertRegisters(regs));}}

四个用例覆盖了寄存器转字符串的主要边界:正常字符、空数组、全零(应当视为无数据)、数字字符。0x0000被当作字符串结束符——这是协议决定的,测试把这个约定固化下来。

方法名拼错,反射就找不到

GetMethod("ConverRegisterToString", ...)里那个Conver不是笔误——源方法名本身就叫这个,少了一个t。

这带来一个很实际的教训:反射是按字符串找方法的,编译期不检查。方法改名了、拼错了,测试不会编译失败,而是运行时抛NullReferenceException(GetMethod返回null,后面!掩盖了它)。

所以反射调用一定要给方法名加保护,至少让失败信息可读:

varmethod=typeof(PlcService).GetMethod("ConverRegisterToString",BindingFlags.NonPublic|BindingFlags.Static)??thrownewInvalidOperationException("找不到私有方法 ConverRegisterToString,可能已改名");

比NullReferenceException好排查得多。


哈希函数也这么测

publicclassUserServiceTests{privatestaticstringHash(stringpassword){varmethod=typeof(UserService).GetMethod("HashPassword",BindingFlags.NonPublic|BindingFlags.Static)!;return(string)method.Invoke(null,newobject[]{password})!;}[Fact]publicvoidHashPassword_相同输入_相同输出(){Assert.Equal(Hash("admin"),Hash("admin"));}[Fact]publicvoidHashPassword_SHA256输出64位十六进制(){Assert.Equal(64,Hash("admin").Length);}[Fact]publicvoidHashPassword_不同输入_不同输出(){Assert.NotEqual(Hash("admin"),Hash("engineer"));}[Fact]publicvoidHashPassword_空字符串_返回空(){Assert.Equal("",Hash(""));}}

这四个断言把哈希函数的契约固定住了:确定性(同输入同输出)、输出格式(SHA256 的 64 位十六进制)、区分度、空值处理。

注意第二个断言的价值。它看起来像在测实现细节(“64 位”),但它其实在防一类真实事故:有人把哈希算法从 SHA256 换成别的,或者不小心截断了输出。长度断言是最便宜的守门人。

测试覆盖的分布:

反射

反射

IClassFixture

测试工程

PlcServiceTests

UserServiceTests

DataServiceTests

寄存器转字符串 4 例

密码哈希 4 例

范围查询 3 例

CSV 导出 4 例

TestDbFixture 临时库


踩坑记录

用:memory:做数据库测试

连接池里每个连接是独立内存库,建的表在别的连接里看不到。用临时文件更省心,行为也更接近生产。

删临时库文件前没等句柄释放

Dispose后立刻File.Delete会偶发IOException。要GC.Collect+WaitForPendingFinalizers。

fixture 共享但数据没隔离

测试互相看到对方的数据,顺序一变结果就变。每个测试开头重置数据。

反射方法名改了但没报错

GetMethod找不到就返回null,加!之后变成运行时空引用,堆栈指向测试辅助方法,看不出真正原因。加显式判空和清晰异常。

为了测试把私有方法改成 public

可见性是为了设计服务的,不该为测试让路。反射或者InternalsVisibleTo都能绕开这个问题。


本篇小结

问题做法
测试污染正式数据每个 fixture 独立临时数据库文件
测试互相干扰fixture 共享 + 每个用例重置数据
删文件失败Dispose → GC.Collect → WaitForPendingFinalizers → Delete
私有方法没法测反射 +BindingFlags.NonPublic
反射失败信息差显式判空,抛带方法名的异常
测试环境失真用文件库,别用:memory:

测试能跑起来之后,登录、权限这些“只有在特定角色下才暴露”的逻辑,才有办法系统性地验证。下一篇做用户认证和权限管理。


下期预告

第20篇:用户认证与权限管理

下一篇实现密码哈希、角色划分和登录窗口,并处理一个安全问题:为什么固定盐是不安全的,以及密码框为什么天生不能绑定。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 3:16:27

PDF轻编辑实战:精准擦除水印与添加文字图片

1. 轻编辑需求为什么越来越常见先聊一个很多人都会遇到的场景。某份合同扫描件里有一行备注写错了&#xff0c;需要清掉重新批注&#xff1b;某本电子书上有水印和阅读器自带的页码&#xff0c;想擦掉再打印&#xff1b;某份课件截图上有多余的公式或标注&#xff0c;想删干净后…

作者头像 李华
网站建设 2026/10/14 3:15:03

MySQL零基础入门:从安装建库到增删改查全流程教程

1. MySQL到底是个啥&#xff0c;为什么零基础也要学它这一篇我们聊聊 MySQL。不管你之前有没有接触过数据库&#xff0c;只要你会用电脑打字&#xff0c;跟着这份超详细的教程&#xff0c;就能把 MySQL 装起来、跑起来&#xff0c;然后亲手写下第一条 SQL。我见过不少零基础学编…

作者头像 李华
网站建设 2026/10/14 3:14:49

ORA-12505故障精解:Oracle监听器服务注册与连接描述符匹配原理

1. 问题本质与典型场景还原“ORA-12505, TNS:listener does not currently know of SID given in connect descriptor”——这行报错&#xff0c;我在过去十年带过的二十多个Oracle数据库项目里&#xff0c;几乎每三个月就要亲手处理一次。它不是那种高深莫测的内核级错误&…

作者头像 李华
网站建设 2026/10/14 3:14:10

Spring AOP 从入门到原理:一文搞懂面向切面编程与动态代理

一. 为什么需要AOP&#xff1f;首先&#xff0c;我们先来用一个例子来说明 AOP 存在的意义。就简单地以统计各个接口方法的执行耗时为例&#xff0c;根据我们之前学习的知识来说&#xff0c;我们需要在每个业务方法前后分别统计开始时间、结束时间并计算出所使用的时间&#xf…

作者头像 李华
网站建设 2026/10/14 3:14:02

AI预览版与前沿隐藏能力:普通从业者如何用出专家级效果

前段时间&#xff0c;一位常年泡在 AI 前沿一线的研究者在公开场合说了句话&#xff0c;大意是&#xff1a;外界看到的 AI 能力&#xff0c;其实只是一段“预览版”的演示&#xff1b;真正的前沿系统&#xff0c;早就在处理那些连专业人才都要耗上数年才能完成的活了。这句话让…

作者头像 李华