hyva-playwright-test

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Writing Playwright Tests for Hyvä + Alpine.js

为Hyvä + Alpine.js编写Playwright测试

Overview

概述

Hyvä replaces Luma's KnockoutJS/RequireJS/jQuery with Alpine.js + Tailwind CSS. Playwright's strict mode (rejects locators matching multiple elements) conflicts with Alpine.js DOM patterns where hidden elements exist throughout the page. This skill documents pitfalls and solutions discovered while writing Playwright tests for Hyvä storefronts.
Hyvä 使用Alpine.js + Tailwind CSS替代了Luma主题的KnockoutJS/RequireJS/jQuery。Playwright的严格模式(拒绝匹配多个元素的定位器)与Alpine.js的DOM模式存在冲突,因为Alpine.js页面中随处可见隐藏元素。本文档记录了在为Hyvä前端编写Playwright测试时发现的陷阱及解决方案。

The #1 Rule: Hidden Alpine Elements

第一条规则:隐藏的Alpine元素

Hyvä templates scatter elements like
<div x-show="displayErrorMessage" class="message error">
throughout the DOM. These are invisible but present, so a bare selector like
.message.error
matches both hidden and visible instances, causing Playwright strict mode violations.
Always scope page-level messages to the
#messages
container:
typescript
// WRONG — matches hidden Alpine x-show elements throughout DOM
await expect(page.locator('.message.success')).toContainText('Added to cart');
await expect(page.locator('.message-error')).toContainText('Error');

// RIGHT — scoped to the visible messages container
await expect(page.locator('#messages .message.success')).toContainText('Added to cart');
await expect(page.locator('#messages .message-error, #messages .message.error')).toContainText('Error');
Never use: bare
.message
,
.message.error
,
.message.success
, or
div.message
as selectors.
Exception — inline page messages: Not all
.message
elements are flash messages. The search results "no results" notice (
.message.notice
) renders as static inline content inside
#maincontent
, not inside the
#messages
container. For these inline messages, the bare class selector is correct.
Hyvä模板在DOM中散布着类似
<div x-show="displayErrorMessage" class="message error">
的元素。这些元素不可见但确实存在,因此像
.message.error
这样的裸选择器会同时匹配隐藏和可见的实例,导致Playwright严格模式违规。
请始终将页面级消息限定在
#messages
容器内:
typescript
// 错误写法 —— 匹配DOM中所有隐藏的Alpine x-show元素
await expect(page.locator('.message.success')).toContainText('Added to cart');
await expect(page.locator('.message-error')).toContainText('Error');

// 正确写法 —— 限定在可见的消息容器内
await expect(page.locator('#messages .message.success')).toContainText('Added to cart');
await expect(page.locator('#messages .message-error, #messages .message.error')).toContainText('Error');
禁止使用: 裸选择器
.message
.message.error
.message.success
div.message
例外情况——内联页面消息: 并非所有
.message
元素都是弹窗消息。搜索结果中的“无结果”提示(
.message.notice
)是渲染在
#maincontent
内的静态内联内容,而非
#messages
容器中。对于这类内联消息,直接使用类选择器是正确的。

Selector Strategy

选择器策略

  1. getByRole()
    — always prefer — closest to how users perceive the page. Avoids text ambiguity where the same text appears in headings, links, breadcrumbs, and
    sr-only
    spans.
  2. getByLabel()
    — for form controls (checkboxes, inputs with associated labels).
  3. getByText()
    — for non-interactive elements, scoped to a container (e.g.,
    page.locator('#maincontent').getByText(...)
    ).
  4. getByPlaceholder()
    ,
    getByAltText()
    — for inputs and images respectively.
  5. getByTestId()
    — when Hyvä provides
    data-testid
    attributes or when adding custom test IDs.
  6. CSS selectors — last resort, only when user-facing locators aren't available. Prefer
    aria-*
    attribute selectors (e.g.,
    [aria-label="pagination"]
    ,
    [aria-current="page"]
    ) over class-based selectors. When CSS is necessary, scope to a unique container (e.g.,
    #messages .message.success
    ).
Avoid:
:visible
pseudo-selector — per Playwright docs, "it's usually better to find a more reliable way to uniquely identify the element." Scope to a container or use role/attribute selectors instead. Only use
:visible
as an absolute last resort when the DOM provides no other way to distinguish elements.
  1. getByRole()
    —— 优先使用 —— 最贴近用户对页面的感知方式。避免文本歧义,比如相同文本出现在标题、链接、面包屑和
    sr-only
    跨度标签中的情况。
  2. getByLabel()
    —— 用于表单控件(复选框、带有关联标签的输入框)。
  3. getByText()
    —— 用于非交互元素,限定在容器内(例如:
    page.locator('#maincontent').getByText(...)
    )。
  4. getByPlaceholder()
    getByAltText()
    —— 分别用于输入框和图片。
  5. getByTestId()
    —— 当Hyvä提供
    data-testid
    属性或添加自定义测试ID时使用。
  6. CSS选择器 —— 最后手段,仅当没有面向用户的定位器时使用。优先使用
    aria-*
    属性选择器(例如
    [aria-label="pagination"]
    [aria-current="page"]
    )而非基于类的选择器。当必须使用CSS时,限定在唯一容器内(例如
    #messages .message.success
    )。
避免使用:
:visible
伪选择器 —— 根据Playwright文档,“通常最好找到更可靠的方式来唯一识别元素”。改为限定在容器内或使用角色/属性选择器。仅当DOM没有其他方式区分元素时,才将
:visible
作为绝对最后的手段。

Alpine.js Interaction Patterns

Alpine.js交互模式

PatternProblemSolution
x-show
hidden elements
Strict mode: multiple matchesScope to unique container (
#messages
), use role/attribute selectors
x-defer="intersect"
Element not initialized until visible
scrollIntoViewIfNeeded()
before interacting
x-if
(template)
Elements don't exist in DOM until condition trueClick the trigger first, then query children
x-model
on inputs
Alpine clears value after form submitDon't assert input value post-submit; verify via success message
x-text
/
x-html
async
Cart badge updates asynchronouslyUse web-first assertions with timeout:
not.toHaveText('0', { timeout: 15_000 })
x-show
submenus
Hidden until hover
hover()
on parent before clicking child
Alpine form revealFields hidden until checkbox checked
waitFor({ state: 'visible' })
after checking the checkbox
press('Enter')
on input
May submit Alpine-bound form unexpectedlyPrefer explicit
.click()
on submit button
模式问题解决方案
x-show
隐藏元素
严格模式下匹配多个元素限定在唯一容器(
#messages
),使用角色/属性选择器
x-defer="intersect"
元素在可见前未初始化交互前调用
scrollIntoViewIfNeeded()
x-if
(模板)
元素在条件为真前不存在于DOM中先点击触发元素,再查询子元素
输入框上的
x-model
Alpine在表单提交后清除值提交后不要断言输入框值;通过成功消息验证
x-text
/
x-html
异步更新
购物车徽章异步更新使用带超时的web-first断言:
not.toHaveText('0', { timeout: 15_000 })
x-show
子菜单
悬停前隐藏点击子元素前先对父元素执行
hover()
Alpine表单显示字段在复选框勾选前隐藏勾选复选框后调用
waitFor({ state: 'visible' })
输入框上的
press('Enter')
可能意外提交绑定Alpine的表单优先使用显式的
.click()
点击提交按钮

Assertions

断言

Always use web-first assertions that auto-wait and retry:
typescript
// DO — auto-retries             // DON'T — no retry
await expect(loc).toBeVisible(); // expect(await loc.isVisible()).toBe(true);
await expect(loc).toContainText('X'); // expect(await loc.textContent()).toContain('X');
For async Alpine.js updates (cart counts, prices), use extended timeouts on the assertion — never
waitForTimeout()
:
typescript
// Cart count updates asynchronously via Alpine x-text
await expect(page.locator('#menu-cart-icon span[x-text="summaryCount"]'))
  .not.toHaveText('0', { timeout: 15_000 });
始终使用web-first断言,它会自动等待并重试:
typescript
// 推荐写法 —— 自动重试             // 不推荐写法 —— 无重试机制
await expect(loc).toBeVisible(); // expect(await loc.isVisible()).toBe(true);
await expect(loc).toContainText('X'); // expect(await loc.textContent()).toContain('X');
对于Alpine.js的异步更新(购物车数量、价格),在断言中使用延长的超时时间——绝不要使用
waitForTimeout()
typescript
// 购物车数量通过Alpine x-text异步更新
await expect(page.locator('#menu-cart-icon span[x-text="summaryCount"]'))
  .not.toHaveText('0', { timeout: 15_000 });

Hyvä vs Luma Selector Differences

Hyvä与Luma的选择器差异

ElementHyvä SelectorLuma Selector
Pagination nav
getByRole('navigation', { name: 'pagination' })
ul.pages-items
Page link
getByRole('link', { name: 'Page 2' })
.pages-items li a
Active page
[aria-current="page"]
<strong>
element
Filter button
getByRole('button', { name: 'Color filter' })
.filter-options-title
Cart icon badge
#menu-cart-icon > span[x-text="summaryCount"]
.counter-number
Account menu
#customer-menu + nav
.customer-menu
Success message
#messages .message.success
.message-success
Error message
#messages .message-error, #messages .message.error
.message-error
Main menu
getByRole('navigation', { name: 'Main menu' })
nav.navigation
Footer nav
getByRole('navigation', { name: 'Company Menu' }).getByRole('link', { name })
nav ul li:nth-child(N) a
Product image
#gallery img[itemprop="image"]
#gallery img:visible
Add to Cart (card)
getByRole('button', { name: /Add to Cart/ }).first()
button.btn-primary:visible
元素Hyvä选择器Luma选择器
分页导航
getByRole('navigation', { name: 'pagination' })
ul.pages-items
页码链接
getByRole('link', { name: 'Page 2' })
.pages-items li a
当前页码
[aria-current="page"]
<strong>
元素
筛选按钮
getByRole('button', { name: 'Color filter' })
.filter-options-title
购物车图标徽章
#menu-cart-icon > span[x-text="summaryCount"]
.counter-number
账户菜单
#customer-menu + nav
.customer-menu
成功消息
#messages .message.success
.message-success
错误消息
#messages .message-error, #messages .message.error
.message-error
主导航
getByRole('navigation', { name: 'Main menu' })
nav.navigation
页脚导航
getByRole('navigation', { name: 'Company Menu' }).getByRole('link', { name })
nav ul li:nth-child(N) a
商品图片
#gallery img[itemprop="image"]
#gallery img:visible
添加到购物车(卡片)
getByRole('button', { name: /Add to Cart/ }).first()
button.btn-primary:visible

References

参考资料

See
references/
for code examples. Load files relevant to the current task:
Always useful:
  • page-object-patterns.md — Page object structure, navigation, form submits, redirects
  • selector-patterns.md — Before/after selector fixes (messages, text ambiguity, forms)
Page-specific (load when testing that page):
  • cart-patterns.md — Cart spinner wait, quantity changes, mini cart
  • product-patterns.md — Bundle quantities, gallery images
  • account-patterns.md — Password change (Alpine checkbox reveal)
  • category-patterns.md — Filters (x-defer scroll), pagination (ARIA)
<!-- Copyright © Hyvä Themes https://hyva.io. All rights reserved. Licensed under OSL 3.0 -->
查看
references/
目录下的代码示例。加载与当前任务相关的文件:
常用文件:
  • page-object-patterns.md —— 页面对象结构、导航、表单提交、重定向
  • selector-patterns.md —— 选择器修复前后对比(消息、文本歧义、表单)
页面特定文件(测试对应页面时加载):
  • cart-patterns.md —— 购物车加载等待、数量修改、迷你购物车
  • product-patterns.md —— 捆绑商品数量、图库图片
  • account-patterns.md —— 密码修改(Alpine复选框显示)
  • category-patterns.md —— 筛选(x-defer滚动)、分页(ARIA)
<!-- Copyright © Hyvä Themes https://hyva.io. All rights reserved. Licensed under OSL 3.0 -->