
在 Vitest 中替换 SQLite 缓存实现epic-stack 的 cache.server 内存 Mock 实践【免费下载链接】epic-stackThis is a Full Stack app starter with the foundational things setup and configured for you to hit the ground running on your next EPIC idea.项目地址: https://gitcode.com/GitHub_Trending/ep/epic-stack## 导读本篇文章围绕 epic-stack 仓库中的一项架构决策docs/decisions/047-mock-cache-server-in-tests.md展开讲解该项目如何通过「测试专用缓存存根 Vite 模块解析替换」的组合让 Vitest 单测环境彻底绕开基于node:sqlite的缓存实现与CACHE_DATABASE_PATH环境变量约束。读完本文你将掌握为什么要为测试提供独立的缓存 Mock、存根实现与原版缓存的结构差异、Vite 插件如何按运行环境劫持模块解析以及这套方案在生产/开发与测试环境之间的隔离边界。一、背景Vite 打包node:sqlite引发的 CI 连锁故障1.1 Vitest 运行环境的特殊性epic-stack 的单元测试套件基于 Vitest 运行而 Vitest 本身构建在 Vite 之上当测试文件通过 jsdom 环境执行时Vite 会尝试对测试所依赖的模块进行打包bundle。问题恰恰出在这个环节——应用层的缓存模块 app/utils/cache.server.ts 在顶部直接导入了 Node 原生模块import { DatabaseSync } from node:sqlitenode:sqlite是 Node.js 内置的原生模块Vite 无法在面向浏览器/客户端风格的 jsdom 环境中对其进行打包。一旦 Vitest 升级后开始为 jsdom 测试套件解析缓存模块就会触发 CI 构建失败。1.2 环境变量带来的本地运行摩擦除了打包失败缓存模块对运行环境还有硬性要求。在 app/utils/cache.server.ts 中缓存数据库路径直接取自环境变量const CACHE_DATABASE_PATH process.env.CACHE_DATABASE_PATH而createDatabase在路径缺失时会直接抛出异常function createDatabase(tryAgain true): DatabaseSync { const databasePath CACHE_DATABASE_PATH if (!databasePath) { throw new Error(CACHE_DATABASE_PATH is not set) } // ... }同时app/utils/env.server.ts 也把该变量声明为必填项CACHE_DATABASE_PATH: z.string(),这意味着对于只写单元测试的开发者来说为了跑通一个与缓存无关的测试也必须在本地配置CACHE_DATABASE_PATH否则应用初始化阶段就会失败。正如决策文档047所描述的the cache module requiresCACHE_DATABASE_PATH, which is not needed for unit tests and creates friction for running tests locally.1.3 问题的本质把两个现象放在一起看本质诉求非常清晰打包层面单元测试根本不需要 SQLite 的持久化能力却被迫承担node:sqlite无法被 Vite 打包的代价配置层面单元测试需要的是隔离、快速、可并发的内存语义而不是一个真实落盘的数据库文件及其环境变量前置条件。二、决策测试专用缓存存根 模块解析替换2.1 决策内容针对上述问题项目做出的决策Status: accepted日期 2026-02-01是提供一份仅测试使用的缓存服务存根test-only cache server stub通过指令让Vite/Vitest 在运行测试时把对cache.server.ts的解析重定向到该存根存根提供内存态缓存行为和一份最小化的cachified实现足以满足测试需求生产与开发构建继续使用真实的 SQLite 缓存实现二者互不干扰。2.2 存根实现tests/mocks/cache-server.ts存根位于 tests/mocks/cache-server.ts它按照原版缓存的公开接口逐一对齐但内部全部退化为Map内存存储。首先是类型与存储type CacheEntryValue { metadata: { createdTime: number ttl?: number | null swr?: number | null } value: Value } const lruStore new Mapstring, CacheEntryunknown() const sqliteStore new Mapstring, CacheEntryunknown()存根同时暴露了lruCache与cache两个对象分别模拟原版中的内存 LRU 缓存和 SQLite 持久化缓存export const lruCache { name: test-lru-cache, set: (key: string, value: CacheEntryunknown) { lruStore.set(key, value) return value }, get: (key: string) lruStore.get(key), delete: (key: string) lruStore.delete(key), } export const cache { name: test-sqlite-cache, async get(key: string) { return sqliteStore.get(key) ?? null }, async set(key: string, entry: CacheEntryunknown) { sqliteStore.set(key, entry) return entry }, async delete(key: string) { sqliteStore.delete(key) }, }与原版见下文第三节对比可以注意到存根刻意省略了 TTL/SWR 过期计算、Buffer 序列化、litefs 主实例判断等逻辑get/set/delete都是纯内存操作——这正是决策文档所说的 simplified to in-memory semantics。键枚举与搜索接口同样被保留export async function getAllCacheKeys(limit: number) { const sqlite [...sqliteStore.keys()].slice(0, limit) const lru [...lruStore.keys()].slice(0, limit) return { sqlite, lru } } export async function searchCacheKeys(search: string, limit: number) { const matches (value: string) value.includes(search) const sqlite [...sqliteStore.keys()].filter(matches).slice(0, limit) const lru [...lruStore.keys()].filter(matches).slice(0, limit) return { sqlite, lru } }最后是最小化的cachified实现——它不再处理 TTL、SWR、缓存命中/未命中逻辑而是直接调用getFreshValue并返回新鲜值export async function cachifiedValue( options: { getFreshValue: (context: { metadata: CacheEntryunknown[metadata] }) PromiseValue | Value }, ): PromiseValue { return options.getFreshValue({ metadata: { createdTime: Date.now(), ttl: null, swr: null }, }) }2.3 解析替换的落地vite.config.ts 中的自定义插件模块替换并非依赖 Vitest 的alias配置而是在 vite.config.ts 中实现了一个自定义插件vitest-cache-server-stubconst isTest mode test || Boolean(process.env.VITEST) const cacheServerStubPlugin { name: vitest-cache-server-stub, enforce: pre as const, resolveId(source: string) { if (!process.env.VITEST) return null if (source.endsWith(cache.server.ts)) { return path.resolve(tests/mocks/cache-server.ts) } return null }, }该插件有两个关键设计点enforce: pre在 Vite 默认解析流程之前介入确保对cache.server.ts的引用被优先劫持process.env.VITEST双重闸门只有处于 Vitest 运行环境时才返回替换路径其余情况下返回null放行默认解析——这是「仅测试生效、生产开发不受影响」的核心保障。插件随后被挂载进 plugins 列表并与其他环境相关插件并列plugins: [ cacheServerStubPlugin, envOnlyMacros(), tailwindcss(), reactRouterDevTools(), iconsSpritesheet({ ... }), isTest ? null : reactRouter(), // 测试时连 react-router 插件也一并跳过 mode production process.env.SENTRY_AUTH_TOKEN ? sentryReactRouter(sentryConfig, config) : null, ],从源码结构可以看出vite.config.tsisTest同时被用于跳过reactRouter()插件进一步印证了「测试走独立构建路径」的整体设计思路。测试配置部分则声明了测试文件范围与 setup 文件test: { include: [./app/**/*.test.{ts,tsx}], setupFiles: [./tests/setup/setup-test-env.ts], globalSetup: [./tests/setup/global-setup.ts], restoreMocks: true, coverage: { include: [app/**/*.{ts,tsx}], all: true }, },三、被替换对象真实的 SQLite 缓存实现长什么样理解存根的意义需要先看懂它替换掉的是什么。app/utils/cache.server.ts 是生产级缓存的核心实现其职责远比存根复杂。3.1 双层缓存结构LRU SQLite模块顶部用remember保证数据库单例并声明了容量为 5000 的 LRU 内存缓存const lru remember( lru-cache, () new LRUCachestring, CacheEntryunknown({ max: 5000 }), ) export const lruCache { name: app-memory-cache, set: (key, value) { const ttl totalTtl(value?.metadata) lru.set(key, value, { ttl: ttl Infinity ? undefined : ttl, start: value?.metadata?.createdTime, }) return value }, get: (key) lru.get(key), delete: (key) lru.delete(key), } satisfies Cache注意这里利用了epic-web/cachified提供的totalTtl把createdTimettl换算成 LRU 的绝对过期时间——这套 TTL 计算逻辑在存根中被完全省略。3.2 SQLite 建表与预编译语句createDatabase会创建目录、打开DatabaseSync并在主实例litefs primary上建表CREATE TABLE IF NOT EXISTS cache ( key TEXT PRIMARY KEY, metadata TEXT, value TEXT )随后所有读写均使用预编译语句const getStatement cacheDb.prepare( SELECT value, metadata FROM cache WHERE key ?, ) const setStatement cacheDb.prepare( INSERT OR REPLACE INTO cache (key, value, metadata) VALUES (?, ?, ?), ) const deleteStatement cacheDb.prepare(DELETE FROM cache WHERE key ?)写入时通过bufferReplacer/bufferReviver对 Buffer 类型做 Base64 往返序列化读取时用 zod 的cacheEntrySchema/cacheQueryResultSchema做运行时校验。3.3 litefs 多实例写入协调真实缓存的set/delete还嵌入了 litefs 的主从协调逻辑getInstanceInfo来自 app/utils/litefs.server.ts其内部复用litefs-js当前实例是 primary 时直接写本地 SQLite当前实例是 secondary 时通过updatePrimaryCacheValue以 fire-and-forget 方式把更新请求转发给主实例来自 app/routes/admin/cache/sqlite.server.ts失败时记录错误日志。这套「多实例一致 事务日志追赶」的分布式语义同样是单元测试不需要承担的复杂度。3.4 cachified 封装与计时上报真实的cachified包装器在epic-web/cachified基础上叠加了cachifiedTimingReporter见 app/utils/timing.server.ts用于把缓存命中等耗时写入 Server-Timingexport async function cachifiedValue( { timings, ...options }: CachifiedOptionsValue { timings?: Timings }, reporter: CreateReporterValue verboseReporterValue(), ): PromiseValue { return baseCachified( options, mergeReporters(cachifiedTimingReporter(timings), reporter), ) }对比之下存根版cachified只保留getFreshValue调用把完整缓存管线TTL 判定、SWR 刷新、命中上报全部让位给真实实现测试中只验证业务逻辑而非缓存机制本身。四、测试环境中的配套机制数据库与环境变量Mock 缓存并非孤立方案它与测试环境的其他初始化设施协同工作。4.1 按测试池隔离的数据库tests/setup/db-setup.ts 在 Vitest 每个测试池pool内为DATABASE_URL生成独立数据库文件并对CACHE_DATABASE_PATH做同样处理const poolId process.env.VITEST_POOL_ID || 0 const databaseFile ./tests/prisma/data.${poolId}.db const databasePath path.join(process.cwd(), databaseFile) process.env.DATABASE_URL file:${databasePath} const cacheDatabasePath process.env.CACHE_DATABASE_PATH if (cacheDatabasePath cacheDatabasePath ! :memory:) { const parsed path.parse(cacheDatabasePath) const cacheFileName parsed.ext ? ${parsed.name}.${poolId}${parsed.ext} : ${parsed.name}.${poolId} const cacheDir parsed.dir || . process.env.CACHE_DATABASE_PATH path.join(cacheDir, cacheFileName) }注意这里对:memory:特殊值的放行——当缓存路径被显式指定为内存数据库时不再做文件切分这为开发者本地调试保留了灵活性。而 tests/setup/global-setup.ts 则负责在套件启动时通过prisma migrate reset --force --skip-seed --skip-generate生成基准数据库tests/prisma/base.db供每个测试池拷贝使用beforeEach中fsExtra.copyFile。4.2 测试环境初始化顺序tests/setup/setup-test-env.ts 顶部通过注释明确标注了导入顺序的敏感性import dotenv/config import ./db-setup.ts import #app/utils/env.server.ts // we need these to be imported first 之所以要严格排序是因为db-setup.ts必须在 Prisma 被导入初始化之前改写DATABASE_URL与CACHE_DATABASE_PATH——正如其afterAll中的注释wemustuse dynamic imports here so the process.env.DATABASE_URL is set before prisma is imported and initialized。该文件还引入了 MSW#tests/mocks/index.ts以及全局的 console 错误/警告监控console.error、console.warn被vi.spyOn拦截一旦调用即抛错防止噪音日志污染测试输出。五、影响与边界三个维度的取舍决策文档047明确列出了该方案的三点影响结合源码可以进一步细化5.1 稳定的 CI 测试Vitest 不再尝试打包node:sqlite因为模块解析在enforce: pre阶段就被重定向到纯 JS 的Map存根。同时单元测试不再需要CACHE_DATABASE_PATH——从缓存模块的throw new Error(CACHE_DATABASE_PATH is not set)路径看存根化之后该模块根本不会被加载环境变量前置条件自然消失本地npm test的摩擦被移除。5.2 收窄的行为范围由于缓存行为被简化为内存语义SQLite 特有的行为持久化、WAL 日志、litefs 主从同步、TTL/SWR 过期机制不会在单元测试中被覆盖。这意味着涉及这些能力的测试应由集成测试/E2E 测试如 tests/e2e 下的用例或真实运行环境来承担单测关注点被刻意收窄到业务逻辑。5.3 零运行时影响解析重定向由process.env.VITEST条件严格把关生产构建与开发服务器npm run dev解析到的始终是真实实现 app/utils/cache.server.ts。换句话说Mock 只在 Vitest 进程内存在不会以任何形式泄漏到产物中。六、适用场景与复用指南如果你在自己的项目中遇到「测试环境被 Node 原生模块或环境变量卡住」的问题可以从本决策中提炼出可直接复用的模式识别边界模块找出只在服务端/生产环境才需要的模块如本项目中的node:sqlite、litefs 协调逻辑确认单元测试确实不依赖其行为细节编写同接口存根存根必须严格对齐原模块的导出签名本项目存根完整保留了lruCache、cache、getAllCacheKeys、searchCacheKeys、cachified五个导出保证调用方零改动用 Vite 插件做条件化解析resolveId钩子 enforce: pre 环境变量process.env.VITEST三重组合既能在测试期抢先替换又能在其他环境下优雅放行配合测试环境初始化在setupFiles中完成环境变量注入与导入顺序控制确保替换发生在任何业务模块被加载之前。这套「以接口对齐为前提、以环境变量为开关、以构建插件为手段」的 Mock 策略正是 docs/decisions/047-mock-cache-server-in-tests.md 留给后来者的核心资产它用最小的侵入把测试环境的复杂度与生产环境的健壮性彻底解耦。【免费下载链接】epic-stackThis is a Full Stack app starter with the foundational things setup and configured for you to hit the ground running on your next EPIC idea.项目地址: https://gitcode.com/GitHub_Trending/ep/epic-stack创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考