Expo应用的测试策略有哪些?如何进行单元测试和端到端测试?
Expo应用的测试是保障代码质量和团队协作效率的基石。实际项目中,测试策略的选择直接影响迭代速度和线上稳定性。本文从单元测试、组件测试、端到端测试三个层面,梳理 Expo 项目中经过实战验证的测试方案和踩坑经验。
Jest 单元测试:从工具函数到 Hooks
Jest 是 Expo 官方推荐的测试框架,配合 jest-expo preset 可以开箱即用,无需手动配置复杂的 transform 规则。
安装依赖:
bashnpx expo install jest-expo jest --dev
在 package.json 中添加脚本和配置:
json{ "scripts": { "test": "jest", "test:ci": "jest --coverage --ci" }, "jest": { "preset": "jest-expo" } }
工具函数测试是最直接的切入点,输入输出明确,不依赖任何 UI 渲染:
typescript// utils/format.test.ts import { formatDate, calculateTotal } from '../format'; describe('formatDate', () => { it('格式化有效日期', () => { expect(formatDate(new Date('2024-01-15'))).toBe('2024-01-15'); }); it('空值返回空字符串', () => { expect(formatDate(null)).toBe(''); }); it('非法输入抛出错误', () => { expect(() => formatDate('invalid')).toThrow(); }); });
自定义 Hooks 测试需要 @testing-library/react-hooks,它在测试环境中模拟 React 的渲染周期:
typescript// hooks/useUser.test.ts import { renderHook, act } from '@testing-library/react-hooks'; import { useUser } from './useUser'; describe('useUser', () => { it('加载用户数据', async () => { const { result, waitFor } = renderHook(() => useUser('123')); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.user.name).toBe('John'); }); it('网络错误时设置 error 状态', async () => { jest.spyOn(global, 'fetch').mockRejectedValue(new Error('Network')); const { result, waitFor } = renderHook(() => useUser('999')); await waitFor(() => expect(result.current.error).toBeTruthy()); }); });
踩坑提醒: 如果项目使用了 react-native-reanimated、expo-linear-gradient 等原生模块,Jest 运行时会报模块找不到的错误。解决方式是在 jest.config.js 中统一 mock:
javascriptmodule.exports = { preset: 'jest-expo', setupFilesAfterSetup: ['./jest.setup.js'], };
javascript// jest.setup.js jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock') ); jest.mock('expo-linear-gradient', () => { const { View } = require('react-native'); return { LinearGradient: View }; });
React Native Testing Library:面向用户行为的组件测试
组件测试的核心原则是「测试用户能看到什么、能做什么」,而不是测试组件内部状态和方法。@testing-library/react-native 正是基于这个理念设计的。
安装:
bashnpx expo install @testing-library/react-native --dev
交互测试示例——登录表单:
typescript// components/LoginForm.test.tsx import { render, fireEvent, waitFor } from '@testing-library/react-native'; import LoginForm from './LoginForm'; describe('LoginForm', () => { it('输入合法数据后提交', async () => { const onSubmit = jest.fn(); const { getByPlaceholderText, getByText } = render( <LoginForm onSubmit={onSubmit} /> ); fireEvent.changeText(getByPlaceholderText('邮箱'), 'user@example.com'); fireEvent.changeText(getByPlaceholderText('密码'), 'password123'); fireEvent.press(getByText('登录')); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith({ email: 'user@example.com', password: 'password123', }); }); }); it('空邮箱时显示错误提示', () => { const { getByText, queryByText } = render(<LoginForm onSubmit={jest.fn()} />); fireEvent.press(getByText('登录')); expect(getByText('请输入邮箱')).toBeTruthy(); expect(queryByText('请输入密码')).toBeNull(); }); });
导航测试是 Expo 项目中的高频场景。组件内如果用了 useNavigation,测试时需要包裹 NavigationContainer:
typescriptimport { NavigationContainer } from '@react-navigation/native'; const renderWithNavigation = (ui: React.ReactElement) => { return render(<NavigationContainer>{ui}</NavigationContainer>); };
AsyncSelect / 搜索框测试要注意 waitFor 的使用,避免断言在异步操作完成前执行:
typescriptit('搜索时展示建议列表', async () => { const { getByPlaceholderText, findAllByText } = render(<SearchSelect />); fireEvent.changeText(getByPlaceholderText('搜索'), 'React'); const items = await findAllByText(/React/); expect(items.length).toBeGreaterThan(0); });
端到端测试:Maestro vs Detox
端到端测试模拟真实用户操作,验证从启动到完成某个流程的完整链路。Expo 项目中目前有两个主流选择。
Maestro:轻量高效的 E2E 方案
Maestro 是近年来在 React Native 社区快速崛起的 E2E 工具,配置简单,YAML 驱动,适合快速上手。
安装:
bashcurl -Ls "https://get.maestro.mobile.dev" | bash
编写测试用例(YAML 格式):
yaml# .maestro/login.yaml appId: com.example.myapp --- - launchApp - assertVisible: "邮箱" - inputText: "user@example.com" id: "email-input" - inputText: "password123" id: "password-input" - tapOn: "登录" - assertVisible: "欢迎回来"
运行:
bashmaestro test .maestro/login.yaml
Maestro 的优势在于不需要写原生构建配置,测试用例可读性强,非开发人员也能理解和维护。对于 Expo 项目,配合 eas build 生成的开发构建即可运行测试。
Detox:灰盒测试的经典方案
Detox 由 Wix 团队开发,在 React Native 生态中使用广泛。它的「灰盒」机制能同步等待异步操作完成,减少 flaky test。
安装和初始化:
bashnpm install --save-dev detox detox-cli detox init -r jest
Detox 要求先生成原生代码,所以需要先执行 npx expo prebuild。
配置文件 .detoxrc.js:
javascriptmodule.exports = { testRunner: { args: { '$0': 'jest', config: 'e2e/jest.config.js' }, }, apps: { 'ios.debug': { type: 'ios.app', binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/MyApp.app', build: 'xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build', }, }, devices: { simulator: { type: 'ios.simulator', device: { type: 'iPhone 15' } }, }, configurations: { 'ios.sim.debug': { device: 'simulator', app: 'ios.debug' }, }, };
E2E 测试用例:
typescript// e2e/login.e2e.ts describe('登录流程', () => { beforeAll(async () => { await device.launchApp({ newInstance: true }); }); it('合法凭据登录成功', async () => { await element(by.id('email-input')).typeText('user@example.com'); await element(by.id('password-input')).typeText('password123'); await element(by.id('login-button')).tap(); await expect(element(by.id('welcome-screen'))).toBeVisible(); }); it('错误凭据显示提示', async () => { await element(by.id('email-input')).typeText('wrong@example.com'); await element(by.id('password-input')).typeText('wrong'); await element(by.id('login-button')).tap(); await expect(element(by.text('账号或密码错误'))).toBeVisible(); }); });
选型建议: 新项目或团队 E2E 经验不多,优先选 Maestro,学习成本低、维护简单;已有 Detox 基础设施或需要细粒度同步控制的团队,继续用 Detox。
测试金字塔与覆盖率策略
测试不是越多越好,投入产出比最高的分布是:
- 单元测试占 70%——覆盖工具函数、Hooks、状态管理逻辑,运行快、维护成本低
- 组件测试占 20%——覆盖关键交互流程(表单提交、列表筛选、弹窗关闭等)
- E2E 测试占 10%——只覆盖核心业务链路(注册、登录、支付、下单),每个用例运行耗时是单元测试的 50-100 倍
覆盖率配置:
json{ "collectCoverage": true, "coverageReporters": ["text", "lcov"], "coverageThreshold": { "global": { "branches": 70, "functions": 70, "lines": 70, "statements": 70 } } }
注意:覆盖率达到 70% 即可,追求 100% 会导致大量测试代码维护负担,反而拖慢迭代速度。
CI/CD 集成
将测试接入 CI 是保证每次提交质量的关键一步。
GitHub Actions 配置:
yamlname: Test on: [push, pull_request] jobs: unit-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' - run: npm ci - run: npm run test:ci e2e-test: runs-on: macos-latest needs: unit-test steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' - run: npm ci - run: npx expo prebuild - run: npm run e2e:ios
单元测试和 E2E 测试分两个 Job,单元测试先跑,通过后才触发耗时的 E2E 测试。
Mock 策略与常见陷阱
测试中的 Mock 是一把双刃剑,过度 Mock 会让测试失去意义。
应该 Mock 的:
- 网络请求(用
jest.spyOn或msw) - 第三方 SDK 的初始化和调用
AsyncStorage、SecureStore等持久化存储
不应该 Mock 的:
- 被测组件自身的子组件(这属于内部实现细节)
- React 的 hooks(如
useState、useEffect)
网络请求 Mock 示例:
typescriptimport { rest } from 'msw'; import { setupServer } from 'msw/node'; const server = setupServer( rest.get('/api/user', (_, res, ctx) => res(ctx.json({ id: 1, name: 'John' })) ) ); beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close());
快照测试的取舍: 快照测试适合稳定的 UI 组件(如 Button、Card 等基础组件),但对频繁变动的业务页面,快照测试几乎每次都会失败,维护成本高于收益。建议只在组件库中使用快照测试。
实战经验总结
- 先测工具函数,再测组件,最后补 E2E——这条路径学习曲线最平缓,产出最快
- E2E 测试只覆盖主流程——登录、支付、核心操作各一条用例足够,不要试图用 E2E 覆盖所有边界情况
- 测试代码也是代码——保持测试文件的命名、结构和复用性,抽取公共的 renderWithProvider 工具函数
- CI 中跑测试,本地不强制——开发时快速迭代,提交时由 CI 兜底,避免测试成为开发的阻碍
- 关注测试失败的原因,而非数量——一个经常 flaky 的 E2E 测试比没有测试更糟糕,遇到不稳定用例优先修复或删除