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

单元测试实战: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篇:用户认证与权限管理

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

相关推荐
人道领域4 小时前
【面试八股速通篇01】锁与并发工具
java·开发语言·面试·c#
宇卿.4 小时前
C#字段、属性、索引器、常量
开发语言·计算机视觉·c#
程序员Linc4 小时前
ONNXOCRSharp 开源手记 02|v0.4.0 PDF识别
开发语言·pdf·c#·ocr·onnxocrsharp
mmsx4 小时前
Android 架构分层:MVVM 与 MVP 共存,网络层怎么切
android·策略模式·mvvm·mvp
慧都小妮子5 小时前
MSG 附件太占内存?Aspose.Email for .NET 26.9 延迟加载
c#·.net·aspose·内存优化·延迟加载·aspose.email·附件内存
慧都小妮子5 小时前
FastReport PDF/UA 导出:让出海 PDF 满足无障碍合规要求
pdf·c#·fastreport·报表导出·pdf/ua·pdf 无障碍
淡海水9 小时前
03-02-架构篇-Boehm GC架构解析
unity·架构·c#·gc·boehm
光泽雨19 小时前
路由概念和路由事件 继承链路 RadioButton的事件解析
wpf
samble1 天前
从零搭建灌装监控系统(十八):自定义控件,三色灯与状态指示
c#·wpf·mvvm·modbus·工业控制