ワンクリックで
hai-app-tests
以 TDD 驱动应用开发:先分析需求、生成单元测试(Vitest)与 E2E 测试(Playwright),确认测试全部失败后再编码实现直至测试通过;当需求涉及应用测试、TDD 流程、覆盖率提升、E2E 测试时使用。
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
メニュー
以 TDD 驱动应用开发:先分析需求、生成单元测试(Vitest)与 E2E 测试(Playwright),确认测试全部失败后再编码实现直至测试通过;当需求涉及应用测试、TDD 流程、覆盖率提升、E2E 测试时使用。
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
SOC 職業分類に基づく
Use when: using @h-ai/ai, LLM calls, chat completion, tool calling, function calling, MCP server, streaming, memory management, context compression, summarization, token estimation, RAG, knowledge base, AI client, embeddings, reasoning, rerank, file parsing, speech recognition ASR, speech synthesis TTS, audio, A2A agent-to-agent. 使用 @h-ai/ai 进行 LLM 调用、工具定义、MCP 服务器、流式处理、记忆管理、上下文压缩、知识库、推理引擎、Rerank、文件解析、语音识别与合成、A2A 与会话持久化。
Use when: using @h-ai/ai for LLM calls, tools, MCP, streaming, memory/context, RAG, audio, A2A, or the AI client. 当需求涉及 AI 对话、工具、Audio、会话、知识库或 AI 客户端时使用。
Use when: creating or extending apps in hai-framework, adding routes, pages, API endpoints, service workspaces, mobile app, H5 app, admin console. 在 hai-framework 中创建或扩展应用或 API service workspace,包含路由、API 端点、typed contract、服务层与 UI 脚手架代码。
Use when: creating a new module, new package, scaffold, add sub-feature, add provider, create repository, module structure, tsup config, error codes, NotInitializedKit pattern. 在 hai-framework 中创建新模块(package)。
Use when: reviewing app code in hai-framework, auditing app quality, checking app conventions, reviewing routes, reviewing API service workspaces, app security, app i18n review. 对 hai-framework 应用层代码进行审查:路由安全 → 认证授权 → i18n → 组件使用 → API 端点 / service workspace → 服务层 → 性能。
Use when: reviewing code, code review, auditing module quality, checking hai-framework conventions, verifying HaiResult<T> usage, reviewing module structure, PR review, checking naming consistency, verifying NotInitializedKit pattern, auditing performance, security, distributed systems. 对 hai-framework 模块进行全维度代码审查:架构 → 命名 → 类型 → 注释 → 性能 → 分布式 → 安全 → 日志 → 测试 → 文档。
| name | hai-app-tests |
| description | 以 TDD 驱动应用开发:先分析需求、生成单元测试(Vitest)与 E2E 测试(Playwright),确认测试全部失败后再编码实现直至测试通过;当需求涉及应用测试、TDD 流程、覆盖率提升、E2E 测试时使用。 |
| 项目 | 契约 |
|---|---|
| 能力 | 以 TDD 驱动应用开发:先分析需求、生成单元测试(Vitest)与 E2E 测试(Playwright),确认测试全部失败后再编码实现直至测试通过;当需求涉及应用测试、TDD 流程、覆盖率提升、E2E 测试时使用。 |
| 适用场景 | 当任务与 hai-app-tests 的能力描述匹配,并且需要遵循本 Skill 的流程和边界时 |
| 输入 | 模块配置、类型化业务参数、依赖初始化状态和目标运行环境 |
| 输出 | 符合模块公共 API 的实现或示例;业务结果使用 HaiResult,并同步必要测试与文档 |
| 限制 | 遵守 init → use → close 生命周期与运行环境边界;不绕过类型、授权、输入校验或敏感信息保护 |
面向 AI 助手的 TDD 测试指南。测试先行:先写测试定义行为,再写实现让测试通过。涵盖 Vitest 单元测试与 Playwright E2E 测试。
$lib/server/services/ 编写服务层单元测试pnpm --filter <app-name> test # 单元测试 — 应全部 FAIL
pnpm --filter <app-name> test:e2e # E2E 测试 — 应全部 FAIL
hai-app-create 技能编写实现代码pnpm --filter <app-name> test # 单元测试 — 应全部 PASS
pnpm --filter <app-name> test:e2e # E2E 测试 — 应全部 PASS
hai-app-review 技能审查代码规范pnpm typecheck && pnpm lint && pnpm testtests/ # 单元测试(Vitest)
services/
user.test.ts # 服务层测试
article.test.ts
schemas/
user-schema.test.ts # Schema 校验测试
utils/
helper.test.ts # 工具函数测试
e2e/ # E2E 测试(Playwright)
helpers.ts # E2E 共用工具函数
health.spec.ts # 健康检查 API
auth-api.spec.ts # 认证 API 测试
auth-ui.spec.ts # 认证页面 UI 测试
users-api.spec.ts # 用户管理 API 测试
users-ui.spec.ts # 用户管理页面 UI 测试
reldb.sql、iam.authn),不直接调用内部实现。每个功能至少覆盖以下路径(单元测试 + E2E 按需):
| 路径 | 说明 | 单元测试示例 | E2E 测试示例 |
|---|---|---|---|
| 正常路径 | 核心功能可用 | 创建用户成功 | POST /api/users → 201 |
| 边界路径 | 非法输入、空值、格式错误 | 空名称、超长字符串 | 空表单提交 → 校验提示 |
| 权限路径 | 未认证、无权限 | 未登录调用服务 | 未登录访问 → 401/重定向 |
| 错误路径 | 服务异常、数据库错误 | DB 失败时的 HaiResult | 服务不可用 → 错误提示页 |
import { createArticle, listArticles } from '$lib/server/services/article'
// tests/services/article.test.ts
import { reldb } from '@h-ai/reldb'
import { describe, expect, it, vi } from 'vitest'
// Mock 依赖模块
vi.mock('@h-ai/reldb', () => ({
db: {
sql: { query: vi.fn(), execute: vi.fn() },
crud: { create: vi.fn(), findById: vi.fn(), paginate: vi.fn() },
},
}))
vi.mock('@h-ai/core', () => ({
core: { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } },
}))
describe('ArticleService', () => {
// 正常路径
it('should create article successfully', async () => {
const mockArticle = { id: '1', title: 'Test', authorId: 'u1' }
vi.mocked(reldb.crud.create).mockResolvedValue({ success: true, data: mockArticle })
const result = await createArticle({ title: 'Test', authorId: 'u1' })
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.title).toBe('Test')
}
})
// 边界路径
it('should return error for empty title', async () => {
const result = await createArticle({ title: '', authorId: 'u1' })
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBeDefined()
}
})
// 错误路径
it('should return error when db fails', async () => {
vi.mocked(reldb.crud.create).mockResolvedValue({
success: false,
error: { code: 'DB_ERROR', message: 'Connection lost' },
})
const result = await createArticle({ title: 'Test', authorId: 'u1' })
expect(result.success).toBe(false)
})
})
import { CreateUserSchema } from '$lib/server/schemas/user'
// tests/schemas/user-schema.test.ts
import { describe, expect, it } from 'vitest'
describe('CreateUserSchema', () => {
it('should accept valid input', () => {
const result = CreateUserSchema.safeParse({
username: 'testuser',
email: 'test@example.com',
password: '12345678',
})
expect(result.success).toBe(true)
})
it('should reject short username', () => {
const result = CreateUserSchema.safeParse({
username: 'ab',
email: 'test@example.com',
password: '12345678',
})
expect(result.success).toBe(false)
})
it('should reject invalid email', () => {
const result = CreateUserSchema.safeParse({
username: 'testuser',
email: 'not-email',
password: '12345678',
})
expect(result.success).toBe(false)
})
})
项目中应有 playwright.config.ts(由 CLI 生成):
// playwright.config.ts
import process from 'node:process'
import { defineConfig } from '@playwright/test'
const DEFAULT_BASE_URL = 'http://localhost:4173'
const baseURL = process.env.BASE_URL || DEFAULT_BASE_URL
const previewHost = resolveHostFromBaseUrl(baseURL, 'localhost')
const previewPort = resolvePortFromBaseUrl(baseURL, '4173')
function resolveHostFromBaseUrl(urlString: string, fallbackHost: string): string {
try {
const url = new URL(urlString)
return url.hostname || fallbackHost
}
catch {
return fallbackHost
}
}
function resolvePortFromBaseUrl(urlString: string, fallbackPort: string): string {
try {
const url = new URL(urlString)
if (url.port)
return url.port
return url.protocol === 'https:' ? '443' : '80'
}
catch {
return fallbackPort
}
}
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 2,
reporter: 'list',
timeout: 30_000,
use: {
baseURL,
channel: 'chrome',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command: `pnpm build && pnpm preview --host ${previewHost} --port ${previewPort} --strictPort`,
env: { HAI_E2E: '1', BASE_URL: baseURL },
url: baseURL,
reuseExistingServer: false,
timeout: 180_000,
},
})
// e2e/helpers.ts
import type { APIRequestContext, Page } from '@playwright/test'
/** 生成唯一测试用户 */
export function uniqueUser(prefix = 'e2e') {
const safePrefix = (prefix.replace(/\W/g, '') || 'e2e').slice(0, 8)
const entropy = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
const id = entropy.slice(-10)
const username = `${safePrefix}_${id}`.slice(0, 20)
return {
username,
email: `${safePrefix}_${id}@test.local`,
password: 'Test1234!@',
}
}
/** 通过 API 注册用户 */
export async function registerViaApi(
request: APIRequestContext,
user: ReturnType<typeof uniqueUser>,
) {
return request.post('/api/auth/register', {
data: {
username: user.username,
email: user.email,
password: user.password,
confirmPassword: user.password,
},
})
}
/** 注册并登录,返回已认证的 Page */
export async function registerAndLogin(
page: Page,
request: APIRequestContext,
prefix = 'e2e',
) {
const user = uniqueUser(prefix)
await registerViaApi(request, user)
await page.goto('/login')
await page.fill('[name="username"]', user.username)
await page.fill('[name="password"]', user.password)
await page.click('button[type="submit"]')
await page.waitForURL('/')
return user
}
// e2e/articles-api.spec.ts
import { expect, test } from '@playwright/test'
import { registerAndLogin } from './helpers'
test.describe('Articles API', () => {
test('GET /api/articles 未认证返回 401', async ({ request }) => {
const response = await request.get('/api/articles')
expect(response.status()).toBe(401)
})
test('POST /api/articles 创建文章成功', async ({ page, request }) => {
await registerAndLogin(page, request, 'article')
const response = await page.request.post('/api/articles', {
data: { title: 'E2E Test Article', content: 'Test content' },
})
expect(response.status()).toBe(201)
const body = await response.json()
expect(body.success).toBe(true)
expect(body.data.title).toBe('E2E Test Article')
})
test('POST /api/articles 空标题返回 400', async ({ page, request }) => {
await registerAndLogin(page, request, 'article')
const response = await page.request.post('/api/articles', {
data: { title: '', content: '' },
})
expect(response.status()).toBe(400)
})
test('GET /api/articles 认证后获取列表', async ({ page, request }) => {
await registerAndLogin(page, request, 'article')
const response = await page.request.get('/api/articles')
expect(response.ok()).toBeTruthy()
const body = await response.json()
expect(body.success).toBe(true)
expect(Array.isArray(body.data)).toBe(true)
})
})
// e2e/articles-ui.spec.ts
import { expect, test } from '@playwright/test'
import { registerAndLogin } from './helpers'
test.describe('Articles Page', () => {
test('未登录跳转登录页', async ({ page }) => {
await page.goto('/admin/articles')
await expect(page).toHaveURL(/login/)
})
test('已登录显示文章列表', async ({ page, request }) => {
await registerAndLogin(page, request, 'artui')
await page.goto('/admin/articles')
await expect(page.locator('h1, h2').first()).toContainText(/文章|article/i)
})
test('创建文章表单提交', async ({ page, request }) => {
await registerAndLogin(page, request, 'artui')
await page.goto('/admin/articles/new')
await page.fill('[name="title"]', 'E2E Article')
await page.fill('[name="content"]', 'E2E content')
await page.click('button[type="submit"]')
// 验证跳转或成功提示
await expect(page.locator('.alert-success, .toast')).toBeVisible({ timeout: 5000 })
})
})
需求:用户可以创建文章,标题必填且不超过 100 字符,需要登录后才能操作
↓ 拆分测试点
单元测试(Vitest):
✅ 创建文章成功返回 HaiResult.ok
✅ 空标题返回 HaiResult.err + VALIDATION_FAILED
✅ 超长标题(>100 字符)返回 HaiResult.err
✅ DB 异常时透传错误
Schema 测试(Vitest):
✅ 合法输入通过校验
✅ 空标题被拒绝
✅ 超长标题被拒绝
✅ 缺少必填字段被拒绝
E2E 测试(Playwright):
✅ POST /api/articles 未认证 → 401
✅ POST /api/articles 合法数据 → 201
✅ POST /api/articles 空标题 → 400
✅ 页面表单提交成功 → 跳转/提示
✅ 未登录访问创建页 → 重定向登录
result.successerror.code(不只检查 success === false)// ✅ 单元测试断言
const result = await service.create(input)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.id).toBeDefined()
}
// ✅ 单元测试错误断言
const result = await service.create(invalidInput)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('VALIDATION_FAILED')
}
// ✅ E2E API 断言
const response = await request.post('/api/articles', { data: input })
expect(response.status()).toBe(201)
const body = await response.json()
expect(body.success).toBe(true)
// ✅ E2E UI 断言
await expect(page.locator('.alert-success')).toBeVisible()
await expect(page).toHaveURL('/admin/articles')
# 运行单元测试
pnpm --filter <app-name> test
# 运行 E2E 测试
pnpm --filter <app-name> test:e2e
# 覆盖率
pnpm --filter <app-name> test:coverage
# 全量门禁
pnpm typecheck && pnpm lint && pnpm test
hai-app-create:应用功能创建规范(TDD Green 阶段参照)hai-app-review:应用代码审查规范(TDD Refactor 阶段参照)hai-build:项目架构总览与 TDD 工作流导航