707 lines
20 KiB
Markdown
707 lines
20 KiB
Markdown
# 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)
|