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

改一行导出逻辑,手动验了半小时
CSV 导出那里我改了一个条件------把时间范围从闭区间改成半开区间。
改完要验证,我的流程是:启动软件 → 连设备 → 扫码造几条记录 → 打开导出 → 选日期 → 检查文件 → 用 Excel 打开看编码。一套下来二十分钟,而且每次都要重新造数据。
改了三行代码,验证花半小时。这个比例一旦出现,人就会开始偷懒------"这次改动很小,应该没问题"。
然后就有了那个经典的 bug:导出函数在处理空结果集时返回了 0 条但没关文件句柄,测试数据看不出来,现场连续导出几次之后文件被占用。
手动验证最大的问题不是慢,是不可重复。测试工程要解决的正是这个。
测试工程怎么搭
先加一个测试项目,引用主工程:
xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\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。测试如果直接用正式数据库,会污染现场数据;如果用同一个文件跑所有测试,测试之间会互相干扰。
做法是每个测试类拿到一个独立的临时数据库文件:
csharp
public class TestDbFixture : IDisposable
{
public string DbPath { get; }
public TestDbFixture()
{
DbPath = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid():N}.db");
DbProvider.Initialize($"Data Source={DbPath}");
}
public void Dispose()
{
DbProvider.Fsql?.Dispose();
GC.Collect();
GC.WaitForPendingFinalizers();
if (File.Exists(DbPath))
File.Delete(DbPath);
}
public async Task SeedAsync()
{
// 清空表,保证测试隔离(首次可能表还不存在,忽略错误)
try { DbProvider.Fsql.Ado.ExecuteNonQuery("DELETE FROM ProductionRecord"); }
catch { }
var records = new List<ProductionRecord>
{
new() { Time = new DateTime(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 = new DateTime(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 = new DateTime(2026, 6, 2, 11, 0, 0), BatchNo = "B004",
ActualCount = 102, TargetCount = 120, SettingTemp = 26.0,
ActualTemp = 26.0, CycleTime = 1.5, IsNG = false },
};
await DbProvider.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,测试类通过泛型参数拿到它:
csharp
public class DataServiceTests : IClassFixture<TestDbFixture>
{
private readonly TestDbFixture _db;
public DataServiceTests(TestDbFixture db)
{
_db = db;
}
[Fact]
public async Task QueryRecordAsync_范围内查询_返回正确条数()
{
await _db.SeedAsync();
var result = await DataService.QueryRecordAsync(
new DateTime(2026, 6, 1), new DateTime(2026, 6, 3));
Assert.Equal(3, result.Count);
}
[Fact]
public async Task QueryRecordAsync_单日查询_返回当天数据()
{
await _db.SeedAsync();
var result = await DataService.QueryRecordAsync(
new DateTime(2026, 6, 1), new DateTime(2026, 6, 2));
Assert.Equal(2, result.Count);
}
}
IClassFixture<T> 让 xUnit 在整个测试类里共享同一个 fixture 实例------数据库只初始化一次,每个测试方法各自 SeedAsync 重置数据。
这就是 SeedAsync 里那句 DELETE FROM ProductionRecord 的用处:fixture 共享,但数据必须隔离。不清理的话,第二个测试看到的就是第一个测试留下的数据。
反射测试私有方法
有些逻辑非常值得测,但它是 private 的------比如协议里寄存器数组转字符串、密码哈希。
把方法改成 public 只是为了测试,是设计污染。用反射可以在不改可见性的前提下测到:
csharp
public class PlcServiceTests
{
// 反射调用私有方法 --- 测试专用
private static string ConvertRegisters(ushort[] regs)
{
var method = typeof(PlcService).GetMethod("ConverRegisterToString",
BindingFlags.NonPublic | BindingFlags.Static)!;
return (string)method.Invoke(null, new object[] { regs })!;
}
[Fact]
public void ConverRegisterToString_AB字符串_返回AB()
{
ushort[] regs = { 0x0041, 0x0042, 0x0000 };
Assert.Equal("AB", ConvertRegisters(regs));
}
[Fact]
public void ConverRegisterToString_空数组_返回空字符串()
{
Assert.Equal("", ConvertRegisters(Array.Empty<ushort>()));
}
[Fact]
public void ConverRegisterToString_全零_返回空()
{
Assert.Equal("", ConvertRegisters(new ushort[] { 0x0000 }));
}
[Fact]
public void ConverRegisterToString_数字123_返回123()
{
ushort[] regs = { 0x0031, 0x0032, 0x0033, 0x0000 };
Assert.Equal("123", ConvertRegisters(regs));
}
}
四个用例覆盖了寄存器转字符串的主要边界:正常字符、空数组、全零(应当视为无数据)、数字字符。0x0000 被当作字符串结束符------这是协议决定的,测试把这个约定固化下来。
方法名拼错,反射就找不到
GetMethod("ConverRegisterToString", ...) 里那个 Conver 不是笔误------源方法名本身就叫这个 ,少了一个 t。
这带来一个很实际的教训:反射是按字符串找方法的,编译期不检查。 方法改名了、拼错了,测试不会编译失败,而是运行时抛 NullReferenceException(GetMethod 返回 null,后面 ! 掩盖了它)。
所以反射调用一定要给方法名加保护,至少让失败信息可读:
csharp
var method = typeof(PlcService).GetMethod("ConverRegisterToString",
BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException(
"找不到私有方法 ConverRegisterToString,可能已改名");
比 NullReferenceException 好排查得多。
哈希函数也这么测
csharp
public class UserServiceTests
{
private static string Hash(string password)
{
var method = typeof(UserService).GetMethod("HashPassword",
BindingFlags.NonPublic | BindingFlags.Static)!;
return (string)method.Invoke(null, new object[] { password })!;
}
[Fact]
public void HashPassword_相同输入_相同输出()
{
Assert.Equal(Hash("admin"), Hash("admin"));
}
[Fact]
public void HashPassword_SHA256输出64位十六进制()
{
Assert.Equal(64, Hash("admin").Length);
}
[Fact]
public void HashPassword_不同输入_不同输出()
{
Assert.NotEqual(Hash("admin"), Hash("engineer"));
}
[Fact]
public void HashPassword_空字符串_返回空()
{
Assert.Equal("", Hash(""));
}
}
这四个断言把哈希函数的契约固定住了:确定性(同输入同输出)、输出格式(SHA256 的 64 位十六进制)、区分度、空值处理。
注意第二个断言的价值。它看起来像在测实现细节("64 位"),但它其实在防一类真实事故:有人把哈希算法从 SHA256 换成别的,或者不小心截断了输出。长度断言是最便宜的守门人。
测试覆盖的分布:
#mermaid-svg-q3vJ4mnimMqjQQ0K{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-q3vJ4mnimMqjQQ0K .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-q3vJ4mnimMqjQQ0K .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-q3vJ4mnimMqjQQ0K .error-icon{fill:#552222;}#mermaid-svg-q3vJ4mnimMqjQQ0K .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-q3vJ4mnimMqjQQ0K .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-q3vJ4mnimMqjQQ0K .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-q3vJ4mnimMqjQQ0K .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-q3vJ4mnimMqjQQ0K .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-q3vJ4mnimMqjQQ0K .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-q3vJ4mnimMqjQQ0K .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-q3vJ4mnimMqjQQ0K .marker{fill:#333333;stroke:#333333;}#mermaid-svg-q3vJ4mnimMqjQQ0K .marker.cross{stroke:#333333;}#mermaid-svg-q3vJ4mnimMqjQQ0K svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-q3vJ4mnimMqjQQ0K p{margin:0;}#mermaid-svg-q3vJ4mnimMqjQQ0K .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-q3vJ4mnimMqjQQ0K .cluster-label text{fill:#333;}#mermaid-svg-q3vJ4mnimMqjQQ0K .cluster-label span{color:#333;}#mermaid-svg-q3vJ4mnimMqjQQ0K .cluster-label span p{background-color:transparent;}#mermaid-svg-q3vJ4mnimMqjQQ0K .label text,#mermaid-svg-q3vJ4mnimMqjQQ0K span{fill:#333;color:#333;}#mermaid-svg-q3vJ4mnimMqjQQ0K .node rect,#mermaid-svg-q3vJ4mnimMqjQQ0K .node circle,#mermaid-svg-q3vJ4mnimMqjQQ0K .node ellipse,#mermaid-svg-q3vJ4mnimMqjQQ0K .node polygon,#mermaid-svg-q3vJ4mnimMqjQQ0K .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-q3vJ4mnimMqjQQ0K .rough-node .label text,#mermaid-svg-q3vJ4mnimMqjQQ0K .node .label text,#mermaid-svg-q3vJ4mnimMqjQQ0K .image-shape .label,#mermaid-svg-q3vJ4mnimMqjQQ0K .icon-shape .label{text-anchor:middle;}#mermaid-svg-q3vJ4mnimMqjQQ0K .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-q3vJ4mnimMqjQQ0K .rough-node .label,#mermaid-svg-q3vJ4mnimMqjQQ0K .node .label,#mermaid-svg-q3vJ4mnimMqjQQ0K .image-shape .label,#mermaid-svg-q3vJ4mnimMqjQQ0K .icon-shape .label{text-align:center;}#mermaid-svg-q3vJ4mnimMqjQQ0K .node.clickable{cursor:pointer;}#mermaid-svg-q3vJ4mnimMqjQQ0K .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-q3vJ4mnimMqjQQ0K .arrowheadPath{fill:#333333;}#mermaid-svg-q3vJ4mnimMqjQQ0K .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-q3vJ4mnimMqjQQ0K .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-q3vJ4mnimMqjQQ0K .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-q3vJ4mnimMqjQQ0K .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-q3vJ4mnimMqjQQ0K .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-q3vJ4mnimMqjQQ0K .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-q3vJ4mnimMqjQQ0K .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-q3vJ4mnimMqjQQ0K .cluster text{fill:#333;}#mermaid-svg-q3vJ4mnimMqjQQ0K .cluster span{color:#333;}#mermaid-svg-q3vJ4mnimMqjQQ0K div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-q3vJ4mnimMqjQQ0K .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-q3vJ4mnimMqjQQ0K rect.text{fill:none;stroke-width:0;}#mermaid-svg-q3vJ4mnimMqjQQ0K .icon-shape,#mermaid-svg-q3vJ4mnimMqjQQ0K .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-q3vJ4mnimMqjQQ0K .icon-shape p,#mermaid-svg-q3vJ4mnimMqjQQ0K .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-q3vJ4mnimMqjQQ0K .icon-shape rect,#mermaid-svg-q3vJ4mnimMqjQQ0K .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-q3vJ4mnimMqjQQ0K .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-q3vJ4mnimMqjQQ0K .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-q3vJ4mnimMqjQQ0K :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 反射
反射
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篇:用户认证与权限管理
下一篇实现密码哈希、角色划分和登录窗口,并处理一个安全问题:为什么固定盐是不安全的,以及密码框为什么天生不能绑定。