move claude-marketplace to ai-proj-helper

This commit is contained in:
2026-03-12 21:42:30 +08:00
parent d7b6835e1d
commit 43585b8504
188 changed files with 39510 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
{
"name": "dev-test-plugin",
"description": "软件测试技能。用于单元测试、集成测试、E2E测试、测试用例设计。支持 Go、Vue、React、iOS、Android 等多平台测试。",
"version": "2.0.0",
"author": {
"name": "qiudl"
}
}

View File

@@ -0,0 +1,141 @@
---
name: dev-test
description: 软件测试技能。用于单元测试、集成测试、E2E测试、测试用例设计。支持 Go、Vue、React、iOS、Android 等多平台测试。
---
# 软件测试 Skill (dev-test)
## 子文件索引
| 文件 | 内容 |
|------|------|
| `go-testing.md` | Go 后端测试 (testify + test DB + httptest)。**biz 层禁止 mock必须用真实 PostgreSQL test DB** |
| `frontend-testing.md` | Vue (Vitest) + React (Jest) 前端测试 |
| `ios-testing.md` | iOS 测试 (XCTest + Swift Concurrency) |
| `android-testing.md` | Android 测试 (JUnit + Espresso + Compose) |
| `e2e-testing.md` | E2E Playwright + Coolbuy PaaS 集成测试 |
---
## 测试金字塔
```
/\
/ \ E2E (少量)
/----\
/ \ 集成测试 (适量)
/--------\
/ \ 单元测试 (大量)
/------------\
```
| 类型 | 范围 | 速度 | 数量 |
|------|------|------|------|
| 单元测试 | 函数/方法 | 快 | 多 |
| 集成测试 | 模块交互 | 中 | 适量 |
| E2E 测试 | 完整流程 | 慢 | 少 |
---
## 测试命令速查
| 平台 | 命令 | 详见 |
|------|------|------|
| Go | `make test` / `go test ./...` | `go-testing.md` |
| Vue | `npm run test` | `frontend-testing.md` |
| React | `npm test` | `frontend-testing.md` |
| iOS | `xcodebuild test` | `ios-testing.md` |
| Android | `./gradlew test` | `android-testing.md` |
| E2E (通用) | `npm run test:e2e` | `e2e-testing.md` |
| E2E (Coolbuy PaaS) | `make e2e` | `e2e-testing.md` |
---
## Chrome DevTools MCP (AI 浏览器调试)
> Google 官方 MCP 服务器,让 AI 助手直接控制和检查 Chrome 浏览器。
```bash
claude mcp add chrome-devtools npx chrome-devtools-mcp@latest
```
| 分类 | 工具 | 说明 |
|------|------|------|
| **输入** | `click` / `fill` / `fill_form` / `hover` / `upload_file` | 页面交互 |
| **导航** | `navigate_page` / `new_page` / `list_pages` / `wait_for` | 页面导航 |
| **调试** | `evaluate_script` / `list_console_messages` / `take_screenshot` | 调试工具 |
| **网络** | `list_network_requests` / `get_network_request` | 网络分析 |
| **性能** | `performance_start_trace` / `performance_stop_trace` | 性能追踪 |
| **模拟** | `emulate_device` / `throttle_network` / `throttle_cpu` | 环境模拟 |
---
## 测试用例设计
### 等价类划分
| 输入 | 有效类 | 无效类 |
|------|--------|--------|
| 用户名 | 3-64字符 | <3, >64 |
| 年龄 | 0-150 | <0, >150 |
| 邮箱 | 有效格式 | 无效格式 |
### 边界值
```
范围 [1, 100]:
测试点: 0, 1, 2, 99, 100, 101
```
### 测试用例模板
```markdown
## TC-001: 用户登录成功
**前置条件**: 用户已注册
**步骤**:
1. 输入有效用户名
2. 输入有效密码
3. 点击登录
**预期**: 跳转到首页
**优先级**: P0
```
---
## 覆盖率目标
| 类型 | 目标 |
|------|------|
| 行覆盖 | >80% |
| 分支覆盖 | >70% |
| 函数覆盖 | >90% |
---
## 与 ai-proj 集成
```bash
# 创建测试任务
ai-proj task create --title "[模块] 单元测试"
# 记录测试结果
ai-proj task append-doc --id <taskId> --content "# 测试报告
- 覆盖率: 85%
- 通过: 42
- 失败: 0"
```
---
## 最佳实践
1. **测试金字塔** - 多单元测试,少 E2E
2. **测试隔离** - 每个测试独立
3. **命名清晰** - 描述预期行为
4. **快速反馈** - 测试要快
5. **持续集成** - 每次提交运行
6. **Biz 层禁止 Mock** - biz/service 层必须使用真实 PostgreSQL test DB + 真实 storemock 等于没测
7. **Mock 仅限 Handler 层** - handler 层可以 mock biz 接口 + httptest
7. **李宁测试用例** - Excel 导出见 `coolbuy-legacy` 技能的 `test-cases-excel.md`

View File

@@ -0,0 +1,145 @@
# Android 测试 (JUnit + Espresso)
## 运行测试
```bash
# 单元测试
./gradlew test
# UI 测试
./gradlew connectedAndroidTest
```
## 单元测试 (JUnit)
```kotlin
class TaskViewModelTest {
@get:Rule
val instantTaskRule = InstantTaskExecutorRule()
@get:Rule
val coroutineRule = MainCoroutineRule()
private lateinit var viewModel: TaskViewModel
private lateinit var repository: FakeTaskRepository
@Before
fun setup() {
repository = FakeTaskRepository()
viewModel = TaskViewModel(repository)
}
@Test
fun `fetchTasks updates state`() = runTest {
// Arrange
repository.addTasks(listOf(
Task(1, "Task 1"),
Task(2, "Task 2")
))
// Act
viewModel.fetchTasks()
// Assert
val tasks = viewModel.tasks.first()
assertEquals(2, tasks.size)
}
@Test
fun `createTask adds task`() = runTest {
// Act
viewModel.createTask("New Task")
// Assert
val tasks = viewModel.tasks.first()
assertTrue(tasks.any { it.title == "New Task" })
}
}
```
## UI 测试 (Espresso)
```kotlin
@RunWith(AndroidJUnit4::class)
@LargeTest
class TaskListActivityTest {
@get:Rule
val activityRule = ActivityScenarioRule(TaskListActivity::class.java)
@Test
fun displayTaskList() {
onView(withId(R.id.taskList))
.check(matches(isDisplayed()))
}
@Test
fun clickTask_opensDetail() {
onView(withId(R.id.taskList))
.perform(RecyclerViewActions.actionOnItemAtPosition<TaskViewHolder>(0, click()))
onView(withId(R.id.taskDetail))
.check(matches(isDisplayed()))
}
@Test
fun addTask_showsInList() {
// Click add button
onView(withId(R.id.addButton)).perform(click())
// Enter title
onView(withId(R.id.titleInput))
.perform(typeText("New Task"), closeSoftKeyboard())
// Save
onView(withId(R.id.saveButton)).perform(click())
// Verify in list
onView(withText("New Task"))
.check(matches(isDisplayed()))
}
}
```
## Compose UI 测试
```kotlin
@RunWith(AndroidJUnit4::class)
class TaskListScreenTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun taskList_displays() {
val tasks = listOf(
Task(1, "Task 1"),
Task(2, "Task 2")
)
composeRule.setContent {
TaskListScreen(tasks = tasks)
}
composeRule.onNodeWithText("Task 1").assertExists()
composeRule.onNodeWithText("Task 2").assertExists()
}
@Test
fun taskClick_callsOnClick() {
var clickedId: Int? = null
val tasks = listOf(Task(1, "Task 1"))
composeRule.setContent {
TaskListScreen(
tasks = tasks,
onTaskClick = { clickedId = it.id }
)
}
composeRule.onNodeWithText("Task 1").performClick()
assertEquals(1, clickedId)
}
}
```

View File

@@ -0,0 +1,169 @@
# E2E 测试 (Playwright)
## 通用 Playwright 配置
```typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
timeout: 30000,
use: {
baseURL: 'http://localhost:3000',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
],
})
```
## 通用 E2E 测试示例
```typescript
// login.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Login', () => {
test('successful login', async ({ page }) => {
await page.goto('/login')
await page.fill('[data-testid="username"]', 'testuser')
await page.fill('[data-testid="password"]', 'password')
await page.click('[data-testid="submit"]')
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('.welcome')).toContainText('testuser')
})
test('invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.fill('[data-testid="username"]', 'wrong')
await page.fill('[data-testid="password"]', 'wrong')
await page.click('[data-testid="submit"]')
await expect(page.locator('.error')).toBeVisible()
})
})
test.describe('Task Management', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login')
await page.fill('[data-testid="username"]', 'testuser')
await page.fill('[data-testid="password"]', 'password')
await page.click('[data-testid="submit"]')
})
test('create task', async ({ page }) => {
await page.click('[data-testid="new-task"]')
await page.fill('[data-testid="task-title"]', 'E2E Test Task')
await page.click('[data-testid="save"]')
await expect(page.locator('text=E2E Test Task')).toBeVisible()
})
})
```
---
## Coolbuy PaaS E2E 集成测试
> Playwright 全链路 E2E 测试独立环境DB + 端口),可与 dev 服务并行运行。
### 环境架构
| 服务 | Dev 端口 | E2E 端口 | DB |
|------|---------|---------|-----|
| Auth Service | 7089 | 7189 | coolbuy_paas_e2e |
| Foundation Service | 7090 | 7190 | coolbuy_paas_e2e |
| ERP Service | 7091 | 7191 | coolbuy_paas_e2e |
| Web Frontend | 4000 | 4010 | - |
**E2E DB 初始化**(首次/重置):
```bash
psql -U coolbuy-dev -d postgres -c "DROP DATABASE IF EXISTS coolbuy_paas_e2e;"
psql -U coolbuy-dev -d postgres -c "CREATE DATABASE coolbuy_paas_e2e OWNER \"coolbuy-dev\";"
pg_dump -U coolbuy-dev coolbuy_paas_local | psql -U coolbuy-dev coolbuy_paas_e2e
```
### 启动 / 停止 E2E 服务
```bash
make e2e-start # 启动全部 E2E 服务auth/foundation/erp/web
make e2e-stop # 停止全部 E2E 服务
make e2e-reset # 重置 DB 后启动
make e2e # 启动服务 + 运行全部测试
```
脚本位置:`scripts/start-e2e-services.sh` / `scripts/stop-e2e-services.sh`
### 运行测试
```bash
cd web
# 全部测试(无头模式)
npx playwright test
# 带 UI 调试
npx playwright test --headed
# 单个文件
npx playwright test tests/product-crud.spec.ts
# 查看 HTML 报告(注意:会启动 HTTP server需 Ctrl+C 退出)
npx playwright show-report
```
### Auth 自动登录
`tests/auth.setup.ts` 优先点击快速登录按钮(`VITE_ENABLE_QUICK_LOGIN=true`),降级为表单登录:
```typescript
// 快速登录E2E 环境默认开启)
const quickLoginBtn = page.locator('button, a').filter({ hasText: /李宁|lining|ID:2/i }).first();
if (await quickLoginBtn.isVisible({ timeout: 3000 })) {
await quickLoginBtn.click();
} else {
// 降级:填写 lining_admin / admin123验证码任意 4 位SkipVerify=true
}
await page.waitForURL(/\/tenant/, { timeout: 15000 });
await page.context().storageState({ path: authFile });
```
Session 保存至 `.auth/user.json`,后续测试自动复用,无需重复登录。
### 配置文件
| 文件 | 说明 |
|------|------|
| `web/.env.e2e` | E2E 环境变量(端口 / 快速登录开关) |
| `web/playwright.config.ts` | baseURL=localhost:4010reporter=[html, list] |
| `auth-service/api/etc/auth-api-e2e.yaml` | E2E auth 配置SkipVerify=true |
| `foundation-service/api/etc/foundation-api-e2e.yaml` | E2E foundation 配置 |
| `erp-service/configs/config.e2e.yaml` | E2E ERP 配置 |
### 测试结果解读
当前 **113 tests — 103 ✅ / 10 ❌**,已知失败项:
| 失败原因 | 涉及测试 |
|---------|---------|
| `/tenant/order/business` 路由 404页面未实现 | 业务订单列表 × 3、订单模块导航 |
| 预警管理无搜索表单组件 | 预警管理搜索/筛选 |
| 库存管理页无 table/empty 状态 | 仓库管理-库存管理 |
| 待审批订单 networkidle 超时(>60s | 待审批订单列表 |
| 数据权限/字段权限页渲染异常 | 系统管理 × 2 |
| 业务 CRM 页渲染异常 | 业务 CRM |
### 常见问题
| 问题 | 解决 |
|------|------|
| `Executable doesn't exist` | `npx playwright install chromium` |
| 端口 4010 被占用 | `make e2e-stop` 后重试 |
| GORM migration 失败 | 检查 DB 是否有旧约束名,手动 DROP CONSTRAINT 后重启服务 |
| HTML 报告进程不退出 | Playwright 在 report 模式会启动 HTTP server`Ctrl+C` 停止 |

View File

@@ -0,0 +1,174 @@
# 前端测试 (Vue + React)
## Vue 前端测试
### 测试框架
- **Vitest**: 测试运行器
- **Vue Test Utils**: 组件测试
- **MSW**: API Mock
### 运行测试
```bash
npm run test
npm run test:watch
npm run test:coverage
```
### 组件测试
```typescript
// UserList.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import UserList from './UserList.vue'
describe('UserList', () => {
it('renders user list', () => {
const wrapper = mount(UserList, {
props: {
users: [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
]
}
})
expect(wrapper.findAll('.user-item')).toHaveLength(2)
expect(wrapper.text()).toContain('Alice')
})
it('emits select event', async () => {
const wrapper = mount(UserList, {
props: { users: [{ id: 1, name: 'Alice' }] }
})
await wrapper.find('.user-item').trigger('click')
expect(wrapper.emitted('select')).toBeTruthy()
expect(wrapper.emitted('select')[0]).toEqual([1])
})
it('shows empty state', () => {
const wrapper = mount(UserList, {
props: { users: [] }
})
expect(wrapper.find('.empty-state').exists()).toBe(true)
})
})
```
### API Mock (MSW)
```typescript
// mocks/handlers.ts
import { rest } from 'msw'
export const handlers = [
rest.get('/api/v1/users', (req, res, ctx) => {
return res(ctx.json({
code: 0,
data: {
total: 2,
list: [{ id: 1, name: 'Alice' }]
}
}))
}),
rest.post('/api/v1/users', async (req, res, ctx) => {
const body = await req.json()
return res(ctx.json({
code: 0,
data: { id: 3, ...body }
}))
})
]
```
---
## React 前端测试
### 测试框架
- **Jest**: 测试运行器
- **React Testing Library**: 组件测试
- **Playwright**: E2E 测试
### 运行测试
```bash
npm test
npm run test:e2e
npm run test:e2e:headed
```
### 组件测试
```typescript
// TaskCard.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import TaskCard from './TaskCard'
describe('TaskCard', () => {
const mockTask = {
id: 1,
title: 'Test Task',
status: 'todo',
priority: 'high'
}
it('renders task title', () => {
render(<TaskCard task={mockTask} />)
expect(screen.getByText('Test Task')).toBeInTheDocument()
})
it('displays priority', () => {
render(<TaskCard task={mockTask} />)
expect(screen.getByText('high')).toHaveClass('priority-high')
})
it('calls onClick', () => {
const handleClick = jest.fn()
render(<TaskCard task={mockTask} onClick={handleClick} />)
fireEvent.click(screen.getByRole('article'))
expect(handleClick).toHaveBeenCalledWith(mockTask)
})
})
```
### Hook 测试
```typescript
// useTimer.test.ts
import { renderHook, act } from '@testing-library/react-hooks'
import { useTimer } from './useTimer'
describe('useTimer', () => {
beforeEach(() => jest.useFakeTimers())
afterEach(() => jest.useRealTimers())
it('starts timer', () => {
const { result } = renderHook(() => useTimer())
act(() => result.current.start())
expect(result.current.isRunning).toBe(true)
})
it('increments time', () => {
const { result } = renderHook(() => useTimer())
act(() => {
result.current.start()
jest.advanceTimersByTime(3000)
})
expect(result.current.seconds).toBe(3)
})
})
```

View File

@@ -0,0 +1,208 @@
# Go 后端测试
## 测试框架
- **testify**: 断言和套件
- **httptest**: HTTP 测试
- **gomock**: Mock 生成(仅用于 handler 层)
## ⚠️ Biz 层测试规则:禁止使用 Mock
**Biz/Service 层测试必须使用真实 PostgreSQL test DB不允许使用 mock store。**
Mock store 只是在测试你的 mock 实现,无法验证真实的 SQL 行为、事务、FK 约束等。
| 层 | 测试方式 | 原因 |
|----|---------|------|
| model/store | **test DB** (PostgreSQL) | 验证真实 SQL/ORM 行为 |
| biz/service | **test DB** (PostgreSQL) + 真实 store | 验证业务逻辑 + 真实数据交互 |
| handler | **mock biz + httptest** | 只测 HTTP 路由和参数绑定 |
```go
// ✅ 正确 — biz 层使用真实 test DB + 真实 store
func setupBiz(t *testing.T) (*SomeBiz, *gorm.DB) {
db := newTestDB(t)
s := store.NewSomeStore(db)
biz := NewSomeBiz(s)
t.Cleanup(func() {
db.Exec("DELETE FROM some_table WHERE tenant_id = ?", testTenantID)
})
return biz, db
}
// ❌ 错误 — biz 层使用 mock store等于没测
mockStore := store.NewMockIStore(ctrl)
mockStore.EXPECT().Get(gomock.Any(), id).Return(fakeData, nil)
biz := NewSomeBiz(mockStore)
```
### testdb_test.go 模板
```go
package biz
import (
"os"
"testing"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
const testTenantID int64 = 99
func newTestDB(t *testing.T, models ...interface{}) *gorm.DB {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_DSN")
if dsn == "" {
dsn = "host=localhost user=coolbuy-dev dbname=coolbuy_paas_test sslmode=disable"
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(models...))
return db
}
```
## 运行测试
```bash
# 所有测试
go test ./...
make test
# 带覆盖率
make cover
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
# 特定包
go test -v ./internal/twms/biz/...
# 特定函数
go test -v -run TestFunctionName ./...
```
## Biz 层单元测试模板(真实 DB
```go
package biz
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"project/internal/user/model"
"project/internal/user/store"
)
func setupUserBiz(t *testing.T) (*UserBiz, *gorm.DB) {
db := newTestDB(t, &model.User{})
s := store.NewUserStore(db)
biz := NewUserBiz(s)
t.Cleanup(func() {
db.Exec("DELETE FROM users WHERE tenant_id = ?", testTenantID)
})
return biz, db
}
func createTestUser(t *testing.T, db *gorm.DB, username string) *model.User {
t.Helper()
user := &model.User{TenantID: testTenantID, Username: username, Email: username + "@test.com"}
require.NoError(t, db.Create(user).Error)
return user
}
func TestUserBiz_Get(t *testing.T) {
biz, db := setupUserBiz(t)
user := createTestUser(t, db, "john")
result, err := biz.Get(context.Background(), user.ID)
assert.NoError(t, err)
assert.Equal(t, "john", result.Username)
}
func TestUserBiz_Get_NotFound(t *testing.T) {
biz, _ := setupUserBiz(t)
_, err := biz.Get(context.Background(), 99999)
assert.Error(t, err)
}
```
## 表驱动测试
```go
func TestValidateUsername(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{"valid", "john_doe", false},
{"too_short", "ab", true},
{"too_long", strings.Repeat("a", 65), true},
{"special_chars", "user@name", true},
{"empty", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateUsername(tt.input)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
```
## HTTP Handler 测试
```go
func TestUserController_List(t *testing.T) {
gin.SetMode(gin.TestMode)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockBiz := biz.NewMockIBiz(ctrl)
mockUserBiz := biz.NewMockUserBiz(ctrl)
mockBiz.EXPECT().Users().Return(mockUserBiz).AnyTimes()
mockUserBiz.EXPECT().List(gomock.Any(), gomock.Any()).Return(&v1.ListUsersResponse{
Total: 1,
Users: []*v1.User{{Id: 1, Username: "test"}},
}, nil)
controller := NewUserController(mockBiz)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/v1/users?page=1&limit=10", nil)
controller.List(c)
assert.Equal(t, http.StatusOK, w.Code)
}
```
## Mock 生成
```bash
# 生成 Mock
mockgen -source=internal/twms/store/store.go \
-destination=internal/twms/store/mock_store.go \
-package=store
# go:generate 方式
//go:generate mockgen -source=store.go -destination=mock_store.go -package=store
```

View File

@@ -0,0 +1,157 @@
# iOS 测试 (XCTest + Swift Concurrency)
## 测试框架
- **XCTest**: Apple 官方测试框架
- **Swift Testing**: Swift 6 新测试框架 (可选)
- **ViewInspector**: SwiftUI 视图测试 (第三方)
## 运行测试
```bash
# 全部测试
xcodebuild test \
-scheme AI-Proj-iOS \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-quiet
# 特定测试类
xcodebuild test \
-scheme AI-Proj-iOS \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-only-testing:AI-Proj-iOSTests/DashboardViewModelTests
# 覆盖率
xcodebuild test \
-scheme AI-Proj-iOS \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-enableCodeCoverage YES
```
## 项目测试结构 (AI-Proj-iOS)
```
AI-Proj-iOSTests/
├── Mocks/
│ ├── MockServices.swift # Mock 服务协议实现
│ └── MockNetworkService.swift
├── ViewModels/
│ ├── DashboardViewModelTests.swift
│ ├── TaskViewModelTests.swift
│ └── RequirementViewModelTests.swift
├── Services/
│ ├── TaskServiceTests.swift
│ └── DashboardAggregationServiceTests.swift
├── Models/
│ └── ModelDecodingTests.swift
└── Utilities/
└── DateFormatterTests.swift
```
## 关键模式
### 1. Mock 服务 — Result 注入
```swift
class MockTaskService: TaskServiceProtocol {
var fetchTasksResult: Result<TaskListResponse, Error> = .success(.mock)
func fetchTasks(...) async throws -> TaskListResponse {
switch fetchTasksResult {
case .success(let response): return response
case .failure(let error): throw error
}
}
}
```
所有 Mock 服务统一用 `Result` 属性控制成功/失败返回。
### 2. ViewModel 测试 — @MainActor + async
```swift
@MainActor
final class DashboardViewModelTests: XCTestCase {
var sut: DashboardViewModel!
var mockService: MockDashboardAggregationService!
override func setUp() {
super.setUp()
mockService = MockDashboardAggregationService()
sut = DashboardViewModel(dashboardService: mockService)
}
override func tearDown() {
sut = nil; mockService = nil
super.tearDown()
}
func testLoadDashboardData_Success() async {
mockService.fetchDashboardDataResult = .success(expectedData)
await sut.loadDashboardData()
XCTAssertFalse(sut.isLoading)
XCTAssertEqual(sut.todayStats.completedTasks, 5)
}
}
```
要点:`@MainActor` + `async` 测试方法 + setUp/tearDown 重置。
### 3. Mock 数据工厂 — 静态 `.mock()` 方法
```swift
extension TaskModel {
static func mock(id: Int = 1, status: TaskStatus = .todo) -> TaskModel {
TaskModel(id: id, title: "Mock Task", status: status, ...)
}
}
extension TaskListResponse {
static var mock: TaskListResponse {
TaskListResponse(tasks: [.mock(id: 1), .mock(id: 2)], total: 2, page: 1, pageSize: 20)
}
}
```
### 4. 模型解码测试 — JSON → Model
```swift
func testTaskModel_DecodesFromJSON() throws {
let json = """
{ "id": 123, "status": "in_progress", "priority": "high", ... }
""".data(using: .utf8)!
let task = try decoder.decode(TaskModel.self, from: json)
XCTAssertEqual(task.status, .inProgress)
}
```
### 5. SwiftUI 视图测试 — ViewInspector
```swift
extension EnhancedStatsSection: Inspectable {}
func testStatsSection_DisplaysCorrectValues() throws {
let view = EnhancedStatsSection(stats: .mock)
let text = try view.inspect().find(text: "5")
XCTAssertNotNil(text)
}
```
## 最佳实践
1. **@MainActor** — ViewModel 测试必须在主线程
2. **Mock 所有依赖** — 协议抽象 + Result 注入
3. **async/await** — 避免 XCTestExpectation 回调
4. **数据工厂**`.mock()` 静态方法,参数带默认值
5. **隔离测试** — setUp/tearDown 重置所有状态
6. **命名**`test<Method>_<Scenario>` 格式
## Xcode 快捷键
| 快捷键 | 操作 |
|--------|------|
| `Cmd + U` | 运行所有测试 |
| `Ctrl + Opt + Cmd + U` | 运行当前测试方法 |
| `Ctrl + Opt + Cmd + G` | 重新运行上次测试 |
| `Cmd + 6` | Test Navigator |