- 开发工具
- 前端
- 前端构建
【免费下载链接】stencil
A toolchain for building scalable, enterprise-ready component systems on top of TypeScript and Web Component standards. Stencil components can be distributed natively to React, Angular, Vue, (+ more) and traditional web applications from a single, framework-agnostic codebase.
导读
Web Components 组件只有在真实浏览器环境中运行,才能暴露诸如属性序列化、Shadow DOM 插槽分配、Scoped 样式隔离、生命周期与用户交互之间的竞态条件等"编译期发现不了、单测测不出"的问题。本指南以 Stencil 仓库自带的端到端组件测试目录 test/wdio 为核心,系统讲解如何基于 WebdriverIO 的@wdio/browser-runner为 Stencil 组件编写、运行与调试真实浏览器测试,涵盖测试目录规划、构建流程、渲染 API、异步匹配器与多浏览器配置,并深入源码佐证其底层实现,帮助你为组件系统搭建一套可复用的真实浏览器测试基础设施。
一、认识 WebdriverIO 组件测试体系
test/wdio目录存放的是一组 Stencil 组件测试,用于验证涉及用户交互或组件渲染的各种场景。与纯 Node 环境的 Jest 单元测试不同,这里的测试运行在真实浏览器中,能够覆盖:
- 用户点击、输入等交互触发组件重渲染后的 DOM 状态;
- 属性(
@Prop)序列化、反射到属性(reflect)等浏览器侧行为; - Shadow DOM 与 Scoped 封装下的插槽(slot)内容分发;
- 生命周期钩子、监听器(listener)在真实事件循环下的执行顺序;
- CSS 变量、自定义样式、全局脚本等浏览器特性。
从目录结构可以看到,test/wdio 下按行为主题划分了几十个测试套件:attribute-basic(属性基础)、slot-nested-order(嵌套插槽顺序)、slot-hide-content(无目标插槽时隐藏内容)、shadow-dom-basic、event-listener-capture、scoped-slot-*系列、lifecycle-*系列等。每个子目录内的组件和测试文件同处一地,保持了良好的内聚性。
可用脚本一览
test/wdio/package.json 中定义了三个核心脚本:
| 脚本 | 作用 |
|---|---|
npm run build | 使用 Stencil 编译器将所有组件构建为lazy-loaded bundle(同时构建主应用、global-script、prerender、invisible-prehydration、es2022、auto-loader、test-sibling 等多个变体) |
npm run wdio | 运行 WebdriverIO 测试 |
npm test | 依次执行build→wdio→end-to-end |
如果从仓库根目录运行,package.json 中还有两条封装命令:
npm run test.wdio # cd test/wdio && npm ci && npm run test npm run test.wdio.testOnly # 跳过构建,仅执行 wdio 测试"test.wdio": "cd test/wdio && npm ci && npm run test", "test.wdio.testOnly": "cd test/wdio && npm ci && npm run wdio"筛选与调试
可以通过--spec参数只运行特定场景:
npm run wdio -- --spec conditional-basic # 等价于运行 test/wdio/conditional-basic/cmp.test.tsx调试时推荐开启 watch 模式,让测试文件修改后自动重跑:
npm run wdio -- --spec conditional-basic --watch需要注意的是:watch 模式只监听测试文件的改动。如果修改了被测组件(cmp.tsx),需要另开一个终端手动重新执行npm run build把组件重新编译成 lazy bundle,测试才能感知到变更。这正是"组件必须先构建、后测试"这一设计约束的体现。
二、测试运行原理:组件如何进入浏览器
2.1 两步式流程
WebdriverIO 组件测试遵循一个关键前提:所有组件必须预先编译成 lazy-loaded Stencil 组件,才能执行测试。整个流程分两步:
npm run build:由node ../../bin/stencil build --es5等命令编译组件。构建产物包括dist、dist-custom-elements(输出到test-components目录,customElementsExportBehavior: 'bundle')、dist-hydrate-script(输出到hydrate目录)等,见 test/wdio/stencil.config.ts。npm run wdio:WebdriverIO 的浏览器 runner 启动后,通过 Mocha 的require钩子加载设置脚本 test/wdio/setup.ts。
2.2 setup.ts 做了什么
test/wdio/setup.ts 是连接"构建产物"与"测试用例"的桥梁,其核心逻辑是:
const testRequiresManualSetup = window.__wdioSpec__.includes('custom-elements-output-tag-class-different') || window.__wdioSpec__.includes('custom-elements-delegates-focus') || window.__wdioSpec__.includes('custom-elements-output') || window.__wdioSpec__.includes('no-external-runtime') || window.__wdioSpec__.includes('global-script') || window.__wdioSpec__.endsWith('custom-tag-name.test.tsx') || // ... if (!testRequiresManualSetup) { await import('./dist/testapp/testapp.esm.js'); } if (window.__wdioSpec__.includes('global-script.test.tsx')) { await import('./www-global-script/build/testglobalscript.esm.js'); }它依据当前运行 spec 的文件名(通过window.__wdioSpec__注入)判断:
- 绝大多数测试套件:直接动态
import('./dist/testapp/testapp.esm.js'),把全部编译好的自定义组件注册到浏览器,测试文件里无需任何额外 import即可使用<my-component>标签; - 少数特殊套件(如
custom-elements-output、no-external-runtime、global-script等)需要测试自己手动设置组件,因此被排除在自动注册之外; - 涉及全局脚本的测试,还会额外加载独立的
testglobalscript.esm.js构建产物。
该脚本顶部注释还引用了 WebdriverIO 官方wdio-browser-runner的 setup 实现(packages/wdio-browser-runner/src/browser/setup.ts)作为该做法的依据,说明这是浏览器 runner 的标准加载机制。
2.3 wdio.conf.ts 的 runner 配置
test/wdio/wdio.conf.ts 中,runner 被配置为'browser'并使用preset: 'stencil':
runner: [ 'browser', { preset: 'stencil', viteConfig: { resolve: { alias: { '@stencil/core/internal': path.resolve(__dirname, '..', '..', 'internal'), '@stencil/core': path.resolve(__dirname, '..', '..', 'internal'), }, }, }, }, ],关键点:
preset: 'stencil'是 WebdriverIO 为 Stencil 提供的官方预设,负责把测试中的 JSX/TSX 正确编译并在浏览器中渲染组件;- 通过 Vite
alias把@stencil/core指向仓库自身的internal目录,保证测试使用当前仓库正在开发的运行时而非 npm 上发布的版本,这是仓库自测(self-hosting)的核心技巧; - 测试文件通过
specs: [['./**/*.test.tsx', './**/*.test.ts']]匹配,框架为 Mocha(ui: 'bdd'),并通过mochaOpts.require: ['./setup.ts']注入 2.2 节所述设置脚本; maxInstances: 10控制并行 worker 数,waitforTimeout: 3000是异步匹配的默认超时,specFileRetries: isCI ? 1 : 0在 CI 环境对失败 spec 重试一次。
2.4 多浏览器支持
配置通过BROWSER环境变量控制运行目标浏览器:
BROWSER=CHROME npm run wdio # 默认值,仅 Chrome BROWSER=FIREFOX npm run wdio # 仅 Firefox BROWSER=EDGE npm run wdio # 仅 Edge BROWSER=ALL npm run wdio # Chrome + Edge 全量对应能力(capability)的注册逻辑位于 test/wdio/wdio.conf.ts 末尾:
if (['CHROME', 'ALL'].includes(BROWSER_CONFIGURATION)) { (config.capabilities as WebdriverIO.Capabilities[]).push({ browserName: 'chrome', browserVersion: 'stable', 'wdio:enforceWebDriverClassic': true, // < this is 3x faster? }); } // FIREFOX 与 EDGE 分支同理值得注意的是,配置中明确注释"Disable FF tests due to issues in the WebDriver protocol",且ALL组合仅包含 Chrome 与 Edge,Firefox 需要显式通过BROWSER=FIREFOX单独触发——这是仓库实测得出的兼容性取舍,并非所有浏览器都默认纳入全量回归。
三、创建新的测试套件
3.1 目录与命名约定
测试套件存放在test/wdio下的子目录中,与被测组件同处一地(colocated)。创建步骤如下:
第一步:创建一个描述性的目录名,最好是能简洁概括被测行为的名词或动词:
mkdir test/wdio/my-excellent-new-test-suite仓库中现成的命名示例:svg-class、slot-nested-order、slot-hide-content、conditional-basic、event-listener-capture等,均一目了然地描述被测行为。
第二步:在目录内创建被测组件cmp.tsx,只保留足以复现目标行为的最小实现。标签名(tag)与类名可自定义,但尽量与测试主题相关(例如更偏好<slot-relocation>而非<my-test-component-2>)。
[!IMPORTANT]组件标签名必须全局唯一,不得与其他任何测试套件的组件标签冲突。因为
setup.ts会把所有构建产物一次性注册进浏览器,重名标签会互相覆盖、导致测试结果不可信。
第三步:创建测试文件,必须以.test.tsx结尾。约定命名方式为"主组件文件名换扩展名":若组件写在cmp.tsx,测试就写在cmp.test.tsx。
第四步:运行npm run build(或从根目录执行npm run test.wdio,它会自动先构建),新组件才会进入 lazy bundle 供测试使用。
3.2 从零看一个真实套件:attribute-basic
以 test/wdio/attribute-basic/cmp.tsx 为例,被测组件覆盖了多种属性形态:
import { Component, h, Prop } from '@stencil/core'; @Component({ tag: 'attribute-basic', }) export class AttributeBasic { private _getter = 'getter'; @Prop() single = 'single'; @Prop() multiWord = 'multiWord'; @Prop({ attribute: 'my-custom-attr' }) customAttr = 'my-custom-attr'; @Prop() get getter() { return this._getter; } set getter(newVal: string) { this._getter = newVal; } render() { return ( <div> <div class="single">{this.single}</div> <div class="multiWord">{this.multiWord}</div> <div class="customAttr">{this.customAttr}</div> <div class="getter">{this.getter}</div> <div> <label class="htmlForLabel" htmlFor={'a'}> htmlFor </label> <input type="checkbox" id={'a'}></input> </div> </div> ); } }对应测试文件 test/wdio/attribute-basic/cmp.test.tsx:
import { h } from '@stencil/core'; import { render } from '@wdio/browser-runner/stencil'; import { $, expect } from '@wdio/globals'; describe('attribute-basic', () => { before(async () => { render({ template: () => <attribute-basic-root></attribute-basic-root>, }); }); it('button click rerenders', async () => { await $('attribute-basic.hydrated').waitForExist(); await expect($('.single')).toHaveText('single'); await expect($('.multiWord')).toHaveText('multiWord'); await expect($('.customAttr')).toHaveText('my-custom-attr'); await expect($('.htmlForLabel')).toHaveAttribute('for', 'a'); await expect($('.getter')).toHaveText('getter'); const button = await $('button'); await button.click(); await expect($('.single')).toHaveText('single-update'); await expect($('.multiWord')).toHaveText('multiWord-update'); await expect($('.customAttr')).toHaveText('my-custom-attr-update'); await expect($('.getter')).toHaveText('getter-update'); }); });这个套件同时验证了属性默认值渲染、@Prop({ attribute })自定义属性名映射、getter/setter 形式的@Prop,以及交互点击后组件重渲染输出新文本的完整链路。
四、编写测试用例
4.1 用render渲染组件
渲染组件使用@wdio/browser-runner/stencil提供的render辅助方法:
import { render } from '@wdio/browser-runner/stencil'; render({ template: () => <my-component></my-component>, });由于 setup.ts 已经注册了所有编译好的组件,测试里不需要 import 组件本身,模板中直接写 JSX 标签即可。
4.2 组织测试结构
推荐使用常见的describe/it语法组织测试,用before/beforeEach钩子把组件渲染进页面。完整的简单示例:
import { h } from '@stencil/core'; import { render } from '@wdio/browser-runner/stencil'; import { $, expect } from '@wdio/globals'; describe('attribute-basic', function () { before(async () => { render({ template: () => <attribute-basic-root></attribute-basic-root>, }); }); it('button click rerenders', async () => { await expect($('.single')).toHaveText('single'); // ... }); });$选择器与expect断言均来自@wdio/globals,与 WebdriverIO 的 e2e 测试 API 保持一致。
4.3 真实套件解析:slot-hide-content
test/wdio/slot-hide-content/cmp.test.tsx 展示了更复杂的用法——在beforeEach中渲染带插槽内容的组件、为按钮绑定 DOM 事件、并直接操作document查询节点:
import { Fragment, h } from '@stencil/core'; import { render } from '@wdio/browser-runner/stencil'; describe('slot-hide-content', function () { beforeEach(async () => { render({ template: () => ( <> <slot-hide-content-scoped className="test-cmp"> <p id="slotted-1">Hello</p> </slot-hide-content-scoped> <slot-hide-content-open className="test-cmp"> <p id="slotted-2">Hello</p> </slot-hide-content-open> <button type="button">Enable slot</button> </> ), }); await $('button').waitForExist(); document.querySelector('button').addEventListener('click', () => { document.querySelectorAll('.test-cmp').forEach((ref) => ref.setAttribute('enabled', true)); }); }); describe('scoped encapsulation', () => { it('should hide content when no slot is provided', async () => { const host = document.body.querySelector('slot-hide-content-scoped'); const slottedContent = host.querySelector('#slotted-1'); expect(slottedContent).toBeDefined(); expect(slottedContent.hasAttribute('hidden')).toBe(true); expect(slottedContent.parentElement.tagName).toContain('SLOT-HIDE-CONTENT-SCOPED'); document.querySelector('button').click(); await browser.pause(); expect(slottedContent.hasAttribute('hidden')).toBe(false); expect(slottedContent.parentElement.classList).toContain('slot-wrapper'); }); }); });注意这里既使用了await expect($('button')).toExist()这类异步匹配(等待按钮渲染就绪),也允许在钩子里直接操作真实 DOM 事件,再通过browser.pause()等待渲染更新后同步断言——两种方式可以按场景混用。
4.4 条件渲染测试:conditional-basic
test/wdio/conditional-basic/cmp.test.tsx 是交互驱动重渲染的极简范式:初始状态下结果区为空文本,点击按钮后变为Content:
describe('conditional-basic', () => { beforeEach(async () => { render({ template: () => <conditional-basic></conditional-basic>, }); }); it('contains a button as a child', async () => { await expect($('button')).toBeExisting(); }); it('button click rerenders', async () => { const button = $('button'); const results = $('div.results'); await expect(results).toHaveText(''); await button.click(); await expect(results).toHaveText('Content'); }); });两个测试共享同一个beforeEach渲染,describe/it结构把"按钮存在性"与"点击后重渲染"拆成两个独立断言场景。
五、异步匹配器:避免竞态条件的正确姿势
5.1 为什么必须用异步匹配
组件渲染是异步的:元素可能尚未挂载、文本可能尚未更新。若用同步断言直接读取 DOM:
// 👎 组件此刻可能尚未渲染,元素不存在或文本不对 expect(document.querySelector('.single').textContent).toBe('single');这一行在"元素还没渲染出来"时就会直接抛错,属于典型的竞态条件(race condition)。
而 WebdriverIO 的异步匹配器会自动重跑断言,直到条件满足或超时失败:
// 👍 让 WebdriverIO 反复抓取并断言组件内容,直到条件满足 await expect($('.single')).toHaveText('single');其语义是:在超时窗口内(默认waitforTimeout: 3000ms,可在 wdio.conf.ts 调整)反复求值$('.single')的文本是否为'single',期间组件完成渲染/重渲染即通过,从而彻底规避时序问题。仓库中几乎每个.test.tsx文件的断言都遵循这一写法。
5.2 常见异步匹配器速查
| 匹配器 | 用途 | 仓库使用示例 |
|---|---|---|
toHaveText(text) | 断言元素文本内容 | await expect($('.single')).toHaveText('single') |
toHaveAttribute(name, value) | 断言属性值 | await expect($('.htmlForLabel')).toHaveAttribute('for', 'a') |
toBeExisting()/toExist() | 断言元素已存在 | await expect($('button')).toBeExisting() |
waitForExist() | 显式等待元素出现 | await $('attribute-basic.hydrated').waitForExist() |
配合 WebdriverIO 官方文档中的组件测试、API 与 Expect Matchers 手册(见原文档 Resources 一节所列主题)可继续深入。
六、从源码印证:这套测试在验证什么
test/wdio中约 100 个测试套件覆盖的正是 Stencil 运行时(src/runtime)与编译器(src/compiler)中最容易出问题的行为面,从套件命名即可映射到对应实现模块:
- 插槽与 scoped 封装(
slot-*、scoped-slot-*、shadow-dom-*系列):验证 src/runtime/slot-polyfill-utils.ts 与 src/utils/shadow-css.ts 中的内容分发与样式作用域逻辑; - 属性与反射(
attribute-*、reflect-*、property-serializer):对应 src/runtime/parse-property-value.ts 的属性序列化与 src/runtime/set-value.ts 的赋值路径; - 生命周期与监听器(
lifecycle-*、listen-*、event-*):对应 src/runtime/initialize-component.ts、src/runtime/host-listener.ts; - 条件/重渲染(
conditional-*、async-rerender、key-reorder):对应 src/runtime/update-component.ts 与 src/runtime/vdom 的虚拟 DOM 协调逻辑; - 样式(
css-variables、dynamic-css-variables、slotted-css、style-plugin):验证 src/runtime/styles.ts 的样式注入与 CSS 变量继承。
这些测试由 wdio.conf.ts 统一驱动,Mocha 框架(timeout: 60000、retries: 1)保证每个用例在真实浏览器中得到充分执行,是 Stencil 发布流程(test.prod包含test.wdio)不可或缺的一环。
七、本地运行与调试清单
按以下顺序即可在本地完整跑通:
# 1. 进入测试目录并安装依赖 cd test/wdio && npm ci # 2. 构建全部测试组件(lazy bundle 及各变体) npm run build # 3. 只跑一个场景(调试期推荐) npm run wdio -- --spec conditional-basic # 4. 调试模式:修改测试文件自动重跑 npm run wdio -- --spec conditional-basic --watch # 5. 从仓库根目录一键运行(自动 npm ci + build + wdio) npm run test.wdio常见注意事项:
- 修改被测组件
cmp.tsx后必须手动重跑npm run build,watch 模式不会监听组件文件; - 新增测试套件时确保组件标签名全局唯一(见 setup.ts 的全量注册机制);
- 断言一律使用
await expect($(...)).toXxx()异步匹配形式,避免同步读 DOM 引入竞态; - 需要多浏览器验证时通过
BROWSER环境变量选择(Chrome 为默认,Firefox 需显式指定,全量ALL组合为 Chrome + Edge)。
结语
以 WebdriverIObrowserrunner 为核心、以"先编译 lazy bundle、再在真实浏览器中渲染断言"为基本盘的这套测试体系,是 Stencil 在 test/wdio 目录下沉淀出的成熟实践:renderAPI 屏蔽了组件注册细节,异步匹配器消除了时序竞态,多浏览器 capability 支持跨引擎回归。无论你是 Stencil 组件库的维护者,还是希望为自有组件系统引入真实浏览器测试,都可以直接复用 test/wdio/wdio.conf.ts 与 test/wdio/setup.ts 的组合,快速搭建起同样可构建、可筛选、可调试、可并行扩展的组件测试流水线。
- 开发工具
- 前端
- 前端构建
【免费下载链接】stencil
A toolchain for building scalable, enterprise-ready component systems on top of TypeScript and Web Component standards. Stencil components can be distributed natively to React, Angular, Vue, (+ more) and traditional web applications from a single, framework-agnostic codebase.
相关推荐
使用 WebdriverIO Browser Runner 在真实浏览器中测试 Stencil 组件
使用 WebdriverIO Browser Runner 在真实浏览器中测试 Stencil 组件 本篇技术指南讲解如何在 WebdriverIO 的浏览器运
测试质量保障WebdriverIO React 组件测试完整指南:基于真实浏览器的组件测试实战
WebdriverIO React 组件测试完整指南:基于真实浏览器的组件测试实战 导读 本文基于 WebdriverIO 的 Browser Runner(浏
测试质量保障WebdriverIO 组件测试指南:用 Browser Runner 在真实浏览器中测试 Vue.js 组件
WebdriverIO 组件测试指南:用 Browser Runner 在真实浏览器中测试 Vue.js 组件 Vue.js 是一个上手简单、性能出色且用途广泛
测试质量保障
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考