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

20 KiB
Raw Blame History

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 关键路径

测试环境配置

安装测试依赖

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 配置

// 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

// 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测试最简单。

// 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。

// 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 测试

// 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。

// 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 层测试

// 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 测试

// 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 配置

// 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 测试用例

// 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 提示位置正确
弹窗交互 点击遮罩可关闭

运行测试

# 运行所有单元测试
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 集成

# .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

相关文档