版本: 1.0 创建日期: 2025-12-26 适用范围: 所有 Taro 小程序 UI 包测试
本文档规定了 Mini UI 包的测试编写规范,基于故事 017.003 的实施经验总结。遵循这些规范可以确保测试的一致性、可维护性和正确性。
重要: Mini UI 包使用 Jest 测试框架,不是 Vitest。
// jest.config.cjs
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
// ...
}
关键原则: 不要在每个 UI 包中重写 Taro mock,直接使用 @d8d/mini-testing-utils 提供的共享 mock。
// jest.config.cjs
module.exports = {
setupFilesAfterEnv: ['@d8d/mini-testing-utils/testing/setup'],
moduleNameMapper: {
'^@tarojs/taro$': '@d8d/mini-testing-utils/testing/taro-api-mock.ts',
}
}
关键原则: 使用真实的 React Query(不要 mock),以便验证 RPC 类型推断。
// ✅ 正确: 使用真实的 React Query
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const createTestWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity }
}
})
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
// ❌ 错误: 不要 mock React Query
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn()
}))
// jest.config.cjs
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
// 使用 mini-testing-utils 提供的共享 setup
setupFilesAfterEnv: ['@d8d/mini-testing-utils/testing/setup'],
moduleNameMapper: {
// 测试文件中的别名映射(仅用于测试文件)
'^@/(.*)$': '<rootDir>/src/$1',
'^~/(.*)$': '<rootDir>/tests/$1',
// Taro API 重定向到共享 mock
'^@tarojs/taro$': '@d8d/mini-testing-utils/testing/taro-api-mock.ts',
// 样式和文件映射
'\\.(css|less|scss|sass)$': '@d8d/mini-testing-utils/testing/style-mock.js',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'@d8d/mini-testing-utils/testing/file-mock.js'
},
testMatch: [
'<rootDir>/tests/**/*.spec.{ts,tsx}',
'<rootDir>/tests/**/*.test.{ts,tsx}'
],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/index.{ts,tsx}',
'!src/**/*.stories.{ts,tsx}'
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
testPathIgnorePatterns: [
'/node_modules/',
'/dist/',
'/coverage/'
],
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
'^.+\\.(js|jsx)$': 'babel-jest'
},
transformIgnorePatterns: [
'/node_modules/(?!(swiper|@tarojs)/)'
],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json']
}
mini-ui-packages/<package-name>/
└── tests/
├── unit/ # 单元测试
│ └── components/
│ ├── ComponentName.test.tsx
│ └── ...
└── pages/ # 页面组件测试
└── PageName/
└── PageName.test.tsx
/**
* ComponentName 组件测试
*/
import React from 'react'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import ComponentName, { PropsInterface } from '../../../src/components/ComponentName'
import Taro from '@tarojs/taro'
describe('ComponentName', () => {
const mockProps: PropsInterface = {
// mock props
}
beforeEach(() => {
// 清理 Taro API mock
;(Taro.someApi as jest.Mock).mockClear()
})
it('应该正确渲染', () => {
render(<ComponentName {...mockProps} />)
expect(screen.getByText('expected text')).toBeInTheDocument()
})
})
/**
* PageName 页面测试
* 使用真实的React Query和RPC类型验证
*/
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import PageName from '../../../src/pages/PageName/PageName'
import { apiClient } from '../../../src/api'
import { useRequireAuth } from '@d8d/xxx-auth-ui/hooks'
import Taro from '@tarojs/taro'
// Mock auth hooks
jest.mock('@d8d/xxx-auth-ui/hooks', () => ({
useRequireAuth: jest.fn()
}))
// Mock API client - 使用真实的RPC类型
jest.mock('../../../src/api', () => ({
apiClient: {
route: {
$get: jest.fn()
}
}
}))
const createMockResponse = <T,>(status: number, data: T) => ({
status,
ok: status >= 200 && status < 300,
json: async () => data
})
const createTestWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Infinity,
refetchOnWindowFocus: false
}
}
})
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
describe('PageName', () => {
beforeEach(() => {
jest.clearAllMocks()
// Mock useRequireAuth to do nothing (user is authenticated)
;(useRequireAuth as jest.Mock).mockImplementation(() => {})
// Reset Taro API mocks
;(Taro.setNavigationBarTitle as jest.Mock).mockClear()
;(Taro.showToast as jest.Mock).mockClear()
})
it('应该渲染带有正确数据的页面', async () => {
// Mock API calls - 使用符合RPC类型的响应
;(apiClient.route.$get as jest.Mock).mockResolvedValue(
createMockResponse(200, mockData)
)
const wrapper = createTestWrapper()
render(<PageName />, { wrapper })
await waitFor(() => {
expect(screen.getByText('expected text')).toBeInTheDocument()
})
})
})
// ✅ 正确: 使用 Mock 响应工具函数
const createMockResponse = <T,>(status: number, data: T) => ({
status,
ok: status >= 200 && status < 300,
json: async () => data
})
// 使用示例
; (apiClient.route.$get as jest.Mock).mockResolvedValue(
createMockResponse(200, { name: '张三', age: 35 })
)
// ✅ 正确: 直接使用 Taro API,不需要自定义 mock
import Taro from '@tarojs/taro'
describe('Component', () => {
beforeEach(() => {
// 清理 mock
;(Taro.showToast as jest.Mock).mockClear()
})
it('应该调用Taro API', () => {
render(<Component />)
expect(Taro.showToast).toHaveBeenCalledWith({ title: '成功' })
})
})
// ❌ 错误: 不要在测试文件中自定义 Taro mock
jest.mock('@tarojs/taro', () => ({
default: {
showToast: jest.fn() // 错误!应该使用 mini-testing-utils 的共享 mock
}
}))
使用真实的 React Query 来验证 RPC 类型推断:
// ✅ 正确: 测试中使用真实的 RPC 类型
const mockData = {
name: '张三',
age: 35,
// 类型错误会在编译时被捕获
wrongField: 'type error' // TypeScript 会报错
}
; (apiClient.route.$get as jest.Mock).mockResolvedValue(
createMockResponse(200, mockData)
)
使用 waitFor 处理异步状态更新:
it('应该在加载完成后渲染数据', async () => {
; (apiClient.route.$get as jest.Mock).mockResolvedValue(
createMockResponse(200, mockData)
)
render(<Page />, { wrapper })
await waitFor(() => {
expect(screen.getByText('data')).toBeInTheDocument()
})
})
it('应该处理点击事件', () => {
const { fireEvent } = require('@testing-library/react')
render(<Component />)
const button = screen.getByText('Click me')
fireEvent.click(button)
expect(Taro.someApi).toHaveBeenCalled()
})
// ❌ 错误: 在测试文件中重写 Taro mock
jest.mock('@tarojs/taro', () => ({
default: {
showToast: jest.fn()
}
}))
// ✅ 正确: 直接导入使用
import Taro from '@tarojs/taro'
// mock 在 mini-testing-utils/testing/setup.ts 中已配置
// ❌ 错误: Mock React Query
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(() => ({ data: mockData }))
}))
// ✅ 正确: 使用真实的 React Query
const wrapper = createTestWrapper()
render(<Page />, { wrapper })
// ❌ 错误: 源代码中使用别名
import { apiClient } from '@/api'
import { Component } from '@/components'
// ✅ 正确: 源代码中使用相对路径
import { apiClient } from '../api'
import { Component } from '../components'
// ❌ 错误: 忘记清理 mock
describe('Component', () => {
it('test 1', () => {
expect(Taro.showToast).toHaveBeenCalledTimes(1)
})
it('test 2', () => {
// test 1 的 mock 调用会影响 test 2
expect(Taro.showToast).toHaveBeenCalledTimes(1) // 可能失败
})
})
// ✅ 正确: 在 beforeEach 中清理 mock
describe('Component', () => {
beforeEach(() => {
; (Taro.showToast as jest.Mock).mockClear()
})
it('test 1', () => {
expect(Taro.showToast).toHaveBeenCalledTimes(1)
})
it('test 2', () => {
// mock 已清理,计数重新开始
expect(Taro.showToast).toHaveBeenCalledTimes(1)
})
})
# 运行所有测试
cd mini-ui-packages/<package-name> && pnpm test
# 运行特定测试
pnpm test --testNamePattern="ComponentName"
# 生成覆盖率报告
pnpm test:coverage
# 监听模式
pnpm test:watch
如果需要添加新的 Taro API mock,在 mini-testing-utils/testing/taro-api-mock.ts 中添加:
// 1. 添加 mock 函数声明
export const mockNewApi = jest.fn()
// 2. 在默认导出中添加
export default {
// ...
newApi: mockNewApi
}
// 3. 在命名导出中添加
export {
// ...
mockNewApi as newApi
}
| 版本 | 日期 | 描述 | 作者 |
|---|---|---|---|
| 1.0 | 2025-12-26 | 初始版本,基于故事 017.003 实施经验 | Dev Agent |