- 后端
- 前端
- 移动开发
【免费下载链接】SparkyFitness
SparkyFitness: Built for Families. Powered by AI. Track food, fitness, water, and health — together.
本文是 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(数据库) | Vitest | SQL 查询形状、行映射 | 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-library | hook 状态、缓存失效、错误处理 | 用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 context | SparkyFitnessMobile/__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 与仓库现状,编写测试时建议按如下顺序自查:
- 先确定变更发生的层:新增/修改了路由 → Route 测试;动了业务编排或计算 → Service 测试;改了 SQL 或行映射 → Repository 测试;动了 RLS 策略或授权语义 → RLS 集成测试;动了前端 hook → Hook 测试;动了组件交互 → 组件测试;动了移动端 API/hook → 移动端按层测试。
- 严格遵守框架边界:服务端只用 Vitest 的
vi.*;前端(SparkyFitnessFrontend)与移动端(SparkyFitnessMobile)只用 Jest 的jest.*,混用会导致 mock 失效或直接报错。 - 不要把真实依赖拖进测试:Route 测试不要 import SparkyFitnessServer.ts 启动整机;Service 测试不要连库;Repository 测试不要连真实 pool(除了 RLS 集成测试)。
- 涉及家庭授权(delegation)时优先 RLS 集成测试:
getClient(userId, authenticatedUserId)两个参数不一致即触发代理语义,只有真实数据库能验证"被授权人可见、无授权不可见"。 - 异步断言一律用
waitFor/.rejects.toThrow(),避免裸断言时序竞态。
这套按层测试体系的价值在于:它把"测试写在哪一层"从个人偏好问题变成了有明确依据的工程决策——每一层的 mock 边界恰好对应着该层对下一层的抽象边界,从而让整套测试既可以飞快运行(绝大多数用例不碰数据库),又能对最敏感的权限语义保留真实数据库回归网。
- 后端
- 前端
- 移动开发
【免费下载链接】SparkyFitness
SparkyFitness: Built for Families. Powered by AI. Track food, fitness, water, and health — together.
相关推荐
如何快速让 GitHub Desktop 对接 GitLab、Bitbucket 与 Azure DevOps:三大平台集成完全指南
如何快速让 GitHub Desktop 对接 GitLab、Bitbucket 与 Azure DevOps:三大平台集成完全指南 GitHub Deskto
桌面应用版本控制开发工具10 分钟把 Win11 调回 Win10 的样子:ExplorerPatcher 界面定制完整攻略
10 分钟把 Win11 调回 Win10 的样子:ExplorerPatcher 界面定制完整攻略 Win11 的界面争议从发布那天就没停过:任务栏锁死在中间
桌面应用系统编程STF 测试指南:基于 Karma 与 Protractor 的前端单元测试和端到端测试实战
STF 测试指南:基于 Karma 与 Protractor 的前端单元测试和端到端测试实战 本文以 Smartphone Test Farm(STF)仓库的
测试后端前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考