move claude-marketplace to ai-proj-helper
This commit is contained in:
141
plugins/dev-test-plugin/skills/dev-test/SKILL.md
Normal file
141
plugins/dev-test-plugin/skills/dev-test/SKILL.md
Normal 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 + 真实 store,mock 等于没测
|
||||
7. **Mock 仅限 Handler 层** - handler 层可以 mock biz 接口 + httptest
|
||||
7. **李宁测试用例** - Excel 导出见 `coolbuy-legacy` 技能的 `test-cases-excel.md`
|
||||
145
plugins/dev-test-plugin/skills/dev-test/android-testing.md
Normal file
145
plugins/dev-test-plugin/skills/dev-test/android-testing.md
Normal 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)
|
||||
}
|
||||
}
|
||||
```
|
||||
169
plugins/dev-test-plugin/skills/dev-test/e2e-testing.md
Normal file
169
plugins/dev-test-plugin/skills/dev-test/e2e-testing.md
Normal 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:4010,reporter=[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` 停止 |
|
||||
174
plugins/dev-test-plugin/skills/dev-test/frontend-testing.md
Normal file
174
plugins/dev-test-plugin/skills/dev-test/frontend-testing.md
Normal 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)
|
||||
})
|
||||
})
|
||||
```
|
||||
208
plugins/dev-test-plugin/skills/dev-test/go-testing.md
Normal file
208
plugins/dev-test-plugin/skills/dev-test/go-testing.md
Normal 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
|
||||
```
|
||||
157
plugins/dev-test-plugin/skills/dev-test/ios-testing.md
Normal file
157
plugins/dev-test-plugin/skills/dev-test/ios-testing.md
Normal 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 |
|
||||
Reference in New Issue
Block a user