|
|
@@ -0,0 +1,491 @@
|
|
|
+# Mini UI 包测试规范
|
|
|
+
|
|
|
+**版本**: 1.0
|
|
|
+**创建日期**: 2025-12-26
|
|
|
+**适用范围**: 所有 Taro 小程序 UI 包测试
|
|
|
+
|
|
|
+## 概述
|
|
|
+
|
|
|
+本文档规定了 Mini UI 包的测试编写规范,基于故事 017.003 的实施经验总结。遵循这些规范可以确保测试的一致性、可维护性和正确性。
|
|
|
+
|
|
|
+## 核心原则
|
|
|
+
|
|
|
+### 1. 使用 Jest,不是 Vitest
|
|
|
+
|
|
|
+**重要**: Mini UI 包使用 **Jest** 测试框架,不是 Vitest。
|
|
|
+
|
|
|
+```javascript
|
|
|
+// jest.config.cjs
|
|
|
+module.exports = {
|
|
|
+ preset: 'ts-jest',
|
|
|
+ testEnvironment: 'jsdom',
|
|
|
+ // ...
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 2. 使用共享的 mini-testing-utils
|
|
|
+
|
|
|
+**关键原则**: 不要在每个 UI 包中重写 Taro mock,直接使用 `@d8d/mini-testing-utils` 提供的共享 mock。
|
|
|
+
|
|
|
+```javascript
|
|
|
+// jest.config.cjs
|
|
|
+module.exports = {
|
|
|
+ setupFilesAfterEnv: ['@d8d/mini-testing-utils/testing/setup'],
|
|
|
+ moduleNameMapper: {
|
|
|
+ '^@tarojs/taro$': '@d8d/mini-testing-utils/testing/taro-api-mock.ts',
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 3. 使用真实的 React Query
|
|
|
+
|
|
|
+**关键原则**: 使用真实的 React Query(不要 mock),以便验证 RPC 类型推断。
|
|
|
+
|
|
|
+```typescript
|
|
|
+// ✅ 正确: 使用真实的 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>
|
|
|
+ )
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```typescript
|
|
|
+// ❌ 错误: 不要 mock React Query
|
|
|
+jest.mock('@tanstack/react-query', () => ({
|
|
|
+ useQuery: jest.fn()
|
|
|
+}))
|
|
|
+```
|
|
|
+
|
|
|
+## Jest 配置规范
|
|
|
+
|
|
|
+### 标准 jest.config.cjs 模板
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 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
|
|
|
+```
|
|
|
+
|
|
|
+## 测试编写规范
|
|
|
+
|
|
|
+### 1. 组件测试模板
|
|
|
+
|
|
|
+```typescript
|
|
|
+/**
|
|
|
+ * 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'
|
|
|
+
|
|
|
+declare const describe: any
|
|
|
+declare const it: any
|
|
|
+declare const expect: any
|
|
|
+declare const jest: any
|
|
|
+declare const beforeEach: any
|
|
|
+
|
|
|
+// 使用 mini-testing-utils 提供的 Taro mock
|
|
|
+// jest.mock('@tarojs/taro') 在 testing/setup.ts 中已配置
|
|
|
+
|
|
|
+describe('ComponentName', () => {
|
|
|
+ const mockProps: PropsInterface = {
|
|
|
+ // mock props
|
|
|
+ }
|
|
|
+
|
|
|
+ beforeEach(() => {
|
|
|
+ // 清理 Taro API mock
|
|
|
+ ;(Taro.someApi as jest.Mock).mockClear()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should render correctly', () => {
|
|
|
+ render(<ComponentName {...mockProps} />)
|
|
|
+ expect(screen.getByText('expected text')).toBeInTheDocument()
|
|
|
+ })
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+### 2. 页面组件测试模板
|
|
|
+
|
|
|
+```typescript
|
|
|
+/**
|
|
|
+ * 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'
|
|
|
+
|
|
|
+declare const describe: any
|
|
|
+declare const it: any
|
|
|
+declare const expect: any
|
|
|
+declare const jest: any
|
|
|
+declare const beforeEach: any
|
|
|
+
|
|
|
+// 使用 mini-testing-utils 提供的 Taro mock
|
|
|
+// jest.mock('@tarojs/taro') 在 testing/setup.ts 中已配置
|
|
|
+
|
|
|
+// 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('should render page with correct data', 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()
|
|
|
+ })
|
|
|
+ })
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+### 3. Mock RPC 响应规范
|
|
|
+
|
|
|
+```typescript
|
|
|
+// ✅ 正确: 使用 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 })
|
|
|
+)
|
|
|
+```
|
|
|
+
|
|
|
+### 4. Taro API 使用规范
|
|
|
+
|
|
|
+```typescript
|
|
|
+// ✅ 正确: 直接使用 Taro API,不需要自定义 mock
|
|
|
+import Taro from '@tarojs/taro'
|
|
|
+
|
|
|
+describe('Component', () => {
|
|
|
+ beforeEach(() => {
|
|
|
+ // 清理 mock
|
|
|
+ ;(Taro.showToast as jest.Mock).mockClear()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('should call Taro API', () => {
|
|
|
+ render(<Component />)
|
|
|
+ expect(Taro.showToast).toHaveBeenCalledWith({ title: '成功' })
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// ❌ 错误: 不要在测试文件中自定义 Taro mock
|
|
|
+jest.mock('@tarojs/taro', () => ({
|
|
|
+ default: {
|
|
|
+ showToast: jest.fn() // 错误!应该使用 mini-testing-utils 的共享 mock
|
|
|
+ }
|
|
|
+}))
|
|
|
+```
|
|
|
+
|
|
|
+## 测试最佳实践
|
|
|
+
|
|
|
+### 1. RPC 类型推断验证
|
|
|
+
|
|
|
+使用真实的 React Query 来验证 RPC 类型推断:
|
|
|
+
|
|
|
+```typescript
|
|
|
+// ✅ 正确: 测试中使用真实的 RPC 类型
|
|
|
+const mockData = {
|
|
|
+ name: '张三',
|
|
|
+ age: 35,
|
|
|
+ // 类型错误会在编译时被捕获
|
|
|
+ wrongField: 'type error' // TypeScript 会报错
|
|
|
+}
|
|
|
+
|
|
|
+; (apiClient.route.$get as jest.Mock).mockResolvedValue(
|
|
|
+ createMockResponse(200, mockData)
|
|
|
+)
|
|
|
+```
|
|
|
+
|
|
|
+### 2. 异步测试
|
|
|
+
|
|
|
+使用 `waitFor` 处理异步状态更新:
|
|
|
+
|
|
|
+```typescript
|
|
|
+it('should render data after loading', async () => {
|
|
|
+ ; (apiClient.route.$get as jest.Mock).mockResolvedValue(
|
|
|
+ createMockResponse(200, mockData)
|
|
|
+ )
|
|
|
+
|
|
|
+ render(<Page />, { wrapper })
|
|
|
+
|
|
|
+ await waitFor(() => {
|
|
|
+ expect(screen.getByText('data')).toBeInTheDocument()
|
|
|
+ })
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+### 3. 用户交互测试
|
|
|
+
|
|
|
+```typescript
|
|
|
+it('should handle click event', () => {
|
|
|
+ const { fireEvent } = require('@testing-library/react')
|
|
|
+
|
|
|
+ render(<Component />)
|
|
|
+
|
|
|
+ const button = screen.getByText('Click me')
|
|
|
+ fireEvent.click(button)
|
|
|
+
|
|
|
+ expect(Taro.someApi).toHaveBeenCalled()
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+## 常见错误
|
|
|
+
|
|
|
+### 错误 1: 重写 Taro mock
|
|
|
+
|
|
|
+```typescript
|
|
|
+// ❌ 错误: 在测试文件中重写 Taro mock
|
|
|
+jest.mock('@tarojs/taro', () => ({
|
|
|
+ default: {
|
|
|
+ showToast: jest.fn()
|
|
|
+ }
|
|
|
+}))
|
|
|
+
|
|
|
+// ✅ 正确: 直接导入使用
|
|
|
+import Taro from '@tarojs/taro'
|
|
|
+// mock 在 mini-testing-utils/testing/setup.ts 中已配置
|
|
|
+```
|
|
|
+
|
|
|
+### 错误 2: Mock React Query
|
|
|
+
|
|
|
+```typescript
|
|
|
+// ❌ 错误: Mock React Query
|
|
|
+jest.mock('@tanstack/react-query', () => ({
|
|
|
+ useQuery: jest.fn(() => ({ data: mockData }))
|
|
|
+}))
|
|
|
+
|
|
|
+// ✅ 正确: 使用真实的 React Query
|
|
|
+const wrapper = createTestWrapper()
|
|
|
+render(<Page />, { wrapper })
|
|
|
+```
|
|
|
+
|
|
|
+### 错误 3: 在 UI 包源代码中使用别名
|
|
|
+
|
|
|
+```typescript
|
|
|
+// ❌ 错误: 源代码中使用别名
|
|
|
+import { apiClient } from '@/api'
|
|
|
+import { Component } from '@/components'
|
|
|
+
|
|
|
+// ✅ 正确: 源代码中使用相对路径
|
|
|
+import { apiClient } from '../api'
|
|
|
+import { Component } from '../components'
|
|
|
+```
|
|
|
+
|
|
|
+### 错误 4: 忘记清理 mock
|
|
|
+
|
|
|
+```typescript
|
|
|
+// ❌ 错误: 忘记清理 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)
|
|
|
+ })
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+## 测试执行
|
|
|
+
|
|
|
+```bash
|
|
|
+# 运行所有测试
|
|
|
+cd mini-ui-packages/<package-name> && pnpm test
|
|
|
+
|
|
|
+# 运行特定测试
|
|
|
+pnpm test --testNamePattern="ComponentName"
|
|
|
+
|
|
|
+# 生成覆盖率报告
|
|
|
+pnpm test:coverage
|
|
|
+
|
|
|
+# 监听模式
|
|
|
+pnpm test:watch
|
|
|
+```
|
|
|
+
|
|
|
+## mini-testing-utils 扩展
|
|
|
+
|
|
|
+如果需要添加新的 Taro API mock,在 `mini-testing-utils/testing/taro-api-mock.ts` 中添加:
|
|
|
+
|
|
|
+```typescript
|
|
|
+// 1. 添加 mock 函数声明
|
|
|
+export const mockNewApi = jest.fn()
|
|
|
+
|
|
|
+// 2. 在默认导出中添加
|
|
|
+export default {
|
|
|
+ // ...
|
|
|
+ newApi: mockNewApi
|
|
|
+}
|
|
|
+
|
|
|
+// 3. 在命名导出中添加
|
|
|
+export {
|
|
|
+ // ...
|
|
|
+ mockNewApi as newApi
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## 参考资料
|
|
|
+
|
|
|
+- [Jest 官方文档](https://jestjs.io/)
|
|
|
+- [Testing Library](https://testing-library.com/)
|
|
|
+- [Taro 官方文档](https://taro-docs.jd.com/)
|
|
|
+- [mini-ui-package-standards.md](./mini-ui-package-standards.md) - Mini UI 包开发规范
|
|
|
+- [testing-strategy.md](./testing-strategy.md) - 通用测试策略
|
|
|
+
|
|
|
+## 版本历史
|
|
|
+
|
|
|
+| 版本 | 日期 | 描述 | 作者 |
|
|
|
+|------|------|------|------|
|
|
|
+| 1.0 | 2025-12-26 | 初始版本,基于故事 017.003 实施经验 | Dev Agent |
|