文件读取函数通常只关心路径与内容,测试却容易被磁盘上的旧文件、目录布局和执行顺序干扰。把文件系统替换成内存实现,可以让每个用例自己准备输入,再在下一次测试开始前清空状态。这样,测试依赖的文件就成为明确可见的测试数据。
本文依据 Vitest 官方文档 Mocking the File System 全文翻译整理。原文无个人署名,归属 VoidZero Inc. 与 Vitest contributors。正文保留原例的模块布局、读取函数和两个测试用例;“审核补充”是本次整理新增的边界说明。核对日期:2026 年 10 月 5 日。

为什么使用 memfs
替换文件系统的目的,是减少测试对真实磁盘的依赖,让输入可控,并避免前一个用例留下的文件影响后一个用例。这种替换也可以用于设计错误路径测试,例如权限不足、磁盘已满或读写失败。但这些场景需要额外的故障模拟;下文的官方实例只展示文本读取、多路径文件数据和用例之间的重置,没有实现这些错误场景。
Vitest 自身没有开箱即用的虚拟文件系统 API。可以用 vi.mock 手工替换 fs,但若逐个维护大量文件 API 的假实现,成本会逐渐增加。官方文档因此推荐 memfs:它提供内存中的文件系统语义,在被替换的调用链里完成文件操作,不写入真实磁盘。
版本说明:本文针对 2026 年 10 月 5 日读取的 Vitest 当前文档。该站入门页当时要求 Node.js ≥ 22.12.0、Vite ≥ 6.4.0;历史项目应先核对所用 Vitest 版本的相应文档。项目需要安装 memfs 作为开发依赖,并已有可工作的 Vitest 配置。本次没有安装依赖或运行示例。
让两个文件接口使用同一份内存实现
在项目根目录创建以下两个替换模块:
__mocks__/
├── fs.cjs
└── fs/
└── promises.cjs
__mocks__/fs.cjs 导出 memfs 的普通文件接口:
// 也可以使用 import,但那样需要显式定义各个导出。
const { fs } = require('memfs')
module.exports = fs
__mocks__/fs/promises.cjs 导出同一实现上的 Promise 接口:
// 也可以使用 import,但那样需要显式定义各个导出。
const { fs } = require('memfs')
module.exports = fs.promises
这里采用 CommonJS 文件,是为了直接转发整个导出对象。普通接口与异步接口都来自 memfs 的同一个 fs,因而指向同一份文件数据。只创建文件还不够:测试中仍需调用 vi.mock 启用相应模块替身。
保留真正的业务读取函数
创建 read-hello-world.js。它仍通过 Node.js 的标准模块名称导入读取函数,无须为了测试而改成直接依赖 memfs:
import { readFileSync } from 'node:fs'
export function readHelloWorld(path) {
return readFileSync(path, 'utf-8')
}
函数的行为很明确:按 UTF-8 读取指定路径并返回字符串。测试的替换发生在模块边界,而不是把 readHelloWorld 本身改成一个固定返回值的假函数。因此,断言仍会覆盖实际的读取逻辑。
先重置,再准备输入并断言
下面是完整的 hello-world.test.js。代码与原文逻辑一致,仅翻译了注释与用例名称:
import { beforeEach, expect, it, vi } from 'vitest'
import { fs, vol } from 'memfs'
import { readHelloWorld } from './read-hello-world.js'
// 告诉 Vitest 使用 __mocks__ 目录中的文件模块替身。
// 若所有用例都要替换文件系统,可移入测试 setup 文件。
vi.mock('node:fs')
vi.mock('node:fs/promises')
beforeEach(() => {
// 清空内存文件系统的状态。
vol.reset()
})
it('返回正确的文本', () => {
const path = '/hello-world.txt'
fs.writeFileSync(path, 'hello world')
const text = readHelloWorld(path)
expect(text).toBe('hello world')
})
it('可以从多个路径读取内容', () => {
// 用一个对象一次定义多个文件。
vol.fromJSON(
{
'./dir1/hw.txt': 'hello dir1',
'./dir2/hw.txt': 'hello dir2',
},
// 解析这些相对路径时采用的工作目录。
'/tmp',
)
expect(readHelloWorld('/tmp/dir1/hw.txt')).toBe('hello dir1')
expect(readHelloWorld('/tmp/dir2/hw.txt')).toBe('hello dir2')
})
第一个用例直接向内存文件系统写入 /hello-world.txt,再调用被测函数,检查文本完全相同。第二个用例通过 vol.fromJSON 构造两条路径;第二个参数 /tmp 是这些相对路径的基准,所以两条完整路径分别是 /tmp/dir1/hw.txt 与 /tmp/dir2/hw.txt。这些路径描述的是内存卷,不要求真实磁盘上有对应目录。
beforeEach 中的 vol.reset() 是隔离关键。它让每个用例从空卷开始,避免第二个测试偶然依赖第一个测试创建的文件。即使两个文件的 basename 都是 hw.txt,不同目录中的内容也应分别读出,第二个用例正是在检查这一点。
审核补充:替换范围与未覆盖的行为
内存文件系统不是安全沙箱。只有确实经过被替换模块的调用才会使用 memfs。另一个库若通过未覆盖的模块路径、原生扩展、子进程或其他 I/O 接口访问磁盘,这两行 vi.mock 不会提供操作系统级隔离。不要据此运行来源不明的代码,也不要把真实敏感路径交给未经确认的测试。
示例同时声明了 node:fs 和 node:fs/promises 的替换,但断言只走 readFileSync;它没有证明 Promise API、权限检查、磁盘容量、符号链接或平台路径差异都与实际文件系统一致。若应用依赖这些行为,需另行设计明确的单元测试,并在受控临时目录中补充必要的集成测试。
共享的 vol 还意味着用例需要遵守隔离约定。本例按普通串行用例展示,不应未经处理就改为并发测试;一个用例的 reset() 可能清空另一个用例正使用的数据。文件模块替身也应限定在需要的测试环境,避免全局 setup 意外改变其他测试的前提。
本次静态审核未在展示的代码中发现硬编码秘密、命令执行或对真实目录的显式删除操作。仍需强调:没有发现这些问题不等于完成漏洞审计。本文未执行测试,未声称任何用例已经通过。
来源与许可
原文:Vitest — Mocking the File System;版本条件:Getting Started。版权归属:© 2026 VoidZero Inc. and Vitest contributors。本稿另以原创示意图说明流程。原仓库的代码及相关文档适用 MIT License,完整许可如下。
MIT 许可声明(原文保留)
MIT License
Copyright (c) 2021-Present VoidZero Inc. and Vitest contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.












暂无评论内容