Jest 如何测试 fs 文件系统和 I/O 操作?
测试文件系统代码时,最重要的问题不是“能不能读写文件”,而是“你的业务逻辑在文件存在、缺失、权限不足、内容损坏时是否处理正确”。Jest 可以 mock fs,也可以配合临时目录做接近真实的集成测试。两种方式都该会用,因为纯 Mock 快但容易脱离真实行为,真实 I/O 准但慢且需要清理。
什么时候 Mock fs?
如果函数只是包装读取、解析和错误处理,mock fs 很合适。它能让测试不依赖本机路径,也不会污染项目目录。
jsconst fs = require('fs') jest.mock('fs') function readConfig(path) { const raw = fs.readFileSync(path, 'utf8') return JSON.parse(raw) } test('读取并解析配置文件', () => { fs.readFileSync.mockReturnValue('{"port":3000}') expect(readConfig('/app/config.json')).toEqual({ port: 3000 }) expect(fs.readFileSync).toHaveBeenCalledWith('/app/config.json', 'utf8') })
边界是:mock 出来的 fs 不会模拟所有 Node 行为,比如真实错误对象的 code、路径分隔符、权限差异、符号链接。只靠 mock,可能会漏掉生产环境里的路径问题。
异步 fs.promises 怎么测?
现代 Node 项目更常用 fs/promises。这时要 mock 对应模块,并用 mockResolvedValue 或 mockRejectedValue 表达成功和失败。
jsjest.mock('fs/promises', () => ({ readFile: jest.fn(), writeFile: jest.fn(), mkdir: jest.fn() })) const fs = require('fs/promises') test('文件不存在时返回默认配置', async () => { fs.readFile.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) await expect(loadConfig('/no-file.json')).resolves.toEqual({}) })
这里的坑是不要只 mock happy path。I/O 最容易出问题的地方恰恰是 ENOENT、EACCES、JSON 格式错误、磁盘写入中断和目录不存在。
什么时候用真实临时文件?
涉及路径拼接、目录创建、文件遍历、编码、换行符或原子写入时,最好用临时目录跑一层集成测试。
jsconst os = require('os') const path = require('path') const fs = require('fs/promises') test('写入后可以再次读取', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'jest-fs-')) const file = path.join(dir, 'data.txt') await saveText(file, 'hello') await expect(fs.readFile(file, 'utf8')).resolves.toBe('hello') await fs.rm(dir, { recursive: true, force: true }) })
清理必须放在 afterEach 或 try/finally 中,否则 CI 上一次失败会影响下一次运行。
追问
Mock fs 和使用临时目录怎么取舍?
Mock fs 速度快、断言清晰,适合覆盖业务分支和错误处理。临时目录更接近真实环境,适合验证路径、编码、目录递归和跨平台行为。取舍标准是测试目标:想验证“函数是否调用了 fs”用 Mock,想验证“文件结果是否真的正确”用临时目录。踩坑是把所有测试都写成真实 I/O,最后测试套件变慢且偶发失败。
如何测试文件不存在和权限不足?
不要只断言抛错,要断言你的代码对错误的处理策略。文件不存在可能返回默认值,也可能提示用户创建配置;权限不足通常应该向上抛出更明确的错误。边界是不同系统的错误消息不稳定,不要用完整 message 做强匹配。更稳的是匹配 error.code,例如 ENOENT 或 EACCES。
为什么手动 mock fs 容易失真?
Node 的 fs API 细节很多,同步、异步、callback、promise 版本行为并不完全一样。手动 mock 只覆盖你想到的分支,没想到的地方会变成测试盲区。取舍是:小函数手动 mock 足够,大量文件操作可以考虑 memfs 这类内存文件系统。坑是 memfs 也不是完整操作系统,权限和符号链接仍可能与真实环境不同。
文件遍历测试要注意什么边界?
目录遍历要测空目录、嵌套目录、隐藏文件、扩展名过滤和排序稳定性。跨平台时还要避免硬编码 /,应该使用 path.join 或 path.resolve。取舍是测试里是否固定排序:如果业务需要稳定输出,就应该排序并测试;如果不需要,断言可以用集合方式。踩坑是 macOS 本地文件顺序和 Linux CI 不一致,导致测试偶发失败。
写入文件时如何避免测试污染?
每个测试都应创建独立临时目录,不要共用项目里的 fixtures/output。清理逻辑要放在 finally 或 afterEach,即使断言失败也能删除文件。边界是调试失败时你可能想保留文件,可以通过环境变量控制是否清理。不要在测试里写固定路径,尤其不要写用户主目录或仓库根目录。