☰
SparkyFitness 全栈测试体系实战指南:Jest、pytest 与路径感知的 CI 测试流水线
2026/10/10 11:37:59 网站建设 项目流程
  • 后端
  • 前端
  • 移动开发

【免费下载链接】SparkyFitness

SparkyFitness: Built for Families. Powered by AI. Track food, fitness, water, and health — together.

项目地址:https://gitcode.com/gh_mirrors/sp/SparkyFitness
点击查看免费下载

导读

SparkyFitness 是一个横跨前端(Vite + React)、后端(Node.js + Express)、移动端(React Native + Expo)与 Garmin 微服务(Python)的多仓组件项目。本文以仓库开发者文档 docs/src/developer/testing.md 为核心骨架,结合 .github/workflows/ci-tests.yml 及各子项目的真实配置与测试用例,系统讲解四个组件的本地测试命令、统一的 Mock 编写规范、基于路径变更检测的 CI 流水线,以及覆盖率报告的处理方式。读完本文,你将掌握如何在本地快速跑通任意组件的测试、如何按仓库既有模式编写可维护的前端组件测试,以及理解 CI 中"只测受影响组件"和数据库迁移双重校验的设计思路。

一、测试技术栈总览

SparkyFitness 的测试体系按组件选用两套主流工具链:

组件技术栈测试框架关键配置位置
SparkyFitnessFrontendVite + React + TypeScriptJest(ts-jest + jsdom)package.json、setupTests.ts
SparkyFitnessServerNode.js + Express + TypeScriptVitestpackage.json、vitest.config.ts
SparkyFitnessMobileReact Native + ExpoJest(jest-expo preset)package.json、jest.setup.js
SparkyFitnessGarminPython 微服务pytest(+ unittest 兼容)requirements.txt、tests/test_daily_calories.py

从源码结构看,这种"前端/移动端用 Jest、后端用 Vitest、Python 服务用 pytest"的分工,既保持了各组件生态内最成熟的测试实践(如前端 jest-dom 匹配器、后端 Vitest 的 ESM 原生支持),又让每个 CI Job 拥有独立的依赖与运行环境,互不干扰。

二、本地运行测试:四个组件的命令清单

各组件脚本定义在其自身的 package.json(或 Garmin 的依赖清单)中,以下命令与文档一致,并标注了每个命令在配置文件中的真实出处:

# Frontend (Vite + React) —— 对应 SparkyFitnessFrontend/package.json cd SparkyFitnessFrontend pnpm test # Run tests in watch mode(对应 "test": "jest") pnpm test:ci # Run tests once with coverage(对应 "test:ci": "jest --ci --coverage --maxWorkers=2") # Backend (Node.js + Express) —— 对应 SparkyFitnessServer/package.json cd SparkyFitnessServer pnpm test # Run tests in watch mode(实际为 "test": "vitest run","test:watch": "vitest") pnpm test:ci # Run tests once with coverage("test:ci": "vitest run --coverage --reporter=verbose") # Mobile (React Native + Expo) —— 对应 SparkyFitnessMobile/package.json cd SparkyFitnessMobile npm run test:run # Run tests once("test:run": "jest") npm run test:ci # Run tests once with coverage("test:ci": "jest --ci --coverage --maxWorkers=2") # Garmin Microservice (Python) cd SparkyFitnessGarmin pytest --cov=. --cov-report=html

几点值得注意的细节:

  • CI 模式的共性:三个 JS/TS 组件在test:ci中都使用了--ci标志(前端与移动端还带--maxWorkers=2限制并行 Worker 数,避免 CI 资源争抢),后端则改用--reporter=verbose输出更详细的失败信息。
  • Watch 模式的差异:前端与移动端的pnpm test/npm run test默认进入 Jest watch 模式;后端文档中写的pnpm test实际执行vitest run(单次运行),如需监听则使用pnpm test:watch(即vitest)。从 SparkyFitnessServer/package.json 的 scripts 定义可以看出这一差别。
  • 验证链:CI 在跑测试前还会执行各组的validate脚本——前端为typecheck && lint && format:check && knip,后端为typecheck && lint && format:check,移动端则在 i18n 与肌肉图生成校验之外叠加 typecheck、lint、knip 与原生本地化检查。测试与静态检查在流水线中是两道并行的质量闸门。

三、CI 工作流:路径感知的按需测试

仓库的 CI 流水线定义在 .github/workflows/ci-tests.yml,在 pull request 以及推送到main分支时触发。与"每次全量跑所有测试"的朴素做法不同,该流水线借助 dorny/paths-filter 做路径变更检测,只对实际改动的组件运行测试,从而显著压缩 PR 的等待时间。

触发条件与组件映射表

工作流最外层通过paths限定触发范围——只有四个组件目录、移动端/iOS 相关文件或工作流自身发生变化时才会启动:

组件触发路径包管理器测试命令对应 CI Job
FrontendSparkyFitnessFrontend/**pnpmpnpm run test:cifrontend-tests
MobileSparkyFitnessMobile/**npmnpm run test:cimobile-tests
ServerSparkyFitnessServer/**pnpmpnpm run test:ciserver-tests
GarminSparkyFitnessGarmin/**pippytestgarmin-tests

注:Mobile 一列文档标注 npm,但实际工作流中移动端 Job 仍使用pnpm install --frozen-lockfile安装依赖,测试命令则为pnpm run test:ci(见 ci-tests.yml 的mobile-tests步骤),与 SparkyFitnessMobile/package.json 中定义的脚本一致;此处以工作流实际内容为准。

两阶段执行模型

流水线由第一个changesJob 和四个/六个后续测试 Job 组成,测试 Job 均通过needs: changes与if: needs.changes.outputs.<component> == 'true'做条件门控:

  1. changes(Detect Changes):actions/checkout@v4检出代码后,用dorny/paths-filter@v2按五组过滤器(frontend、mobile、server、garmin、migrations)计算哪些组件有改动,并将结果以 job outputs 形式暴露。
  2. 按需测试 Job:frontend-tests、mobile-tests、server-tests、garmin-tests各自只在自己的目录working-directory下执行;migration-check与migration-upgrade-check则仅在迁移相关文件变化时启动(详见下文)。

各测试 Job 的通用流程可概括为:actions/checkout@v4→pnpm/action-setup@v4安装 pnpm →actions/setup-node@v4配置指定 Node 版本并缓存 pnpm 依赖(cache-dependency-path: pnpm-lock.yaml)→pnpm install --frozen-lockfile锁定版本安装 → 运行validate(类型检查、Lint、格式化)→ 运行test:ci并输出覆盖率 →actions/upload-artifact@v4上传 coverage 目录,retention-days: 7保留 7 天。

各 Job 的独有细节

  • Node 版本:前端、后端、迁移 Job 使用 Node 24,移动端使用 Node 20(见各 Job 的setup-node步骤),与各自依赖的运行时要求匹配。
  • 后端测试的密钥处理(server-tests):Job 环境特意不设置SPARKY_FITNESS_API_ENCRYPTION_KEY与BETTER_AUTH_SECRET,而是由 vitest.config.ts 在每次运行时用crypto.randomBytes生成随机回退值。这样仓库中不落任何密钥字面量,避免 GitGuardian 等密钥扫描器在每次 push 时报警。后端 Job 还设置了SKIP_RLS_MATRIX: "1",因为该 Job 没有数据库,RLS 权限矩阵测试会被跳过,留待专门的迁移 Job 在真实 Postgres 上验证。
  • Garmin Job:使用actions/setup-python@v5(Python 3.12 + pip 缓存),先pip install -r requirements.txt再补装pytest pytest-cov,执行pytest --cov=. --cov-report=xml --cov-report=html;当目录中不存在测试时打印提示跳过,且该步骤配置了continue-on-error: true,覆盖率上传仍通过if: always()保证执行。

数据库迁移的双重校验

这是流水线中值得单独讲透的部分。migration-check(Fresh-install Migrations)与migration-upgrade-check(Upgrade-path Migrations)共享同一个migrations过滤条件,覆盖路径包括SparkyFitnessServer/db/migrations/**、rls_policies.sql、grantPermissions.ts、dbMigrations.ts、applyRlsPolicies.ts、initializeDatabase.ts及相关集成测试文件。两个 Job 都通过services拉起postgres:18.3-alpine容器(POSTGRES_DB: sparky_test,健康检查pg_isready):

  • migration-check(全新安装路径):在空库上并行启动两个pnpm run test:migrations进程(tests/migrate.script.ts入口),验证并发初始化时数据库锁与幂等机制的正确性;随后依次执行 RLS 权限矩阵、Strava 清理、OIDC 提供方、Better Auth schema(对应历史上 #2469/#2470 认证中断事故的回归测试)、登录限流、provider 同步声明等集成测试,并固定SPARKY_FITNESS_FRONTEND_URL=http://localhost:3004以保证 Better Auth 实例构建方式一致。
  • migration-upgrade-check(升级路径):fetch-depth: 0拉取完整历史,先用git checkout "${BASE_SHA}"把基础分支的 SQL 迁移文件换入并跑一遍test:migrations,再换回 PR 的迁移文件跑第二遍。由于schema_migrations表在两次运行间保留,第二遍只会应用新增迁移——恰好模拟"存量部署平滑升级"的真实场景,专门捕获那些只在已填充数据的 schema 上才会暴露的迁移问题。

四、测试文件布局

文档给出了各组件测试目录的组织方式,结合仓库实际文件结构可进一步确认:

SparkyFitnessFrontend/ src/tests/ setupTests.ts # 全局测试初始化(jest-dom、polyfills) test-utils.tsx # renderWithClient 测试渲染辅助 stubs/ # betterAuth、react-markdown 等第三方模块桩 components/ # 组件测试,与组件领域一一对应 MealBuilder.test.tsx MealManagement.test.tsx MealPlanCalendar.test.tsx SparkyFitnessServer/ tests/ # 后端单元与集成测试(*.test.ts,被 vitest include 捕获) migrate.script.ts # 数据库迁移执行脚本(test:migrations 入口) SparkyFitnessMobile/ __tests__/ components/ # 移动端组件测试 hooks/ # 自定义 Hook 测试 services/ # 服务层测试 screens/ # 屏幕级测试 localization/ # i18n 本地化测试

从源码看,前端测试集中在src/tests/而非与组件文件同目录,后端则统一放在tests/下由 vitest.config.ts 的include: ['**/tests/**/*.test.ts']收集;移动端 Jest 配置还通过testPathIgnorePatterns排除了__tests__/helpers/、__tests__/hooks/queryTestUtils.ts等辅助文件,避免辅助代码被误判为测试。

五、编写前端测试:统一的 Mock 模式

文档强调"所有前端组件测试遵循一致的 Mock 策略",仓库中最具代表性的例子是 MealBuilder.test.tsx(该文件中对waitFor/renderWithClient/initialFoods的组合使用共出现 45 处,是前端测试密集区的典型样本)。标准流程如下:

// 1. Mock i18n —— 返回回退字符串或翻译 key jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, defaultValueOrOpts?: string | Record<string, unknown>) => { if (typeof defaultValueOrOpts === 'string') return defaultValueOrOpts; if (defaultValueOrOpts && typeof defaultValueOrOpts === 'object' && 'defaultValue' in defaultValueOrOpts) { return defaultValueOrOpts.defaultValue as string; } return key; }, }), })); // 2. Mock contexts jest.mock('@/contexts/ActiveUserContext', () => ({ useActiveUser: () => ({ activeUserId: 'test-user-id' }), })); jest.mock('@/contexts/PreferencesContext', () => ({ usePreferences: () => ({ loggingLevel: 'debug', itemDisplayLimit: 100 }), })); // 3. Mock toast jest.mock('@/hooks/use-toast', () => ({ toast: jest.fn() })); // 4. Mock logging jest.mock('@/utils/logging', () => ({ debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), })); // 5. Mock services with trackable fns —— 用可断言的 mock 函数包一层,便于后续断言调用 const mockGetMeals = jest.fn(); jest.mock('@/services/mealService', () => ({ getMeals: (...args: unknown[]) => mockGetMeals(...args), }));

仓库真实测试中的进阶变体

对照 MealBuilder.test.tsx 的开头部分,可以看到文档模式的落地细节:

  • i18n mock 补全了initReactI18next:除useTranslation外还导出了{ type: '3rdParty', init: () => {} },避免组件初始化 i18n 实例时报错。
  • Context mock 携带业务默认值:PreferencesContext的 mock 额外返回nutrientDisplayPreferences(含view_group: 'quick_info'与可见营养项数组)、energyUnit: 'kcal'与convertEnergy,说明 mock 需覆盖被测组件实际读取的全部字段。
  • API 服务按模块整体 mock:如jest.mock('@/api/Foods/meals', ...)同时提供createMeal、updateMeal、getMealById三个可跟踪函数。
  • 复杂子组件 stub 化:FoodUnitSelector、FoodSearchDialog等子组件被替换为返回带data-testid的简单 div,将被测组件与子组件实现彻底隔离。
  • beforeEach(() => jest.clearAllMocks())保证用例之间互不污染。

约定与测试辅助

文档列出的约定在仓库中得到一一印证:

  • 测试文件与其组件领域同放于src/tests/components/,按*.test.tsx命名;
  • 使用@testing-library/react进行渲染与断言(test-utils.tsx 提供了renderWithClient辅助,内部创建retry: false的QueryClient并包裹QueryClientProvider,同时挂载 Query/MutationCache 的全局错误 toast 处理);
  • 用waitFor等待异步操作(如await waitFor(() => expect(screen.getByLabelText('Total Servings')).toBeInTheDocument()),等待 API 调用后的 UI 状态更新);
  • 通过initialFoods之类的 props 直接注入数据,避免依赖交互型子组件(MealBuilder即以initialFoods={sampleFoods}注入样本食物数据)。

六、移动端测试环境:jest-expo 与全局桩

移动端测试的复杂度主要来自大量原生模块。其 Jest 配置(SparkyFitnessMobile/package.json 的jest字段)使用jest-expopreset 与 jsdom 环境,并通过一份数百行的 jest.setup.js 集中桩掉无法在 Node 中运行的原生能力:

  • 标准库 polyfill:TextEncoder/TextDecoder(Expo "winter" 运行时按需安装 whatwg-url 需要它们);
  • 本地化与系统 API:expo-localization固定返回en-US,expo-application/expo-constants提供固定版本号;
  • 健康数据桥:@kingstinct/react-native-healthkit的读写与授权 API 全部 mock(写回保存返回带uuid的样本对象,确保 UUID 跟踪断言真实有效),react-native-health-connect的权限、读取、聚合 API 亦全部桩化;
  • 动画与手势:react-native-reanimated、react-native-gesture-handler、react-native-keyboard-controller提供链式可调用的桩实现,保证拖拽排序、手势等交互代码在单元测试中可安全执行;
  • 第三方渲染库:victory-native、@shopify/react-native-skia、react-native-maps、@gorhom/bottom-sheet渲染为带testID的 View,供断言"画了什么";
  • i18n 生产实例:文件末尾加载真实的src/localization/i18n并以initImmediate: false同步初始化,让被隔离渲染的组件也能解析英文默认文案而非返回原始 key。

同时,moduleNameMapper将@workspace/shared指向../shared/src/index.ts(跨包共享代码直接在测试中解析 TS 源码),并通过精心编写的transformIgnorePatterns白名单让 react-native、expo、@react-navigation、@workspace/shared、zod、better-auth 等 ESM 包通过 babel 转换。

七、后端测试:Vitest 与运行时密钥策略

后端使用 Vitest 且采用globals: true(测试文件中可直接使用 describe/it/expect),环境为 Node。两个值得关注的设计:

  1. 跨包共享代码解析:resolve.alias将@workspace/shared指向../shared/src,与前端、移动端的 moduleNameMapper 策略一致,共享包源码被直接纳入各组件测试。
  2. 随机密钥回退(vitest.config.ts):测试进程需要SPARKY_FITNESS_API_ENCRYPTION_KEY(32 字节 hex)与BETTER_AUTH_SECRET(base64),配置在读取仓库根.env后,若缺失则用crypto.randomBytes生成每次运行不同的随机值注入test.env。因为这些值只在进程内用于加解密与 cookie 签名,不持久化、不跨运行复用,随机生成完全安全,同时保证仓库内没有任何密钥字面量——这正是"不把秘密写进代码"的工程实践在测试层的体现。

后端测试脚本一览(SparkyFitnessServer/package.json):

"test": "vitest run", // 单次运行 "test:watch": "vitest", // 监听模式 "test:coverage": "vitest run --coverage", "test:ci": "vitest run --coverage --reporter=verbose", "test:migrations": "tsx tests/migrate.script.ts"

test:migrations专供 CI 的迁移 Job 调用,配合tests/migrate.script.ts与 Postgres 服务容器完成空库初始化与升级路径验证。

八、Garmin 微服务测试:pytest

Garmin 微服务(Python)使用 pytest 并带覆盖率输出。仓库中的 tests/test_daily_calories.py 是典型样例:以unittest.TestCase组织用例,通过sys.path.insert将父目录加入模块搜索路径后直接导入service.py的业务函数。测试覆盖了:

  • 规范字段解析:activeKilocalories/bmrKilocalories/totalKilocalories正确映射为active_calories/bmr_calories/total_calories浮点值;
  • 别名回退:activeCalories/bmrCalories/totalCalories等已知别名在规范字段缺失时生效,且字符串数字可被正确转换;
  • 边界值:保留合法的0,剔除None、inf与 "not-a-number" 等缺失或非法值。

本地运行方式:pytest --cov=. --cov-report=html;CI 中则执行pytest --cov=. --cov-report=xml --cov-report=html,并将htmlcov/作为构建产物上传。

九、覆盖率报告与产物管理

运行test:ci后,各组件会在自身目录下生成coverage/(前端与移动端;Garmin 为htmlcov/)。CI 中这些目录通过actions/upload-artifact@v4上传为构建产物,retention-days: 7表示保留 7 天后自动清理:

Job上传产物名上传路径保留天数
frontend-testsfrontend-coverageSparkyFitnessFrontend/coverage/7
mobile-testsmobile-coverageSparkyFitnessMobile/coverage/7
server-testsserver-coverageSparkyFitnessServer/coverage/7
garmin-testsgarmin-coverageSparkyFitnessGarmin/htmlcov/7

上传步骤均使用if: always(),即使测试失败也会保留覆盖率产物,便于事后在 GitHub Actions 的 Artifacts 中下载分析。本地查看覆盖率时,可直接打开 HTML 报告(前端/移动端coverage/lcov-report/index.html,Garminhtmlcov/index.html)逐文件浏览未覆盖分支。

十、编写测试的通用建议

综合文档约定与仓库实践,可沉淀出以下可复用的编写准则:

  1. Mock 一切外部依赖,只测被测单元:i18n、Context、toast、日志、API 服务、复杂子组件逐一桩化;对需要断言的服务调用,用可跟踪的jest.fn包裹并在测试内断言调用参数与次数。
  2. 用真实辅助函数包裹 Provider:如renderWithClient统一注入 QueryClient(关闭重试),避免每个测试重复样板代码。
  3. 异步一律waitFor:API 调用、状态更新等异步 UI 断言放在waitFor中,配合screen查询器与 jest-dom 匹配器(toBeInTheDocument、toBeEnabled)书写。
  4. 通过 props 注入数据:优先用initialFoods这类输入属性驱动组件,而不是依赖子组件交互来"间接"准备数据。
  5. 保持 CI 与本地一致:本地先跑validate(typecheck/lint/format)再跑test:ci,与 CI 的检查顺序保持一致,避免"本地绿、CI 红"。
  6. 涉及数据库的改动务必关注迁移 Job:任何db/migrations/**、RLS 策略或迁移运行器文件的改动都会触发两个需要真实 Postgres 的集成验证,本地可借助 docker-compose 中的数据库服务先行演练。

延伸阅读

  • 开发者文档目录:架构(architecture.md)、数据库(database.md)、权限层级(database-security-tiers.md)、troubleshooting.md 等配套文档。
  • 前端全局测试初始化:jsdom polyfills(matchMedia、ResizeObserver、PointerEvent)与 react-leaflet/leaflet 桩。
  • 移动端全局测试初始化:Expo 生态原生模块的完整 mock 清单。
  • 后端测试运行器配置:随机密钥回退与@workspace/shared别名。
  • Garmin 测试样例:Python 侧单元测试的编写范式。
  • 后端
  • 前端
  • 移动开发

【免费下载链接】SparkyFitness

SparkyFitness: Built for Families. Powered by AI. Track food, fitness, water, and health — together.

项目地址:https://gitcode.com/gh_mirrors/sp/SparkyFitness
点击查看免费下载

相关推荐

上一篇:LightTable快捷键大全:提升编码速度的50个必备快捷键
下一篇:Minerva模型可视化工具使用教程:从特征提取到热力图分析

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询