mini-ui-testing-standards.md 11 KB

Mini UI 包测试规范

版本: 1.0 创建日期: 2025-12-26 适用范围: 所有 Taro 小程序 UI 包测试

概述

本文档规定了 Mini UI 包的测试编写规范,基于故事 017.003 的实施经验总结。遵循这些规范可以确保测试的一致性、可维护性和正确性。

核心原则

1. 使用 Jest,不是 Vitest

重要: Mini UI 包使用 Jest 测试框架,不是 Vitest。

// jest.config.cjs
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  // ...
}

2. 使用共享的 mini-testing-utils

关键原则: 不要在每个 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',
  }
}

3. 使用真实的 React Query

关键原则: 使用真实的 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 配置规范

标准 jest.config.cjs 模板

// 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. 组件测试模板

/**
 * 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()
  })
})

2. 页面组件测试模板

/**
 * 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()
    })
  })
})

3. Mock RPC 响应规范

// ✅ 正确: 使用 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 使用规范

// ✅ 正确: 直接使用 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
  }
}))

测试最佳实践

1. RPC 类型推断验证

使用真实的 React Query 来验证 RPC 类型推断:

// ✅ 正确: 测试中使用真实的 RPC 类型
const mockData = {
  name: '张三',
  age: 35,
  // 类型错误会在编译时被捕获
  wrongField: 'type error'  // TypeScript 会报错
}

; (apiClient.route.$get as jest.Mock).mockResolvedValue(
  createMockResponse(200, mockData)
)

2. 异步测试

使用 waitFor 处理异步状态更新:

it('应该在加载完成后渲染数据', async () => {
  ; (apiClient.route.$get as jest.Mock).mockResolvedValue(
    createMockResponse(200, mockData)
  )

  render(<Page />, { wrapper })

  await waitFor(() => {
    expect(screen.getByText('data')).toBeInTheDocument()
  })
})

3. 用户交互测试

it('应该处理点击事件', () => {
  const { fireEvent } = require('@testing-library/react')

  render(<Component />)

  const button = screen.getByText('Click me')
  fireEvent.click(button)

  expect(Taro.someApi).toHaveBeenCalled()
})

常见错误

错误 1: 重写 Taro mock

// ❌ 错误: 在测试文件中重写 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

// ❌ 错误: Mock React Query
jest.mock('@tanstack/react-query', () => ({
  useQuery: jest.fn(() => ({ data: mockData }))
}))

// ✅ 正确: 使用真实的 React Query
const wrapper = createTestWrapper()
render(<Page />, { wrapper })

错误 3: 在 UI 包源代码中使用别名

// ❌ 错误: 源代码中使用别名
import { apiClient } from '@/api'
import { Component } from '@/components'

// ✅ 正确: 源代码中使用相对路径
import { apiClient } from '../api'
import { Component } from '../components'

错误 4: 忘记清理 mock

// ❌ 错误: 忘记清理 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

mini-testing-utils 扩展

如果需要添加新的 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