ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Agentic Playwright:给 AI 一本测试宪法,再用 PreToolUse Hook 在写入时拦截违规代码

Agentic Playwright:给 AI 一本测试宪法,再用 PreToolUse Hook 在写入时拦截违规代码 AI 生成 E2E 测试最大的问题不是生成不出来而是生成出来的是垃圾XPath 硬编码、waitForTimeout(5000)魔法等待、any类型满天飞、没有 Page Object、没有 fixture。用 prompt 约束 AI 写规范测试效果取决于模型心情——prompt 会漂移、会被上下文挤掉、不同模型遵守程度完全不一样。最近开源的agentic-playwrightidavidov13/agentic-playwrightnpm 包create-agentic-playwright给出了一个值得抄的答案把规范从提示词里的建议升级成写入时的硬约束。核心思路分三层Orchestrator 常驻规则宪法速查→ 按需加载的技能树每个关注点一份详细规范→ PreToolUse Hook 在文件落盘前机械拦截违规模式。本文拆解它的规则架构、Hook 实现和 fixture 组合设计。同一个 prompt两种产物先看对比。同样的给商品搜索写个测试指令无约束的 AI 写出的是test(search, async ({ page }) { await page.goto(https://practicesoftwaretesting.com); await page.locator(//input[idsearch-query]).fill(pliers); await page.locator(//button[typesubmit]).click(); await page.waitForTimeout(5000); const cards: any await page.$$(.card); expect(cards.length 0).toBe(true); });而 agentic-playwright 约束下的产物import { expect, test } from ../../../fixtures/pom/test-options; test( should show only matching products when searching, { tag: regression }, async ({ homePage }) { await test.step(GIVEN the user is on the home page, async () { await homePage.open(); }); await test.step(WHEN the user searches for pliers, async () { await homePage.searchFor(pliers); }); await test.step(THEN every result matches the search term, async () { await expect(homePage.searchCaption).toContainText(pliers); await expect(homePage.productNames.first()).toContainText(Pliers); }); } );左边四个经典坏味道XPath 选择器、硬等待、any、无结构断言。右边是依赖注入的 Page Object、Given/When/Then 步骤、web-first 断言、单一 tag。差异不是模型变聪明了而是规则架构让写坏代码这条路被堵死了。三层规则架构建议 → 详规 → 硬拦截┌─ Layer 1: Orchestrator常驻始终加载────────────────────┐ │ CLAUDE.md / .cursor/rules/rules.mdc / copilot-instructions.md │ │ 宪法速查 AI 工作流 技能索引 │ ├─ Layer 2: Skills按需加载每个关注点一份─────────────────┤ │ .claude/skills/*/SKILL.md → Claude CodeAI 按需读取 │ │ .cursor/skills/*/SKILL.md → Cursor按需读取 │ │ .github/instructions/* → CopilotapplyTo glob 自动注入│ │ selectors / page-objects / fixtures / test-standards / │ │ type-safety / api-testing / data-strategy / enums ... │ ├─ Layer 3: PreToolUse Hook写入时硬拦截─────────────────────┤ │ .claude/scripts/enforce_constitution.py │ │ 阻塞 Write/Edit/MultiEdit 中机械可判定的违规 │ └──────────────────────────────────────────────────────────────┘层机制失效模式Orchestrator常驻上下文宪法速查 技能路由上下文被压缩、模型忽略长指令Skills按文件类型按需加载详细规范模型判断不需要读就跳过Hook工具调用前执行违规直接 exit 2只能拦机械可判定的模式语义违规拦不住设计要点越往底层越不依赖模型的自觉。Layer 1 和 2 本质是软约束Layer 3 是硬约束。三棵规则树.claude/、.cursor/、.github/instructions/各自独立、互不引用保证在任意一个工具里都能单树工作。宪法MUST / SHOULD / WONT 三段式规则不是散落的几行要求而是明确分级的宪法Constitution这是 AI 规则工程的精髓——分级决定了模型的优先级MUST依赖注入测试里禁止new LoginPage(page)必须走 fixture、test/expect只能从fixtures/pom/test-options.ts导入、选择器按getByRole → getByLabel → getByPlaceholder → getByText → getByTestId优先级、API schema 必须z.strictObject()、响应校验必须expect(Schema.parse(body)).toBeTruthy()、改动持久状态的测试必须有清理钩子。SHOULDhappy-path 数据用 Faker factory、测试独立用beforeEach、步骤用 Given/When/Then、公共方法写 JSDoc。WONT禁 XPath、禁waitForTimeout()、禁魔法数字、禁any、禁手动实例化 Page Object、禁硬编码密钥、禁describe上打 tag、每个测试只能有一个 tag 且functional被禁用、禁z.object()用strictObject、静态测试数据必须用.tsas constJSON 被禁理由是 JSON 表达不了undefined、没注释、没类型、表单/CRUD 页面对象必须有成功/错误/校验反馈的选择器。其中每测试一 tag 禁用functional这条很妙tag 是 CI 分层的依据destructive是重 tag 且永远优先——它只用于共享/全局状态locale、权限、feature flag只创建自己数据并清理的测试保留重要级 tag。这避免了 tag 语义在演进中腐化。硬拦截enforce_constitution.py 的实现Hook 契约是 Claude Code 的 PreToolUse 标准stdin 收 JSONtool_nametool_inputexit 0 放行exit 2 拦截并把 stderr 反馈给 Agent。核心是 7 条规则表RULES [ (no-playwright-test-import, SPEC_FILE, lambda c: re.search(rfrom\s[]playwright/test[], c), Spec files must import test/expect from fixtures/pom/test-options.ts), (no-hard-wait, TS_FILE, lambda c: waitForTimeout( in c, waitForTimeout() is forbidden. Use web-first assertions), (no-loose-schema, SCHEMA_FILE, lambda c: re.search(r\bz\.object\(, c), API schemas must use z.strictObject(), never z.object()), (no-xpath, UI_OR_TEST_FILE, lambda c: re.search(rxpath|locator\(\s*[]//, c), XPath selectors are forbidden), (no-json-static-data, STATIC_JSON, None, test-data/static/ must be TypeScript, JSON is forbidden), (no-functional-tag, SPEC_FILE, lambda c: functional in c, The functional tag is forbidden), (no-tags-on-describe, SPEC_FILE, TAG_IN_DESCRIBE, Tags belong on individual tests, never in test.describe()), ]每条规则 (规则 ID, 路径谓词, 内容谓词, 提示语)。路径谓词决定规则作用域比如no-loose-schema只查fixtures/api/schemas/下的文件内容谓词做正则/子串匹配。内容谓词为None表示写这个路径本身就是违规如 JSON 静态数据连内容都不用看。最值得抄的细节是added_content()Hook 只检查本次工具调用新增的内容Write取contentEdit取new_stringMultiEdit拼接所有new_string而不是整个文件。否则编辑历史文件时会把旧代码的违规也算进来产生误报。另外 malformed payload 一律 return 0 放行——Hook 基础设施出错时宁可放过不可误杀这是把 Hook 接进主工作流的底线。def added_content(tool_name: str, tool_input: dict) - str: if tool_name Write: return tool_input.get(content, ) if tool_name Edit: return tool_input.get(new_string, ) if tool_name MultiEdit: return \n.join(e.get(new_string, ) for e in tool_input.get(edits, [])) return 注意这套拦截刻意只覆盖机械可判定、零误报风险的子集。XPath、硬等待、z.object、错误 import 是字符串级可判定的但这个选择器语义对不对这个步骤拆分合不合理判不了——那些交给 Layer 1/2 的软约束。把硬 Hook 用在不可靠的判定上只会让团队天天跟误报搏斗最后把 Hook 关掉。fixture 组合单入口 mergeTests测试文件只有一个 import 入口fixtures/pom/test-options.ts三层 fixture 通过mergeTests()组合互不耦合// fixtures/pom/test-options.ts —— 测试唯一 import 点 export const test mergeTests( pageObjectFixtures, // appPage / resetStorageState apiRequestFixtures, // apiRequestT() 泛型 Zod 校验 helperFixtures // createdResource 等 setup/teardown );apiRequestfixture 是 API 测试的唯一通道配合 Zod 4 的顶层格式校验器做运行时契约校验import { z } from zod; export const UserResponseSchema z.strictObject({ id: z.uuid(), email: z.email(), token: z.string(), }); export type UserResponse z.infertypeof UserResponseSchema; // 测试里响应不符合 schema 直接抛错 const { status, body } await apiRequestUserResponse({ method: GET, url: /api/users/me, baseUrl: process.env.API_URL, }); expect(status).toBe(200); expect(UserResponseSchema.parse(body)).toBeTruthy();z.strictObject()而不是z.object()是有意为之多出来的未知字段说明契约变了宁可让测试红也不要静默 strip。API 契约以 OpenAPI/Swagger 文档为准建 schema文档缺失才允许抓真实响应兜底且要标记缺文档。运行时与文档不符是 bug处理方式是test.skip// FIXME注释而不是放宽 schema——No Silent Coverage DropsOpenAPI 里的每个状态码都必须有测试pass、fail 或带理由地 skip就是不许悄悄消失。数据策略静态边界值 vs 动态 factory 的分层测试数据是 AI 最容易乱来的地方——它倾向于在测试里内联写死几组数据或者全部用 Faker 随机生成。这个项目把数据分成三层规则写进data-strategyskill 里层位置内容为什么通用非法值test-data/static/util/invalid-values.ts全项目通用的边界/非法值空串、超长、特殊字符、null一份定义所有接口的负面用例共用领域静态数据test-data/static/{area}/*.ts业务相关的精选集合如登录的 N 组无效凭证可读、可 review是人肉挑选的测试意图动态数据test-data/factories/{area}/*.factory.tsFaker Zod 生成的 happy-path 数据测试隔离避免共享状态静态数据强制.tsas constJSON 被明令禁止理由写得很实在JSON 表达不了undefined、没有注释、没有类型、没有字面量收窄的自动补全。配合 Hook 里的no-json-static-data规则写一个 JSON 数据文件直接 exit 2。// test-data/factories/app/user.factory.ts —— 动态数据用 Faker 生成 export function createUser(overrides: PartialUser {}): User { return { id: faker.string.uuid(), email: faker.internet.email(), name: faker.person.fullName(), ...overrides, // 测试里只覆盖关心的字段 }; }数据驱动测试用循环在 test 块外展开而不是在测试内部 for 循环——每个数据组合是一个独立用例失败时能精确定位到哪组数据for (const { description, email, password } of INVALID_LOGIN_ATTEMPTS) { test(should show error for ${description}, { tag: regression }, async ({ appPage }) { await appPage.openHomePage(); await appPage.login(email, password); await expect(appPage.errorMessage).toBeVisible(); }); }生命周期helper fixture 的 setup/yield/teardown重复 3 个文件都要用的多步 setup/teardown不要写 helper 函数用 helper fixture——生命周期由 Playwright 保证即使断言失败 teardown 也会执行这是函数式清理做不到的// fixtures/helper/helper-fixture.ts export const test base.extend({ createdResource: async ({ apiRequest }, use) { // setupAPI 创建资源走 apiRequest不是 UI 点击 const { body } await apiRequestResource({ method: POST, url: /api/resources, body: {...} }); await use(body); // 测试体拿到已创建的资源 // teardown无论测试成败都删除 await apiRequest({ method: DELETE, url: /api/resources/${body.id} }); }, }); // 测试里只关心业务断言 test(should edit resource, async ({ createdResource, appPage }) { await appPage.navigateToResource(createdResource.id); });这个模式把前置数据准备和后置清理从测试体里彻底剥离测试只剩业务意图。配合宪法的 MUST 规则任何改动持久状态的测试必须有清理钩子共享状态污染在结构上被消灭。踩坑记录与使用建议三棵规则树的镜像同步是手工纪律。npm run check:skills-drift和check:skills-references只 gate.claude/skills/锚点漂移、坏引用、孤儿文件.cursor/与.github/的镜像一致性没有自动校验。改一条规则要在三个树同步改README 明说这是 hand-maintained discipline——多工具团队建议自己补一个 diff 校验脚本。版本单源根目录VERSION文件是唯一真相npm run version:stamp写回package.jsoncheck:version断言 VERSION package.json CHANGELOG 最新标题pre-commit 和 CI 双门禁。上游 skillplaywright-cli、skill-creator用skills-lock.json记录 SHA-256skills:verify检测漂移。这套锁文件 哈希校验的思路可以平移到任何 vendored 依赖。工作流黄金法则Verify → Commit → Proceed。每次 prompt 只做一件事一个页面对象/一个测试文件/一个 schema生成后 review → 跑测试 → 提交 → 再下一个。AI 上下文里已提交的代码就是可信事实未提交的就是待验证假设。配合 ai-native-workflow skill 的置信度门禁每个计划必须带Confidence: 1-10RationaleUnknowns低于 5 分强制停下问人禁止基于猜测出方案。探索先行写页面对象/选择器前必须用playwright-cli而非 codegen、IDE 浏览器 MCP做 UI 探索playwright-cli不可用就停下通知人不换工具替代。这条 WONT 保证了选择器来源单一、可审计。总结与进阶方向这个项目的核心贡献不是又一个 Playwright 脚手架而是把AI 写测试这件事从 prompt 工程问题变成了规则工程 工具链问题Orchestrator 管路由、Skills 管详规、PreToolUse Hook 管硬拦截三层各自承担模型自觉性下降时的兜底。对测试开发团队最可迁移的三样东西MUST/SHOULD/WONT 分级宪法、只查增量内容的 Hook 写法、单 import 点的 fixture 组合。进阶方向一是把 Hook 的机械规则扩展到 ESLint 自定义 rule目前 hook 用 Python 正则接 ESLint 能吃到 AST 级判定比如禁any的完整语义二是给 Copilot 树补上 drift 校验三是把同样的三层架构用到 API 契约测试之外——比如把 OpenAPI 变更检测也做成写入时拦截契约漂移在提交前就死掉。
返回列表