# 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: ['/jest.setup.js'], testEnvironment: 'jest-environment-jsdom', moduleNameMapper: { '^@/(.*)$': '/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 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( ) 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( ) expect(screen.getByText('强制更新')).toBeInTheDocument() }) it('should call onEdit when edit button clicked', () => { render( ) fireEvent.click(screen.getByText('编辑')) expect(mockOnEdit).toHaveBeenCalledTimes(1) }) it('should call onDelete when delete button clicked', () => { render( ) fireEvent.click(screen.getByText('删除')) expect(mockOnDelete).toHaveBeenCalledTimes(1) }) it('should call onToggle when toggle button clicked', () => { render( ) 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() expect(screen.getByText('上传新版本')).toBeInTheDocument() expect(screen.getByText('点击选择 APK 或 IPA 文件')).toBeInTheDocument() }) it('should call onClose when close button clicked', () => { render() fireEvent.click(screen.getByText('取消')) expect(mockOnClose).toHaveBeenCalledTimes(1) }) it('should show error when submitting without file', async () => { render() fireEvent.click(screen.getByText('上传')) await waitFor(() => { expect(screen.getByText('请选择安装包文件')).toBeInTheDocument() }) }) it('should auto-detect platform from file extension', async () => { render() 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)