正确做法是继承 Stream 重写 Read/Write/Flush/Seek 等方法,在底层流层精准控制故障时机,同时必须同步实现对应 Async 方法,避免异步路径绕过注入,且需确保资源彻底释放、不污染环境。

用 FileStream 包裹模拟故障的底层流
直接在真实文件上反复注入错误既慢又污染环境,更关键的是无法精准控制失败时机。正确做法是写一个可控制的 Stream 子类,在读/写/Flush等关键点按需抛异常或延迟。
常见错误现象:IOException、TimeoutException 或线程卡死,但堆栈指向 FileStream.Read 等原生调用,根本看不出是测试逻辑导致的——说明你没拦截在流层,而是去 patch 了更高层(比如 File.ReadAllText),这会漏掉很多底层路径。
- 必须继承
Stream,重写Read、Write、Flush、Seek(如果测试涉及随机访问) - 不要在构造时打开真实文件,而是在
Read/Write中才委托给一个真实的FileStream实例 - 用
Random+ 条件判断决定是否延迟(Task.Delay后同步等待)或抛IOException(别抛InvalidOperationException,那不是 IO 层该冒的错)
AppDomain 或进程级文件锁干扰测试
本地跑单测时,常出现“文件正由另一进程使用”这种错误,其实只是前一次测试没 properly dispose 掉 FileStream,残留句柄锁住了文件。这不是混沌设计问题,是清理漏洞。
使用场景:连续跑多组故障注入测试(比如先测超时,再测磁盘满),必须确保每次测试后资源彻底释放。
- 所有
FileStream必须用using或显式Dispose(),不能只靠Close() - 避免在测试中用
File.OpenRead(path)这类工厂方法——它返回的流不保证可重复 Dispose,且类型不可控;改用new FileStream(path, ...)显式构造 - Windows 下可用
handle.exe(Sysinternals)查残留句柄,Linux/macOS 用lsof -p [pid]验证
异步 IO 路径绕过同步故障注入
如果你只重写了 Stream.Read,但代码实际走的是 Stream.ReadAsync,那所有延迟和异常都不会触发——这是最常被忽略的断裂点。
性能影响:ReadAsync 默认可能回退到线程池同步读(尤其小缓冲区),导致你以为在测异步,实则没压出真正的并发压力。
- 必须同时重写
ReadAsync、WriteAsync、FlushAsync,且行为要和同步版本一致(比如同样概率抛异常) - 检查目标代码是否用了
ConfigureAwait(false),否则测试线程可能被 await 拖入 UI 上下文,掩盖线程阻塞问题 - 别依赖
Task.Run(() => base.Read(...))模拟异步——这会掩盖真实 I/O 完成端口(IOCP)行为,测了也白测
路径权限与 UNC 映射在 CI 中失效
本地用管理员权限能成功注入“拒绝访问”错误,但 CI 机器(如 Azure Pipelines 的 windows-2022)默认没有管理员 token,File.SetAccessControl 直接抛 UnauthorizedAccessException,导致故障分支根本跑不起来。
兼容性影响:不同 Windows 版本对 SeSecurityPrivilege 的要求不同,Server 2019 比 Win10 更严格。
- 放弃在 CI 中动态改 ACL,改用流层抛
UnauthorizedAccessException(它和系统抛的一样,上层 catch 逻辑无需改动) - UNC 路径(
\servershare)在容器化 CI 中通常不可达,别在测试里硬编码;统一用Path.GetTempPath()+ 随机子目录 - 若必须验证权限逻辑,用
new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None, 4096, FileOptions.DeleteOnClose)配合已知只读文件,比改 ACL 更可靠
真正难的不是让文件读失败,而是让失败发生在调用栈里那个具体位置——比如刚读完 header 就断,而不是一上来就炸。这意味着你要在流内部维护状态机,而不是简单掷骰子。这点多数人试两次就放弃了,然后退回到 mock 高层 API,结果漏掉 FileStream 缓冲、OS 缓存、NTFS 日志这些真家伙。








