rwadurian/frontend/mobile-upgrade/docs/TESTING.md

707 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Mobile Upgrade Admin - 测试指南
## 测试架构概述
本项目采用分层测试策略,针对 Clean Architecture 的每一层设计相应的测试方案。
```
┌─────────────────────────────────────────────────────────────┐
│ E2E Tests │
│ (Playwright / Cypress) │
├─────────────────────────────────────────────────────────────┤
│ Integration Tests │
│ (React Testing Library) │
├─────────────────────────────────────────────────────────────┤
│ Unit Tests │
│ (Jest / Vitest) │
└─────────────────────────────────────────────────────────────┘
```
## 测试类型
| 测试类型 | 目标层 | 工具 | 覆盖率目标 |
|----------|--------|------|------------|
| 单元测试 | Domain, Application | Jest/Vitest | 80% |
| 集成测试 | Infrastructure, Presentation | React Testing Library | 70% |
| E2E 测试 | 全链路 | Playwright | 关键路径 |
## 测试环境配置
### 安装测试依赖
```bash
npm install -D jest @types/jest ts-jest
npm install -D @testing-library/react @testing-library/jest-dom
npm install -D @testing-library/user-event
npm install -D msw # Mock Service Worker
npm install -D playwright @playwright/test # E2E 测试
```
### Jest 配置
```javascript
// jest.config.js
const nextJest = require('next/jest')
const createJestConfig = nextJest({
dir: './',
})
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/app/**/*',
],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
}
module.exports = createJestConfig(customJestConfig)
```
### Jest Setup
```javascript
// jest.setup.js
import '@testing-library/jest-dom'
// Mock next/navigation
jest.mock('next/navigation', () => ({
useRouter() {
return {
push: jest.fn(),
replace: jest.fn(),
prefetch: jest.fn(),
}
},
usePathname() {
return ''
},
}))
```
## 单元测试
### Domain 层测试
Domain 层是纯 TypeScript测试最简单。
```typescript
// src/domain/entities/__tests__/version.test.ts
import { AppVersion, Platform } from '../version'
describe('AppVersion Entity', () => {
const mockVersion: AppVersion = {
id: 'test-id',
platform: 'android',
versionCode: 100,
versionName: '1.0.0',
buildNumber: '100',
downloadUrl: 'https://example.com/app.apk',
fileSize: '52428800',
fileSha256: 'abc123',
changelog: 'Test changelog',
isForceUpdate: false,
isEnabled: true,
minOsVersion: '8.0',
releaseDate: '2024-01-01',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
}
it('should have correct platform type', () => {
expect(['android', 'ios']).toContain(mockVersion.platform)
})
it('should have valid version code', () => {
expect(mockVersion.versionCode).toBeGreaterThan(0)
})
})
```
### Application 层测试
测试 Zustand Store 和 Hooks。
```typescript
// src/application/stores/__tests__/version-store.test.ts
import { act, renderHook } from '@testing-library/react'
import { useVersionStore } from '../version-store'
// Mock repository
jest.mock('@/infrastructure', () => ({
versionRepository: {
list: jest.fn(),
getById: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
toggle: jest.fn(),
upload: jest.fn(),
},
}))
import { versionRepository } from '@/infrastructure'
const mockVersionRepository = versionRepository as jest.Mocked<typeof versionRepository>
describe('useVersionStore', () => {
beforeEach(() => {
// Reset store state
useVersionStore.setState({
versions: [],
selectedVersion: null,
isLoading: false,
error: null,
filter: { includeDisabled: true },
})
jest.clearAllMocks()
})
describe('fetchVersions', () => {
it('should fetch versions successfully', async () => {
const mockVersions = [
{ id: '1', versionName: '1.0.0', platform: 'android' },
{ id: '2', versionName: '1.0.1', platform: 'android' },
]
mockVersionRepository.list.mockResolvedValue(mockVersions as any)
const { result } = renderHook(() => useVersionStore())
await act(async () => {
await result.current.fetchVersions()
})
expect(result.current.versions).toEqual(mockVersions)
expect(result.current.isLoading).toBe(false)
expect(result.current.error).toBeNull()
})
it('should handle fetch error', async () => {
mockVersionRepository.list.mockRejectedValue(new Error('Network error'))
const { result } = renderHook(() => useVersionStore())
await act(async () => {
await result.current.fetchVersions()
})
expect(result.current.versions).toEqual([])
expect(result.current.error).toBe('Network error')
})
})
describe('deleteVersion', () => {
it('should delete version and update list', async () => {
useVersionStore.setState({
versions: [
{ id: '1', versionName: '1.0.0' },
{ id: '2', versionName: '1.0.1' },
] as any,
})
mockVersionRepository.delete.mockResolvedValue()
const { result } = renderHook(() => useVersionStore())
await act(async () => {
await result.current.deleteVersion('1')
})
expect(result.current.versions).toHaveLength(1)
expect(result.current.versions[0].id).toBe('2')
})
})
describe('toggleVersion', () => {
it('should toggle version enabled status', async () => {
useVersionStore.setState({
versions: [
{ id: '1', versionName: '1.0.0', isEnabled: true },
] as any,
})
mockVersionRepository.toggle.mockResolvedValue()
const { result } = renderHook(() => useVersionStore())
await act(async () => {
await result.current.toggleVersion('1', false)
})
expect(result.current.versions[0].isEnabled).toBe(false)
})
})
describe('setFilter', () => {
it('should update filter', () => {
const { result } = renderHook(() => useVersionStore())
act(() => {
result.current.setFilter({ platform: 'ios' })
})
expect(result.current.filter.platform).toBe('ios')
})
})
})
```
### Hooks 测试
```typescript
// src/application/hooks/__tests__/use-versions.test.ts
import { renderHook, waitFor } from '@testing-library/react'
import { useVersions, useVersionActions } from '../use-versions'
jest.mock('../stores/version-store')
describe('useVersions', () => {
it('should return versions and loading state', async () => {
const { result } = renderHook(() => useVersions())
expect(result.current.versions).toBeDefined()
expect(result.current.isLoading).toBeDefined()
expect(result.current.error).toBeDefined()
})
})
describe('useVersionActions', () => {
it('should return action functions', () => {
const { result } = renderHook(() => useVersionActions())
expect(result.current.deleteVersion).toBeInstanceOf(Function)
expect(result.current.toggleVersion).toBeInstanceOf(Function)
expect(result.current.uploadVersion).toBeInstanceOf(Function)
})
})
```
## 集成测试
### Infrastructure 层测试
使用 MSW (Mock Service Worker) 模拟 API。
```typescript
// src/infrastructure/repositories/__tests__/version-repository-impl.test.ts
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { VersionRepositoryImpl } from '../version-repository-impl'
const server = setupServer(
rest.get('*/api/v1/versions', (req, res, ctx) => {
return res(
ctx.json([
{ id: '1', versionName: '1.0.0', platform: 'android' },
])
)
}),
rest.post('*/api/v1/versions', (req, res, ctx) => {
return res(ctx.json({ id: 'new-id', ...req.body }))
}),
rest.delete('*/api/v1/versions/:id', (req, res, ctx) => {
return res(ctx.status(204))
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('VersionRepositoryImpl', () => {
const repository = new VersionRepositoryImpl()
describe('list', () => {
it('should fetch versions list', async () => {
const versions = await repository.list()
expect(versions).toHaveLength(1)
expect(versions[0].versionName).toBe('1.0.0')
})
it('should apply platform filter', async () => {
server.use(
rest.get('*/api/v1/versions', (req, res, ctx) => {
const platform = req.url.searchParams.get('platform')
expect(platform).toBe('ios')
return res(ctx.json([]))
})
)
await repository.list({ platform: 'ios' })
})
})
describe('delete', () => {
it('should delete version by id', async () => {
await expect(repository.delete('1')).resolves.toBeUndefined()
})
})
})
```
### Presentation 层测试
```typescript
// src/presentation/components/__tests__/version-card.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { VersionCard } from '../version-card'
const mockVersion = {
id: '1',
platform: 'android' as const,
versionCode: 100,
versionName: '1.0.0',
buildNumber: '100',
downloadUrl: 'https://example.com/app.apk',
fileSize: '52428800',
fileSha256: 'abc123',
changelog: 'Test changelog',
isForceUpdate: false,
isEnabled: true,
minOsVersion: '8.0',
releaseDate: '2024-01-01',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
}
describe('VersionCard', () => {
const mockOnEdit = jest.fn()
const mockOnDelete = jest.fn()
const mockOnToggle = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
})
it('should render version information', () => {
render(
<VersionCard
version={mockVersion}
onEdit={mockOnEdit}
onDelete={mockOnDelete}
onToggle={mockOnToggle}
/>
)
expect(screen.getByText('v1.0.0')).toBeInTheDocument()
expect(screen.getByText('android')).toBeInTheDocument()
expect(screen.getByText('(Build 100)')).toBeInTheDocument()
})
it('should show force update badge when isForceUpdate is true', () => {
render(
<VersionCard
version={{ ...mockVersion, isForceUpdate: true }}
onEdit={mockOnEdit}
onDelete={mockOnDelete}
onToggle={mockOnToggle}
/>
)
expect(screen.getByText('强制更新')).toBeInTheDocument()
})
it('should call onEdit when edit button clicked', () => {
render(
<VersionCard
version={mockVersion}
onEdit={mockOnEdit}
onDelete={mockOnDelete}
onToggle={mockOnToggle}
/>
)
fireEvent.click(screen.getByText('编辑'))
expect(mockOnEdit).toHaveBeenCalledTimes(1)
})
it('should call onDelete when delete button clicked', () => {
render(
<VersionCard
version={mockVersion}
onEdit={mockOnEdit}
onDelete={mockOnDelete}
onToggle={mockOnToggle}
/>
)
fireEvent.click(screen.getByText('删除'))
expect(mockOnDelete).toHaveBeenCalledTimes(1)
})
it('should call onToggle when toggle button clicked', () => {
render(
<VersionCard
version={mockVersion}
onEdit={mockOnEdit}
onDelete={mockOnDelete}
onToggle={mockOnToggle}
/>
)
fireEvent.click(screen.getByText('禁用'))
expect(mockOnToggle).toHaveBeenCalledWith(false)
})
})
```
### Upload Modal 测试
```typescript
// src/presentation/components/__tests__/upload-modal.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UploadModal } from '../upload-modal'
// Mock hooks
jest.mock('@/application', () => ({
useVersionActions: () => ({
uploadVersion: jest.fn().mockResolvedValue({ id: 'new-id' }),
}),
}))
describe('UploadModal', () => {
const mockOnClose = jest.fn()
const mockOnSuccess = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
})
it('should render upload form', () => {
render(<UploadModal onClose={mockOnClose} onSuccess={mockOnSuccess} />)
expect(screen.getByText('上传新版本')).toBeInTheDocument()
expect(screen.getByText('点击选择 APK 或 IPA 文件')).toBeInTheDocument()
})
it('should call onClose when close button clicked', () => {
render(<UploadModal onClose={mockOnClose} onSuccess={mockOnSuccess} />)
fireEvent.click(screen.getByText('取消'))
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should show error when submitting without file', async () => {
render(<UploadModal onClose={mockOnClose} onSuccess={mockOnSuccess} />)
fireEvent.click(screen.getByText('上传'))
await waitFor(() => {
expect(screen.getByText('请选择安装包文件')).toBeInTheDocument()
})
})
it('should auto-detect platform from file extension', async () => {
render(<UploadModal onClose={mockOnClose} onSuccess={mockOnSuccess} />)
const file = new File([''], 'app.apk', { type: 'application/vnd.android.package-archive' })
const input = screen.getByRole('textbox', { hidden: true }) || document.querySelector('input[type="file"]')
// Simulate file selection
if (input) {
await userEvent.upload(input, file)
}
// Android should be selected
const androidRadio = screen.getByLabelText('Android') as HTMLInputElement
expect(androidRadio.checked).toBe(true)
})
})
```
## E2E 测试
### Playwright 配置
```typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
```
### E2E 测试用例
```typescript
// e2e/version-management.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Version Management', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('should display version list', async ({ page }) => {
await expect(page.getByText('版本管理')).toBeVisible()
await expect(page.getByText('管理移动应用的版本更新')).toBeVisible()
})
test('should filter by platform', async ({ page }) => {
// Click Android filter
await page.click('button:has-text("Android")')
// Verify filter is applied
const androidButton = page.locator('button:has-text("Android")')
await expect(androidButton).toHaveClass(/bg-green-600/)
})
test('should open upload modal', async ({ page }) => {
await page.click('button:has-text("上传新版本")')
await expect(page.getByText('上传新版本')).toBeVisible()
await expect(page.getByText('点击选择 APK 或 IPA 文件')).toBeVisible()
})
test('should close upload modal', async ({ page }) => {
await page.click('button:has-text("上传新版本")')
await page.click('button:has-text("取消")')
await expect(page.locator('.fixed.inset-0')).not.toBeVisible()
})
test('should show validation error on empty submit', async ({ page }) => {
await page.click('button:has-text("上传新版本")')
await page.click('button:has-text("上传"):not(:has-text("新版本"))')
await expect(page.getByText('请选择安装包文件')).toBeVisible()
})
})
test.describe('Version Card Actions', () => {
test('should show confirmation on delete', async ({ page }) => {
// Assuming there's at least one version
await page.goto('/')
// Mock confirm dialog
page.on('dialog', async dialog => {
expect(dialog.message()).toContain('确定要删除')
await dialog.dismiss()
})
// Click first delete button
const deleteButton = page.locator('button:has-text("删除")').first()
if (await deleteButton.isVisible()) {
await deleteButton.click()
}
})
})
```
## 手动测试清单
### 功能测试
| 功能 | 测试步骤 | 预期结果 |
|------|----------|----------|
| 版本列表 | 打开首页 | 显示所有版本列表 |
| 平台筛选 | 点击 Android/iOS 按钮 | 只显示对应平台版本 |
| 上传版本 | 点击上传 → 选择文件 → 填写信息 → 提交 | 版本创建成功,列表更新 |
| 编辑版本 | 点击编辑 → 修改信息 → 保存 | 版本信息更新 |
| 删除版本 | 点击删除 → 确认 | 版本从列表移除 |
| 启用/禁用 | 点击启用/禁用按钮 | 状态切换 |
| 下载 | 点击下载按钮 | 文件开始下载 |
### 边界测试
| 场景 | 测试步骤 | 预期结果 |
|------|----------|----------|
| 空列表 | 无版本数据 | 显示"暂无版本数据"提示 |
| 网络错误 | 断开网络后操作 | 显示错误提示 |
| 大文件上传 | 上传 100MB+ 文件 | 上传成功或显示超时提示 |
| 并发操作 | 快速连续点击 | 不出现重复操作 |
### UI/UX 测试
| 检查项 | 预期 |
|--------|------|
| 响应式布局 | 移动端/桌面端正常显示 |
| 加载状态 | 操作时显示 loading |
| 错误提示 | Toast 提示位置正确 |
| 弹窗交互 | 点击遮罩可关闭 |
## 运行测试
```bash
# 运行所有单元测试
npm test
# 运行测试并生成覆盖率报告
npm test -- --coverage
# 运行特定测试文件
npm test -- version-store.test.ts
# 运行 E2E 测试
npx playwright test
# 运行 E2E 测试(带 UI
npx playwright test --ui
# 查看 E2E 测试报告
npx playwright show-report
```
## CI/CD 集成
```yaml
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm ci
- run: npm run lint
- run: npm test -- --coverage
- run: npx playwright install --with-deps
- run: npx playwright test
```
## 相关文档
- [架构文档](./ARCHITECTURE.md)
- [API 文档](./API.md)
- [开发指南](./DEVELOPMENT.md)
- [部署指南](./DEPLOYMENT.md)