RealWorld E2E 测试选择器契约:用一份 SELECTORS 文档统一所有前端实现的 Playwright 共享测试

RealWorld E2E 测试选择器契约:用一份 SELECTORS 文档统一所有前端实现的 Playwright 共享测试 RealWorld E2E 测试选择器契约用一份 SELECTORS 文档统一所有前端实现的 Playwright 共享测试【免费下载链接】realworldThe mother of all demo apps — Exemplary fullstack Medium.com clone powered by React, Angular, Node, Django, and many more项目地址: https://gitcode.com/GitHub_Trending/re/realworldspecs/e2e/SELECTORS.md是 RealWorldThe mother of all demo apps一个 Medium 风格的全栈示例项目共享 E2E 测试套件的选择器契约文档。它完整列举了仓库内这套 Playwright 测试所依赖的全部 CSS 类、HTMLname属性、可见文本标签、前端路由与调试接口任何希望复用这套共享 E2E 测试的 RealWorld 实现都必须提供文档中列出的每一个选择器。读完本文你将掌握这份契约的完整内容、每个选择器在测试代码中的真实用法以及 SPA / SSR / Fullstack 三种部署形态下测试模式的差异能够据此让一个前端实现顺利接入 RealWorld 的共享测试套件。1. 契约的定位为什么需要一份选择器合同RealWorld 的设计目标是让同一套接口规范被 React、Angular、Node、Django 等众多技术栈实现。为了让验证 UI 行为的 E2E 测试可以跨实现复用测试不能针对某一种框架的内部 DOM 结构编写而必须针对一份所有实现共同遵守的最小 DOM 契约。SELECTORS.md就是这份合同文档开头即声明This document lists every CSS class, HTML attribute, text label, route, and interface that the shared e2e tests depend on. Any RealWorld implementation that wants to use these testsmustprovide all of the selectors below.契约的稳定性直接决定共享测试的可移植性实现方缺少任何一个选择器、或文案不一致都会直接导致测试失败。契约中定义的每个选择器都可以在 helpers 目录 中找到真实的消费方auth.ts注册/登录/登出、articles.ts文章 CRUD 与收藏、comments.ts评论、setup.ts数据准备等各*.spec.ts文件如 articles.spec.ts、social.spec.ts则基于这些 helper 编写具体用例。2. 表单输入一律用name属性定位契约的第一原则是优先使用 HTML 标准属性而非自定义 class。表单输入统一通过name属性定位——它在所有框架中语义稳定、不随样式重构变化。契约要求的输入元素如下Selector出现页面input[nameusername]Register, Settingsinput[nameemail]Login, Register, Settingsinput[namepassword]Login, Register, Settingsinput[nametitle]Editorinput[namedescription]Editortextarea[namebody]Editorinput[nameimage]Settingstextarea[namebio]Settingsinput[placeholderEnter tags]Editortextarea[placeholderWrite a comment...]Article detail注意两个例外Editor 的标签输入框与文章详情页的评论输入框没有name属性契约退而使用placeholder文本定位这意味着实现方必须原样保留这两个 placeholder 文案。auth.ts 中的注册流程完整演示了这类选择器的用法并顺带覆盖了错误提示契约见第 7 节export async function register(page: Page, username: string, email: string, password: string) { await page.goto(/register, { waitUntil: load }); await page.fill(input[nameusername], username); await page.fill(input[nameemail], email); await page.fill(input[namepassword], password); // Wait for navigation to complete or error to appear try { await Promise.all([page.waitForURL(/), page.click(button[typesubmit])]); } catch (error) { const errorMsg await page .locator(.error-messages) .textContent() .catch(() ); if (errorMsg) { throw new Error(Registration failed: ${errorMsg}); } throw error; } }登录/logininput[nameemail]input[namepassword]采用同样的先并发等待导航、失败后读取.error-messages文本的模式登出则通过button:has-text(Or click here to logout)触发。3. CSS 类契约按页面模块分组契约将必须提供的 CSS 类按功能模块分为八组每组都规定了类名、挂载元素与用途。3.1 布局与导航ClassElementPurpose.navbarnavMain navigation bar.navbar-brandaLogo / site name link.nav-linkaNavigation tab links.bannerdivHome page hero banner.containerdivPage width container3.2 Feed 与文章ClassElementPurpose.feed-toggledivGlobal Feed / Your Feed tab bar.article-previewdivArticle card in a feed list.article-metadivAuthor avatar name date.article-contentdivRendered article body.article-pagedivArticle detail page wrapper.preview-linkaClickable link wrapping the preview.authoraAuthor name in article meta.empty-feed-messagedivNo articles here placeholder3.3 标签TagsClassElementPurpose.sidebardivHome page sidebar.tag-listdivContainer for tag pills.tag-defaultspanBase tag class.tag-pillspanPill-shaped tag3.4 评论CommentsClassElementPurpose.carddivComment card (also wraps.comment-form).card-blockdivComment text body.comment-formdivComment input form card.comment-author-imgimgCommenters avatar.mod-optionsspanDelete button container.ion-trash-aiDelete icon (inside.mod-options)契约在此处额外规定了一条组合选择器测试用.card:not(.comment-form) .card-block来选取已发表的评论因为评论表单本身也复用.card.card-block结构。comments.ts 忠实执行了这一约定——addComment先用该选择器统计初始评论数点击Post Comment后通过page.waitForFunction等待数量加一const initialCount await page.locator(.card:not(.comment-form) .card-block).count(); await page.click(button:has-text(Post Comment)); await page.waitForFunction( expectedCount document.querySelectorAll(.card:not(.comment-form) .card-block).length expectedCount, initialCount 1, { timeout: 5000 }, );删除评论则定位到包含指定文本的评论卡片再点击其内部的span.mod-options i.ion-trash-a图标按钮。3.5 个人主页ProfileClassElementPurpose.profile-pagedivProfile page wrapper.user-infodivUsername, bio, avatar section.user-imgimgLarge avatar on profile page.user-picimgSmall avatar in the navbar3.6 分页PaginationClassElementPurpose.paginationnav/divPagination container.page-itemli/divIndividual page button wrapper3.7 按钮ButtonsClassElementPurpose.btn-outline-primarybuttonFavorite (not yet favorited).btn-primarybuttonUnfavorite (already favorited).btn-outline-dangerbuttonDestructive action (logout)这三个按钮类不只是样式名它们承载了状态语义详见第 8 节。articles.ts 中的收藏操作完全依赖类名 文本的组合选择器export async function favoriteArticle(page: Page) { await page.click(button.btn-outline-primary:has-text(Favorite)); // Wait for the button to update to Unfavorite await page.waitForSelector(button.btn-primary:has-text(Unfavorite)); }3.8 错误提示ErrorsClassElementPurpose.error-messagesulValidation / API error list这是所有表单页Login / Register / Settings的公共约定后端返回的校验错误必须渲染为一个ul.error-messages测试才能像 auth.ts 那样读取错误文案并抛出带上下文信息的失败原因。4. 文本定位契约:has-text()可见文本与输入框走name属性不同按钮和链接统一通过可见文本定位。这意味着按钮文案是硬性契约实现方不能自由翻译或改写。4.1 按钮TextElementContextPost CommentbuttonArticle detailDelete ArticlebuttonArticle detailPublish ArticlebuttonEditorUpdate SettingsbuttonSettingsOr click here to logoutbuttonSettingsFollow/UnfollowbuttonProfile, article metaFavorite/UnfavoritebuttonArticle detailFavorite ArticlebuttonArticle detail (variant)Publish Article在 articles.ts 的createArticle/editArticle以及 setup.ts 的表单回退路径中均被点击Delete Article同理。Favorite Article作为Favorite的变体文案被明确允许即按钮文本可以是Favorite或Favorite Article。4.2 标题与链接TextElementContextSign inh1Login page headingSign uph1Register page headingGlobal Feeda.nav-linkHome feed toggleYour Feeda.nav-linkHome feed toggleFavoritedaProfile tabEdit ArticleaArticle detailEdit Profile SettingsaProfile pageHomea.nav-linkNavbar5. 路由契约页面路由与 API 端点5.1 页面路由E2E 测试直接按下列路由导航实现方的前端路由表必须与之对齐RoutePage/Home / Global Feed/?feedfollowingYour Feed/?pageNPaginated feed/tag/:tagFiltered by tag/tag/:tag?pageNPaginated tag view/loginLogin/registerRegister/editorNew article/editor/:slugEdit article/settingsUser settings/profile/:usernameUser profile/profile/:username/favoritesUsers favorited articles/article/:slugArticle detail5.2 API 端点用于路由拦截契约同时规定了测试会拦截/调用的一批 API 路径供page.route()拦截与APIRequestContext直连使用EndpointMethods/api/articles*GET/api/articles/:slugGET, PUT, DELETE/api/articles/:slug/commentsGET, POST/api/articles/:slug/comments/:idDELETE/api/articles/:slug/favoritePOST, DELETE/api/usersPOST (register)/api/users/loginPOST/api/userGET, PUT/api/profiles/:usernameGET/api/profiles/:username/followPOST, DELETE/api/tagsGET在 api.ts 中可以看到这些端点的真实消费方式registerUserViaAPI向${API_BASE}/users发 POST 并取出data.user.tokenloginUserViaAPI打${API_BASE}/users/logincreateArticleViaAPI携带Authorization: Token token头向${API_BASE}/articles发 POST。API_BASE定义在 config.ts 中默认值为https://api.realworld.show/api可通过环境变量API_BASE覆盖为本地后端。API 路径与 API 规范 及 接口文档 保持一致。6. 调试接口契约window.__conduit_debug__DOM 之外契约要求实现方必须在window.__conduit_debug__上暴露一个调试接口interface ConduitDebug { getToken(): string | null; getAuthState(): authenticated | unauthenticated | unavailable | loading; getCurrentUser(): { username: string; email: string; bio: string | null; image: string | null; token: string } | null; }getAuthState()的四态设计值得注意unavailable与unauthenticated是不同概念调试接口不可用 vs. 已确认未登录loading允许测试等待鉴权解析完成。helpers/debug.ts 给出了该契约的完整参考实现与使用指南getToken(page)page.evaluate(() window.__conduit_debug__?.getToken() ?? null)读取当前 JWTgetAuthState(page)读取当前鉴权状态getCurrentUser(page)读取当前用户对象waitForAuthState(page, expectedState, { timeout })基于page.waitForFunction轮询getAuthState()默认超时 5000ms用于登录/登出后等待状态落定isDebugInterfaceAvailable(page)判断接口是否存在便于实现方未实现时跳过相关用例。有了这个接口测试就不必通过检查导航栏头像、用户名等 UI 细节来断言登录态跨实现的稳定性显著提升。7. 其余契约细节LocalStorage、默认头像与错误列表LocalStorage 契约JWT 必须存放在jwtToken键下。在BROWSER_API场景SPA 形态中测试可以直接在localStorage中注入 token 来伪造登录态因此该键名同样是硬契约KeyValuePurposejwtTokenJWT stringAuthentication token默认头像规则当用户的image为null或空时所有头像元素.user-img、.user-pic、.comment-author-img、.article-meta img的src属性必须包含default-avatar.svg仓库内对应资源见 default-avatar.svg。null-fields.spec.ts 专门验证字段缺失时的兜底渲染。错误列表规则第 3.8 节.error-messagesul是 Login、Register、Settings 三个页面的统一校验错误容器error-handling.spec.ts 与 auth helper 均依赖它读取错误文案。8. 按钮状态约定类名即状态机契约最后规定了三组状态可视化约定测试通过它们断言操作结果Favorite未收藏时使用.btn-outline-primary点击收藏后切换为.btn-primary文本同步在Favorite/Unfavorite间切换。articles.ts 的favoriteArticle/unfavoriteArticle正是点击旧态 → 等待新态的实现Follow按钮文本在Follow {username}与Unfollow {username}之间切换social.spec.ts 相关用例依赖该文案Pagination active当前页对应的.page-item必须带有 CSS 类active。这三条约定把服务端状态变更映射为可断言的 DOM 状态是 UI 测试无需额外接口查询就能验证收藏、关注与翻页正确性的关键。9. 实现方如何接入这套共享测试综合契约与测试基建一个 RealWorld 实现接入共享 E2E 测试的步骤是将specs/e2e目录复制到工程内playwright.base.ts约定testDir: ./e2e通常放在e2e/目录在playwright.config.ts中扩展 playwright.base.ts 导出的baseConfig并按其头部注释补上baseURL与webServer配置逐项核对本文第 28 节的契约name属性、.error-messages、八组 CSS 类、按钮/链接文案、路由表、window.__conduit_debug__、jwtToken键与默认头像规则按部署形态设置TEST_MODE环境变量。playwright.base.ts 的基线配置本身就体现了共享套件的运行策略export const baseConfig: PlaywrightTestConfig { testDir: ./e2e, fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 1, workers: 1, reporter: html, timeout: 15_000, use: { trace: on-first-retry, screenshot: only-on-failure, actionTimeout: 5_000, navigationTimeout: 10_000, }, expect: { timeout: 5_000 }, projects: [{ name: chromium, use: { ...devices[Desktop Chrome] } }], };要点workers: 1fullyParallel: false保证用例串行执行用例间共享服务端数据串行避免互相污染CI 下forbidOnly防止误提交的test.only重试 2 次本地 1 次trace: on-first-retry与screenshot: only-on-failure确保失败时留痕仅配置 chromium 一个项目。10. 三种测试模式spa / ssr / fullstackconfig.ts 定义了共享套件最重要的适配层通过TEST_MODE环境变量选择运行模式并派生出两个能力标志各 spec 文件应基于标志而非模式名做分支TEST_MODEBROWSER_APIEXTERNAL_APIspatruetruessrfalsetruefullstackfalsefalsespa默认实现是 REST API 的浏览器客户端浏览器自己发 API 请求、在客户端持有 JWT。因此测试可以page.route()拦截 API 流量、通过localStorage注入 token、等待浏览器 API 响应跨用户场景可使用演示后端预置用户如johndoe。ssr实现由服务器代浏览器访问外部 REST API如 SvelteKit/Next.js 的服务端渲染 httpOnly cookie 鉴权。浏览器侧没有可拦截的 API 流量也没有客户端 token因此依赖BROWSER_API的用例会跳过但测试运行器仍可直连 API 做快速数据准备预置用户可用。fullstack实现拥有完整前后端测试不假设存在独立可达的 API 或种子数据一切通过 UI 驱动跨用户场景自建用户。兼容性方面config.ts同时保留了旧版布尔变量API_MODE当TEST_MODE未设置时API_MODEfalse等价于fullstack其余取值等价于spa模块中导出的API_MODE别名指向BROWSER_API并标记为deprecated。这套能力标志直接影响了数据准备的策略setup.ts 的createManyArticles为分页测试批量造文章在EXTERNAL_API request token时走 API 直连api.ts 的createManyArticles每次创建间隔 100ms 以防限流否则回退到纯表单路径——逐次打开/editor填充input[nametitle]、input[namedescription]、textarea[namebody]向input[placeholderEnter tags]输入标签并按回车再点击Publish Article等待跳转到/article/*。health.spec.ts 等用例也依据这些标志决定检查深度。11. 小结契约驱动的跨实现 E2E 测试SELECTORS.md的价值在于把测试能依赖什么从隐性约定变成了可审查的合同定位策略分层输入走name含两个placeholder例外、按钮/链接走可见文本、模块容器走固定 CSS 类、鉴权态走window.__conduit_debug__、登录态注入走localStorage的jwtToken状态可视化约定收藏/关注/分页的 DOM 状态机让 UI 断言自洽无需二次调用接口路由与 API 路径锁定/article/:slug、/?feedfollowing等路由和/api/*端点既是导航契约也是网络拦截契约部署形态解耦TEST_MODEBROWSER_API/EXTERNAL_API能力标志使同一套 spec 文件同时覆盖 SPA、SSR 与全栈实现。对实现方而言接入这套测试的成本就是对照本文各节逐项补齐 DOM 与调试接口对测试维护方而言任何用例改动都可以回到这份契约中追溯其选择器依据。相关的 API 行为契约可继续参考 API 测试总览、OpenAPI 规范 与 后端接口文档。【免费下载链接】realworldThe mother of all demo apps — Exemplary fullstack Medium.com clone powered by React, Angular, Node, Django, and many more项目地址: https://gitcode.com/GitHub_Trending/re/realworld创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考