☰
SparkyFitness 分层测试模式实战指南:Route / Service / Repository / RLS / 前端与移动端测试全景
2026/10/10 2:33:41 网站建设 项目流程
  • 后端
  • 前端
  • 移动开发

【免费下载链接】SparkyFitness

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

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

本文是 SparkyFitness 仓库(Built for Families, Powered by AI 的全栈健康应用,包含 SparkyFitnessServer、SparkyFitnessFrontend 与 SparkyFitnessMobile 三端)的测试方法论指南,源自 agent-docs/testing-patterns.md。它回答了开发中反复出现的问题:新增一个功能或修复一个 bug 时,究竟该写哪种测试、写到哪个目录、用哪个框架、mock 哪一层?读完本文,你将掌握一套可直接落地的"按层测试"模式——用 Vitest + supertest 钉死 HTTP 契约,用 Vitest 单测业务编排,用假客户端断言 SQL 形状,用真实数据库验证 RLS 行级权限,再用 Jest(ts-jest / jest-expo)覆盖前端 React Query 与移动端 API 层。

Overview:各层该测什么、在哪测

整个仓库的测试策略可以浓缩为一张分层对照表。核心原则是"在哪一层引入复杂度,就在哪一层写测试":HTTP 契约在 Route 层验证、业务编排在 Service 层验证、SQL 形状在 Repository 层验证、行级权限只能在真实数据库上验证。

层框架测什么怎么测位置
Route(端点)Vitest + supertest请求/响应、状态码、校验错误mock 掉 service/repository 与鉴权中间件,只测 HTTP 契约SparkyFitnessServer/tests/<domain>Routes.test.ts
Service(业务逻辑)Vitest逻辑、编排、错误处理mock 掉 repository,测工作流SparkyFitnessServer/tests/<domain>Service.test.ts
Repository(数据库)VitestSQL 查询形状、行映射mock 掉poolManager的getClient(用假客户端),断言 SQL 与参数SparkyFitnessServer/tests/<domain>Repository.test.ts
RLS Policy(权限)Vitest + 真实测试库行过滤、权限继承、授权代理(delegation)以 app 角色通过getClient()连接,播种 delegate 授权,执行查询(以存活 DB 探针为门禁)SparkyFitnessServer/tests/rlsPermissionMatrix.integration.test.ts
React Query(前端)Jest + @testing-libraryhook 状态、缓存失效、错误处理用jest.mockmock API 模块,测查询生命周期SparkyFitnessFrontend/src/tests/hooks/*.test.tsx(扁平存放)
Component(UI)Jest + @testing-library渲染、用户交互、表单提交mock API hooks,测用户流程SparkyFitnessFrontend/src/tests/components/
Integration(移动端)Jest(jest-expo)API 调用、状态更新、权限mockapiFetch,配合真实 auth contextSparkyFitnessMobile/__tests__/services/(按层组织)

有一个关键环境事实必须牢记:服务端测试套件是 Vitest,而前端(ts-jest/jsdom)与移动端(jest-expo)套件是 Jest。因此在两处前端代码里要使用jest.mock/jest.fn,而不是vi.*系列 API,混用会导致测试无法运行。另外,绝大多数 Repository 测试都 mock 掉了poolManager,整套测试里唯一的真实数据库 + RLS 往返测试是rlsPermissionMatrix.integration.test.ts。文档中的示例代码是示意性的——动手之前请打开对应的真实测试文件获取确切的测试脚手架(harness)。

服务端测试:Route、Service、Repository 与 RLS

Route 测试:钉死端点 HTTP 契约

测什么:HTTP 请求/响应、状态码、错误处理、请求体校验。

真实的 Route 测试不会启动整个服务器,也不会触碰数据库。它们会构造一个一次性的express()应用,只挂载被测路由,再用vi.mock(...)mock 掉 repository/service 层以及鉴权/权限中间件,从而把测试隔离在纯 HTTP 契约层面。真实参考:SparkyFitnessServer/tests/medicationRoutes.test.ts。

// SparkyFitnessServer/tests/medicationRoutes.test.ts (shape) import { vi, describe, it, expect, beforeEach } from 'vitest'; import request from 'supertest'; import express from 'express'; import medicationRepository from '../models/medicationRepository.js'; import medicationRoutes from '../routes/v2/medicationRoutes.js'; vi.mock('../models/medicationRepository.js'); // Guards are stubbed to pass-through so the test exercises the handler, not auth: vi.mock('../middleware/checkPermissionMiddleware.js', () => ({ default: vi.fn(() => (_req, _res, next) => next()), })); vi.mock('../middleware/onBehalfOfMiddleware.js', () => ({ default: (_req, _res, next) => next(), })); const app = express(); app.use(express.json()); app.use((req, _res, next) => { (req as any).userId = 'user-1'; next(); }); app.use('/api/v2/medications', medicationRoutes); describe('Medication Routes', () => { beforeEach(() => vi.clearAllMocks()); it('POST creates a medication entry', async () => { vi.mocked(medicationRepository.create).mockResolvedValue({ id: 'med-123' }); const response = await request(app) .post('/api/v2/medications') .send({ name: 'Insulin', dosage: '10mg' }); expect(response.status).toBe(201); expect(response.body.id).toBe('med-123'); }); it('rejects an invalid body with 400', async () => { const response = await request(app) .post('/api/v2/medications') .send({ dosage: '' }); // fails the Zod route schema expect(response.status).toBe(400); }); });

打开真实文件可以看到这个"形状"被严格执行,且更复杂:medicationRoutes.test.ts开头 mock 了多达 8 个数据访问模块(medicationRepository、medicationPenRepository、injectionRepository、titrationRepository、medicationEntryRepository、medicationDisplayPreferenceRepository)、glp1Service、permissionUtils.canAccessUserData,甚至用vi.mock('../db/poolManager.js')提供返回假{ query, release }的getClient来支撑"是否为补充剂"的子类型查询,并用executedSql数组记录每次执行的 SQL 供断言。

关键模式:

  • 只在本地express()应用上挂载被测路由,绝不 importSparkyFitnessServer.ts(避免启动整个服务与真实中间件链);
  • 用vi.mockmock 掉 repository/service 层与鉴权/权限中间件——不碰真实 DB、不碰真实 token;
  • v2 路由挂载在/api/v2/...前缀下;真实中间件中 auth 是userId=形式的 cookie(这里被 stub 掉了)。真实测试正是通过.set('Cookie', ['userId=testUser'])注入身份;
  • 同时覆盖成功路径与 Zod 校验(400)错误路径。真实测试还验证了查询参数透传,例如GET /api/v2/medications?glp1Only=true会以expect.objectContaining({ glp1Only: true })断言listMedications('testUser', ...)收到的第二参。

Service 测试:隔离业务编排与错误处理

测什么:逻辑、计算、错误处理、跨模块编排。

// SparkyFitnessServer/tests/medicationService.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest'; import medicationService from '../services/medicationService.js'; import medicationRepository from '../models/medicationRepository.js'; // Mock the repository vi.mock('../models/medicationRepository.js'); describe('Medication Service', () => { beforeEach(() => { vi.clearAllMocks(); }); it('should calculate next dose time correctly', async () => { const result = medicationService.calculateNextDoseTime({ scheduleType: 'daily', lastDoseTime: new Date('2026-07-08T08:00:00Z'), dosageIntervalHours: 12, }); expect(result).toEqual(new Date('2026-07-08T20:00:00Z')); }); it('should validate dosage before creating entry', async () => { vi.spyOn(medicationRepository, 'create').mockResolvedValue({ id: 'med-123', dosage: '10mg', }); const result = await medicationService.logMedication( 'user-1', { medicationId: 'med-1', dosage: '10mg', takenAt: new Date() } ); expect(medicationRepository.create).toHaveBeenCalled(); expect(result.dosage).toBe('10mg'); }); it('should reject invalid dosage', async () => { const invalidDosage = { medicationId: 'med-1', dosage: 'invalid-format', takenAt: new Date(), }; await expect( medicationService.logMedication('user-1', invalidDosage) ).rejects.toThrow('Invalid dosage format'); }); it('should handle repository errors gracefully', async () => { vi.spyOn(medicationRepository, 'create').mockRejectedValue( new Error('Database error') ); await expect( medicationService.logMedication('user-1', { medicationId: 'med-1', dosage: '10mg', takenAt: new Date(), }) ).rejects.toThrow('Failed to log medication'); }); });

关键模式:

  • mock 掉 repository 层(绝不调用真实 DB);
  • 测试纯逻辑、计算、校验分支;
  • 用.rejects.toThrow()覆盖错误路径;
  • 验证 repository 是否以正确参数被调用(toHaveBeenCalledWith)。

从仓库现状看,Service 测试是服务端测试体量最大的部分,覆盖了热量计算(calorieCalculationService.test.ts)、TDEE 自适应(adaptiveTdeeService.test.ts)、酒精周统计(alcoholWeekService.test.ts)等计算密集型领域,同一模式在数百个Service.test.ts文件中反复出现。

Repository 测试:断言 SQL 形状与行映射

仓库里明确区分了两类数据库相关测试:

1. 普通 Repository 测试(<domain>Repository.test.ts)——最常见形态。它们vi.mock('../db/poolManager.js'),用假{ query: vi.fn() }客户端,断言 SQL 字符串与参数以及行到对象的映射。不运行真实数据库。参考:measurementRepository.test.ts。

以 medicationRepository.test.ts 为例,其脚手架是:

vi.mock('../db/poolManager', () => ({ getClient: vi.fn(), })); beforeEach(() => { mockClient = { query: vi.fn(), release: vi.fn() }; // @ts-expect-error mocked in the module mock above getClient.mockResolvedValue(mockClient); mockClient.query.mockClear(); });

随后用mockClient.query.mock.calls[0]直接解构出[sql, values]并断言二者:

const [sql, values] = mockClient.query.mock.calls[0]; expect(sql).toContain('UPDATE medication_schedules'); expect(sql).toContain('schedule_type_id = $3'); expect(sql).toContain('WHERE id = $1 AND user_id = $2'); expect(values).toEqual([scheduleId, userId, 'daily', '09:00', null, null]);

这种"断言 SQL 片段 + 断言参数数组"的方式,能精准捕获占位符顺序错乱、NULL 透传丢失、WHERE条件漏掉user_id这类经典回归。真实测试中还覆盖了空 patch 时不发UPDATE而改发SELECT的分支。

2. RLS 集成测试(rlsPermissionMatrix.integration.test.ts)——全仓库唯一真实数据库测试。它通过getClient()以非 superuser 的 app 角色连接,播种用户与family_access授权,断言行过滤;以存活 DB 探针为门禁,数据库不可达时自动 skip,且不启动服务器。它在文件头部即解释了自己存在的原因:Row-Level Security 是在 Postgres 内部强制的,用 mock 的 pool 无法测试(其余套件 mock 掉db/poolManager.js恰恰绕过了 RLS)。

RLS 集成测试的骨架(注意真实的family_access列名,以及必须加引号的保留字user表):

// SparkyFitnessServer/tests/rlsPermissionMatrix.integration.test.ts (shape) import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import medicationRepository from '../models/medicationRepository.js'; import { getClient, getSystemClient } from '../db/poolManager.js'; describe('Medication Repository (with RLS)', () => { let userId: string; let otherUserId: string; let medicationId: string; beforeEach(async () => { const systemClient = getSystemClient(); // Create test users ("user" is a reserved word — must be quoted) const users = await systemClient.query( `INSERT INTO "user" (id, email) VALUES ($1, $2), ($3, $4) RETURNING id`, ['user-1', 'user1@test.com', 'user-2', 'user2@test.com'] ); userId = users.rows[0].id; otherUserId = users.rows[1].id; // Create medication for user 1 const meds = await systemClient.query( `INSERT INTO medications (id, user_id, name) VALUES ($1, $2, $3) RETURNING id`, ['med-123', userId, 'Insulin'] ); medicationId = meds.rows[0].id; systemClient.release(); }); it('should return only user\'s medications (RLS enforced)', async () => { // Query as user 1 const client = getClient(userId, userId); const result = await medicationRepository.findByUserId(userId, client); client.release(); expect(result).toHaveLength(1); expect(result[0].name).toBe('Insulin'); }); it('should NOT return other user\'s medications (RLS enforced)', async () => { // Query as user 2 requesting user 1's medications const client = getClient(otherUserId, otherUserId); const result = await medicationRepository.findByUserId(userId, client); client.release(); // RLS policy should filter this — result should be empty expect(result).toHaveLength(0); }); it('should allow family delegate to read with permission', async () => { const systemClient = getSystemClient(); // Grant family access. Columns are owner_user_id / family_user_id, and the // permissions are a JSONB boolean map — not a permission_type string. await systemClient.query( `INSERT INTO family_access (owner_user_id, family_user_id, access_permissions, is_active) VALUES ($1, $2, $3, TRUE)`, [userId, otherUserId, { can_manage_medications: true }] ); systemClient.release(); // Query as user 2 with delegation const client = getClient(userId, otherUserId); // active context = user 1, authenticated = user 2 const result = await medicationRepository.findByUserId(userId, client); client.release(); // Delegate with can_manage_medications should see the owner's medications expect(result).toHaveLength(1); }); afterEach(async () => { const systemClient = getSystemClient(); await systemClient.query(`DELETE FROM medications WHERE id = $1`, [medicationId]); await systemClient.query(`DELETE FROM family_access WHERE owner_user_id = $1`, [userId]); await systemClient.query(`DELETE FROM "user" WHERE id IN ($1, $2)`, [userId, otherUserId]); systemClient.release(); }); });

关键模式:

  • 用getClient(userId, authenticatedUserId)设置 RLS 上下文(两个参数分别是"数据归属人"与"实际认证用户",二者不同即触发 delegation 语义);
  • 测试 owner 专属访问;
  • 测试带权限的家庭授权代理;
  • 验证 RLS 正确过滤(user 2 不该看到 user 1 的数据);
  • getSystemClient()只用于 setup/teardown。

getClient的底层行为可以从 db/poolManager.ts 的源码得到印证:它从 app 角色连接池_getRawAppPool().connect()取出连接后,立即执行SELECT public.set_app_context($1, $2)设置 RLS 上下文——第一个参数是被访问数据的 user id,第二个是实际认证用户(缺省时回退到 AsyncLocalStorage 中的authenticatedUserId,再回退到 userId 本身);上下文设置失败时通过client.release(true)销毁连接而不是归还,防止脏上下文泄漏给下一个调用方。真实的rlsPermissionMatrix.integration.test.ts(1484 行)远比示例宏大:它用describe.runIf(RUN)结合存活 DB 探针(2 秒超时的独立 pg.Client 连接探测SELECT 1)做门禁,SKIP_RLS_MATRIX=1环境变量可强制跳过;Part A 断言每张启用 RLS 的表都被归入正确的权限域、其策略引用了预期的 helper(能抓出"checkin 表被错误接到 diary 策略"这类分类错误),Part B 逐个断言各 RLS helper 对每种权限的 allow/deny 行为(抓出删除死键变体等逻辑回归)。

前端测试(Jest):React Query Hook 与组件

React Query Hook 测试

测什么:hook 状态、缓存失效、错误处理。

前端测试使用Jest(ts-jest/jsdom),所以要使用jest.mock/jest.mocked——不是vi.*。Hook 测试是 src/tests/hooks/ 下的扁平文件,扩展名必须是.tsx(JSX 在.ts文件里无法被 ts-jest 编译)。真实参考:useOnboarding.test.tsx。

// SparkyFitnessFrontend/src/tests/hooks/useMedications.test.tsx import { renderHook, waitFor } from '@testing-library/react'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; import { useMedications } from '@/hooks/useMedications'; import * as medicationsApi from '@/api/Medications/medications'; // Mock the API module (jest globals are ambient — no import needed) jest.mock('@/api/Medications/medications'); const createTestQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); describe('useMedications hook', () => { beforeEach(() => { jest.clearAllMocks(); }); it('should fetch medications on mount', async () => { jest.mocked(medicationsApi.fetchMedications).mockResolvedValue([ { id: 'med-1', name: 'Insulin' }, { id: 'med-2', name: 'Aspirin' }, ]); const wrapper = ({ children }: any) => ( <QueryClientProvider client={createTestQueryClient()}> {children} </QueryClientProvider> ); const { result } = renderHook(() => useMedications(), { wrapper }); // Initially loading expect(result.current.isLoading).toBe(true); // Wait for data await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(result.current.data).toHaveLength(2); expect(result.current.data[0].name).toBe('Insulin'); }); it('should handle fetch errors', async () => { jest.mocked(medicationsApi.fetchMedications).mockRejectedValue(new Error('API Error')); const wrapper = ({ children }: any) => ( <QueryClientProvider client={createTestQueryClient()}> {children} </QueryClientProvider> ); const { result } = renderHook(() => useMedications(), { wrapper }); await waitFor(() => { expect(result.current.isError).toBe(true); }); expect(result.current.error?.message).toBe('API Error'); }); });

关键模式:

  • 测试内用QueryClientProvider包裹 hook;
  • mock 掉 API 调用;
  • 覆盖 loading/success/error 三态;
  • 异步更新用waitFor等待。

真实文件 useOnboarding.test.tsx 展示了更完整的实战写法:除了jest.mock('@/api/Onboarding/onboarding'),还 mock 了react-i18next的useTranslation(返回t回调直接回退 fallback 文本,避免 i18n 初始化干扰),并把 API 函数逐一转型为jest.MockedFunction后赋值给mockedGetOnboardingStatus等命名变量——这种写法让每个用例的断言意图更清晰,也方便在beforeEach里统一jest.clearAllMocks()。

组件测试

测什么:渲染、用户交互、表单提交。

// SparkyFitnessFrontend/src/tests/components/MedicationForm.test.tsx import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import MedicationForm from '@/pages/Medications/MedicationForm'; import * as medicationsApi from '@/api/Medications/medications'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; jest.mock('@/api/Medications/medications'); describe('MedicationForm component', () => { beforeEach(() => { jest.clearAllMocks(); }); it('should render form fields', () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( <QueryClientProvider client={queryClient}> <MedicationForm /> </QueryClientProvider> ); expect(screen.getByLabelText(/medication name/i)).toBeInTheDocument(); expect(screen.getByLabelText(/dosage/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); }); it('should submit form with valid data', async () => { jest.mocked(medicationsApi.createMedication).mockResolvedValue({ id: 'med-123', name: 'Insulin' }); const user = userEvent.setup(); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( <QueryClientProvider client={queryClient}> <MedicationForm /> </QueryClientProvider> ); await user.type(screen.getByLabelText(/medication name/i), 'Insulin'); await user.type(screen.getByLabelText(/dosage/i), '10mg'); await user.click(screen.getByRole('button', { name: /save/i })); await waitFor(() => { expect(medicationsApi.createMedication).toHaveBeenCalledWith( expect.objectContaining({ name: 'Insulin', dosage: '10mg' }) ); }); }); it('should show validation error for empty name', async () => { const user = userEvent.setup(); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( <QueryClientProvider client={queryClient}> <MedicationForm /> </QueryClientProvider> ); await user.click(screen.getByRole('button', { name: /save/i })); await waitFor(() => { expect(screen.getByText(/medication name is required/i)).toBeInTheDocument(); }); }); });

关键模式:

  • 组件用 provider 包裹渲染(QueryClientProvider等);
  • 用userEvent模拟贴近真实的用户交互(相比fireEvent更接近浏览器行为);
  • 同时覆盖成功与校验失败两条路径;
  • 验证 API 以正确数据被调用(expect.objectContaining只关心关键字段)。

组件测试统一存放在 src/tests/components/ 下,与页面/组件源码目录一一对应。

移动端测试(Jest / jest-expo):Hook 与 API

测什么:API 调用、状态更新、健康数据同步。

移动端测试使用Jest(jest-expo)与相对路径导入(@/别名虽已配置但在src/中未被使用)。HTTP 辅助函数的导出名是apiFetch(不存在apiClient导出——这是新手最容易踩的坑)。测试按层组织在__tests__/services/、__tests__/hooks/等目录下。示例使用真实的mealsApi。

// SparkyFitnessMobile/__tests__/services/mealsApi.test.ts import { fetchMeals } from '../../src/services/api/mealsApi'; import * as apiClient from '../../src/services/api/apiClient'; jest.mock('../../src/services/api/apiClient'); describe('meals API', () => { beforeEach(() => { jest.clearAllMocks(); }); it('fetches meals via apiFetch', async () => { jest.mocked(apiClient.apiFetch).mockResolvedValue([{ id: 'meal-1' }]); const result = await fetchMeals(); expect(apiClient.apiFetch).toHaveBeenCalledWith( expect.objectContaining({ url: expect.stringContaining('/meals') }) ); expect(result).toHaveLength(1); }); it('propagates auth errors', async () => { jest.mocked(apiClient.apiFetch).mockRejectedValue({ status: 401 }); await expect(fetchMeals()).rejects.toMatchObject({ status: 401 }); }); });

关键模式:

  • mock 掉apiClient模块,并断言apiFetch——它接收一个 options 对象而不是位置参数;
  • 使用相对路径导入与jest.mock/jest.mocked——这是 Jest 项目,不是 Vitest;
  • 同时覆盖成功与错误响应。

从仓库结构看,移动端测试体量很大:tests/services/ 下有约 90 个服务测试文件,tests/hooks/ 下有 100 余个 hook 测试,tests/components/ 下还有 98 个组件测试文件,覆盖了水合记录、营养、健康数据同步等核心业务域。

决策速查:什么时候用哪种测试

场景使用避免
测试 HTTP 契约(状态码、响应形状)Route 测试(Vitest + supertest)Service 测试(不测 HTTP)
测试业务逻辑Service 测试(mock repo)Route 测试(与 HTTP 耦合过重)
测试 SQL 查询形状Repository 测试(mockpoolManager)Service 测试(层级不对)
测试 RLS 行过滤rlsPermissionMatrix.integration.test.ts(真实 DB)Service/Route 测试(无法验证 RLS)
测试 React hook 状态Hook 测试(renderHook)组件测试(setup 过重)
测试组件渲染与交互组件测试(render + userEvent)Hook 测试(不测 UI)
测试家庭访问权限RLS 集成测试(getClient 带 delegation)Route 测试(无法验证 RLS 过滤)

这张表的取舍逻辑很简单:每一层只验证自己职责范围内的行为,并把更底层的能力视为已由下层测试保证的黑盒。测试 HTTP 契约时不在乎业务细节(mock service),测业务时不在乎 SQL(mock repo),测 SQL 形状时不在乎 RLS 语义(mock client),而 RLS 语义本身交给唯一的真实数据库测试去兜底。前端同理:hook 测试通过renderHook验证查询状态机,组件测试用render + userEvent验证交互流,二者互不越界。

运行测试:三端命令速览

服务端(Vitest):

cd SparkyFitnessServer pnpm test # 运行全部测试(vitest run) pnpm exec vitest run tests/medication*.test.ts # 只跑特定测试文件 pnpm run test:coverage # 覆盖率报告

服务端 package.json 中的相关脚本还包括test:watch(vitest监听模式)、test:ci(vitest run --coverage --reporter=verbose)以及test:migrations(tsx tests/migrate.script.ts)。pnpm test在本地数据库可达时会把 rlsPermissionMatrix.integration.test.ts 一并纳入运行(凭据来自../.env),CI 的 migration-check 任务也会运行它,从而在 PR 合并前拦截 RLS 回归。

前端(Jest):

cd SparkyFitnessFrontend pnpm test # 运行全部测试(jest) pnpm test -- useMedications # 按测试名/路径过滤

移动端(Jest / jest-expo):

cd SparkyFitnessMobile pnpm test:run -- --watchman=false --runInBand # 运行全部测试 pnpm exec jest --watchman=false __tests__/services # 只跑某一层目录

--watchman=false在无 watchman 的环境下避免文件监听报错,--runInBand单进程串行执行以规避 RN/Jest 环境的资源竞争。移动端 package.json 还提供test:coverage(jest --coverage)与test:ci(jest --ci --coverage --maxWorkers=2)等脚本。

从模式到实践:给新功能/新 Bug 的落地建议

结合 testing-patterns.md 与仓库现状,编写测试时建议按如下顺序自查:

  1. 先确定变更发生的层:新增/修改了路由 → Route 测试;动了业务编排或计算 → Service 测试;改了 SQL 或行映射 → Repository 测试;动了 RLS 策略或授权语义 → RLS 集成测试;动了前端 hook → Hook 测试;动了组件交互 → 组件测试;动了移动端 API/hook → 移动端按层测试。
  2. 严格遵守框架边界:服务端只用 Vitest 的vi.*;前端(SparkyFitnessFrontend)与移动端(SparkyFitnessMobile)只用 Jest 的jest.*,混用会导致 mock 失效或直接报错。
  3. 不要把真实依赖拖进测试:Route 测试不要 import SparkyFitnessServer.ts 启动整机;Service 测试不要连库;Repository 测试不要连真实 pool(除了 RLS 集成测试)。
  4. 涉及家庭授权(delegation)时优先 RLS 集成测试:getClient(userId, authenticatedUserId)两个参数不一致即触发代理语义,只有真实数据库能验证"被授权人可见、无授权不可见"。
  5. 异步断言一律用waitFor/.rejects.toThrow(),避免裸断言时序竞态。

这套按层测试体系的价值在于:它把"测试写在哪一层"从个人偏好问题变成了有明确依据的工程决策——每一层的 mock 边界恰好对应着该层对下一层的抽象边界,从而让整套测试既可以飞快运行(绝大多数用例不碰数据库),又能对最敏感的权限语义保留真实数据库回归网。

  • 后端
  • 前端
  • 移动开发

【免费下载链接】SparkyFitness

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

项目地址:https://gitcode.com/gh_mirrors/sp/SparkyFitness
点击查看免费下载
上一篇:Calibre格式转换:单本书、整库、传设备三步走
下一篇:流程图变成一条链接发出去:Mermaid Live Editor 3 分钟跑起来

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

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

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

立即咨询