单元测试实战: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 换成别的,或者不小心截断了输出。长度断言是最便宜的守门人。
测试覆盖的分布:
踩坑记录
用:memory:做数据库测试
连接池里每个连接是独立内存库,建的表在别的连接里看不到。用临时文件更省心,行为也更接近生产。
删临时库文件前没等句柄释放
Dispose后立刻File.Delete会偶发IOException。要GC.Collect+WaitForPendingFinalizers。
fixture 共享但数据没隔离
测试互相看到对方的数据,顺序一变结果就变。每个测试开头重置数据。
反射方法名改了但没报错
GetMethod找不到就返回null,加!之后变成运行时空引用,堆栈指向测试辅助方法,看不出真正原因。加显式判空和清晰异常。
为了测试把私有方法改成 public
可见性是为了设计服务的,不该为测试让路。反射或者InternalsVisibleTo都能绕开这个问题。
本篇小结
| 问题 | 做法 |
|---|---|
| 测试污染正式数据 | 每个 fixture 独立临时数据库文件 |
| 测试互相干扰 | fixture 共享 + 每个用例重置数据 |
| 删文件失败 | Dispose → GC.Collect → WaitForPendingFinalizers → Delete |
| 私有方法没法测 | 反射 +BindingFlags.NonPublic |
| 反射失败信息差 | 显式判空,抛带方法名的异常 |
| 测试环境失真 | 用文件库,别用:memory: |
测试能跑起来之后,登录、权限这些“只有在特定角色下才暴露”的逻辑,才有办法系统性地验证。下一篇做用户认证和权限管理。
下期预告
第20篇:用户认证与权限管理
下一篇实现密码哈希、角色划分和登录窗口,并处理一个安全问题:为什么固定盐是不安全的,以及密码框为什么天生不能绑定。