umbraco-e2e-testing by umbraco/umbraco-cms-backoffice-skills
npx skills add https://github.com/umbraco/umbraco-cms-backoffice-skills --skill umbraco-e2e-testing使用 Playwright 和 @umbraco/playwright-testhelpers 为 Umbraco 后台扩展进行端到端测试。此方法针对真实运行的 Umbraco 实例进行测试,验证完整的用户工作流程。
对核心 Umbraco 操作使用 @umbraco/playwright-testhelpers:
| 包 | 用途 | 为何必需 |
|---|---|---|
@umbraco/playwright-testhelpers | UI 和 API 助手 | 处理身份验证、导航、核心实体 CRUD |
@umbraco/json-models-builders | 测试数据构建器 | 创建具有正确结构的有效 Umbraco 实体 |
为何对核心 Umbraco 使用 testhelpers?
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
Umbraco 使用 data-mark 而非 data-testid - testhelpers 处理此问题
身份验证令牌管理复杂 - testhelpers 管理 STORAGE_STAGE_PATH
API 设置/拆卸需要特定的负载格式 - 构建器确保正确性
选择器在不同版本间会变化 - testhelpers 将其抽象化
// 错误 - 对核心 Umbraco 使用原始 Playwright(脆弱) await page.goto('/umbraco'); await page.fill('[name="email"]', 'admin@example.com');
// 正确 - 对核心 Umbraco 使用 Testhelpers import { test } from '@umbraco/playwright-testhelpers';
test('my test', async ({ umbracoApi, umbracoUi }) => { await umbracoUi.goToBackOffice(); await umbracoUi.login.enterEmail('admin@example.com'); });
对于自定义扩展,使用 umbracoUi.page(原始 Playwright),因为 testhelpers 不了解您的自定义元素:
test('my custom extension', async ({ umbracoUi }) => {
// 对核心导航使用 Testhelpers
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.settings);
// 对您的自定义元素使用原始 Playwright
await umbracoUi.page.getByRole('link', { name: 'My Custom Item' }).click();
await expect(umbracoUi.page.locator('my-custom-workspace')).toBeVisible();
});
| 对以下使用 Testhelpers | 对以下使用 umbracoUi.page |
|---|---|
| 登录/登出 | 自定义树项 |
| 导航到任何部分(包括自定义) | 自定义工作区元素 |
| 通过 API 创建/编辑文档 | 自定义实体操作 |
| 内置 UI 交互 | 自定义 UI 组件 |
Umbraco-CMS/tests/Umbraco.Tests.AcceptanceTest添加到 package.json:
{
"devDependencies": {
"@playwright/test": "^1.56",
"@umbraco/playwright-testhelpers": "^17.0.15",
"@umbraco/json-models-builders": "^2.0.42",
"dotenv": "^16.3.1"
},
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug"
}
}
然后运行:
npm install
npx playwright install chromium
版本兼容性:将 testhelpers 与您的 Umbraco 版本匹配:
| Umbraco | Testhelpers |
|---|---|
| 17.1.x (预发布) | 17.1.0-beta.x |
| 17.0.x | ^17.0.15 |
| 14.x | ^14.x |
创建 playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export const STORAGE_STATE = join(__dirname, 'tests/e2e/.auth/user.json');
// 关键:Testhelpers 从此文件读取身份验证令牌
process.env.STORAGE_STAGE_PATH = STORAGE_STATE;
export default defineConfig({
testDir: './tests/e2e',
timeout: 30 * 1000,
expect: { timeout: 5000 },
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: process.env.CI ? 'line' : 'html',
use: {
baseURL: process.env.UMBRACO_URL || 'https://localhost:44325',
trace: 'retain-on-failure',
ignoreHTTPSErrors: true,
// 关键:Umbraco 使用 'data-mark' 而非 'data-testid'
testIdAttribute: 'data-mark',
},
projects: [
{
name: 'setup',
testMatch: '**/*.setup.ts',
},
{
name: 'e2e',
testMatch: '**/*.spec.ts',
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
ignoreHTTPSErrors: true,
storageState: STORAGE_STATE,
},
},
],
});
| 设置 | 值 | 为何必需 |
|---|---|---|
testIdAttribute | 'data-mark' | Umbraco 使用 data-mark,而非 data-testid |
STORAGE_STAGE_PATH | 指向 user.json 的路径 | Testhelpers 从此文件读取身份验证令牌 |
ignoreHTTPSErrors | true | 用于具有自签名证书的本地开发 |
没有 testIdAttribute: 'data-mark',所有 getByTestId() 调用都将失败。
创建 tests/e2e/auth.setup.ts:
import { test as setup } from '@playwright/test';
import { STORAGE_STATE } from '../../playwright.config';
import { ConstantHelper, UiHelpers } from '@umbraco/playwright-testhelpers';
setup('authenticate', async ({ page }) => {
const umbracoUi = new UiHelpers(page);
await umbracoUi.goToBackOffice();
await umbracoUi.login.enterEmail(process.env.UMBRACO_USER_LOGIN!);
await umbracoUi.login.enterPassword(process.env.UMBRACO_USER_PASSWORD!);
await umbracoUi.login.clickLoginButton();
await umbracoUi.login.goToSection(ConstantHelper.sections.settings);
await page.context().storageState({ path: STORAGE_STATE });
});
创建 .env(添加到 .gitignore):
UMBRACO_URL=https://localhost:44325
UMBRACO_USER_LOGIN=admin@example.com
UMBRACO_USER_PASSWORD=yourpassword
UMBRACO_DATA_PATH=/path/to/Umbraco.Web.UI/App_Data # 可选:用于数据重置
| 变量 | 必需 | 用途 |
|---|---|---|
UMBRACO_URL | 是 | 后台 URL |
UMBRACO_USER_LOGIN | 是 | 管理员邮箱 |
UMBRACO_USER_PASSWORD | 是 | 管理员密码 |
UMBRACO_DATA_PATH | 否 | 用于测试数据重置的 App_Data 路径(见“使用持久数据测试”) |
my-extension/
├── src/
│ └── ...
├── tests/
│ └── e2e/
│ ├── .auth/
│ │ └── user.json # 身份验证状态(gitignored)
│ ├── auth.setup.ts # 身份验证
│ └── my-extension.spec.ts
├── playwright.config.ts
├── .env # Gitignored
├── .env.example
└── package.json
import { test } from '@umbraco/playwright-testhelpers';
test('my test', async ({ umbracoApi, umbracoUi }) => {
// umbracoApi - 用于设置/拆卸的 API 助手
// umbracoUi - 用于后台交互的 UI 助手
});
test('can create content', async ({ umbracoApi, umbracoUi }) => {
// 编排 - 通过 API 设置
await umbracoApi.documentType.createDefaultDocumentType('TestDocType');
// 执行 - 通过 UI 执行用户操作
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
await umbracoUi.content.clickActionsMenuAtRoot();
// 断言 - 验证结果
expect(await umbracoApi.document.doesNameExist('TestContent')).toBeTruthy();
});
test.afterEach(async ({ umbracoApi }) => {
await umbracoApi.document.ensureNameNotExists(contentName);
await umbracoApi.documentType.ensureNameNotExists(documentTypeName);
});
文档类型:
await umbracoApi.documentType.createDefaultDocumentType('TestDocType');
await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(
'TestDocType', 'Textstring', dataTypeData.id
);
await umbracoApi.documentType.ensureNameNotExists('TestDocType');
文档:
await umbracoApi.document.createDefaultDocument('TestContent', docTypeId);
await umbracoApi.document.createDocumentWithTextContent(
'TestContent', docTypeId, 'value', 'Textstring'
);
await umbracoApi.document.publish(contentId);
await umbracoApi.document.ensureNameNotExists('TestContent');
数据类型:
const dataType = await umbracoApi.dataType.getByName('Textstring');
await umbracoApi.dataType.create('MyType', 'Umbraco.TextBox', 'Umb.PropertyEditorUi.TextBox', []);
对于复杂的测试数据,使用 @umbraco/json-models-builders:
import { DocumentTypeBuilder, DocumentBuilder } from '@umbraco/json-models-builders';
test('create complex document type', async ({ umbracoApi }) => {
// 构建具有多个属性的文档类型
const docType = new DocumentTypeBuilder()
.withName('Article')
.withAlias('article')
.addGroup()
.withName('Content')
.addTextBoxProperty()
.withAlias('title')
.withLabel('Title')
.done()
.addRichTextProperty()
.withAlias('body')
.withLabel('Body')
.done()
.done()
.build();
await umbracoApi.documentType.create(docType);
});
为何使用构建器?
导航:
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
await umbracoUi.content.goToContentWithName('My Page');
测试自定义树扩展时(例如在“设置”中),使用此模式处理异步加载和滚动:
test('should click custom tree item', async ({ umbracoUi }) => {
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.settings);
// 1. 等待您的树标题(自定义树通常在侧边栏底部)
await umbracoUi.page.getByRole('heading', { name: 'My Tree' }).waitFor({ timeout: 15000 });
// 2. 滚动到视图中(重要 - 侧边栏可能很长)
await umbracoUi.page.getByRole('heading', { name: 'My Tree' }).scrollIntoViewIfNeeded();
// 3. 等待树项加载(来自 API 的异步)
const item1Link = umbracoUi.page.getByRole('link', { name: 'Item 1' });
await item1Link.waitFor({ timeout: 15000 });
// 4. 点击该项
await item1Link.click();
// 断言工作区加载
await expect(umbracoUi.page.locator('my-tree-workspace-editor')).toBeVisible({ timeout: 15000 });
});
为何使用此模式?
getByRole('link', { name: '...' }) 比通用的 umb-tree-item 选择器更可靠umb-tree-item,会导致选择器冲突内容操作:
await umbracoUi.content.clickActionsMenuAtRoot();
await umbracoUi.content.clickCreateActionMenuOption();
await umbracoUi.content.chooseDocumentType('TestDocType');
await umbracoUi.content.enterContentName('My Page');
await umbracoUi.content.enterTextstring('My text value');
await umbracoUi.content.clickSaveButton();
常量:
import { ConstantHelper } from '@umbraco/playwright-testhelpers';
ConstantHelper.sections.content
ConstantHelper.sections.settings
ConstantHelper.buttons.save
ConstantHelper.buttons.saveAndPublish
import { expect } from '@playwright/test';
import { ConstantHelper, NotificationConstantHelper, test } from '@umbraco/playwright-testhelpers';
const contentName = 'TestContent';
const documentTypeName = 'TestDocType';
const dataTypeName = 'Textstring';
const contentText = 'Test content text';
test.afterEach(async ({ umbracoApi }) => {
await umbracoApi.document.ensureNameNotExists(contentName);
await umbracoApi.documentType.ensureNameNotExists(documentTypeName);
});
test('can create content', { tag: '@smoke' }, async ({ umbracoApi, umbracoUi }) => {
// 编排
const dataTypeData = await umbracoApi.dataType.getByName(dataTypeName);
await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(
documentTypeName, dataTypeName, dataTypeData.id
);
// 执行
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
await umbracoUi.content.clickActionsMenuAtRoot();
await umbracoUi.content.clickCreateActionMenuOption();
await umbracoUi.content.chooseDocumentType(documentTypeName);
await umbracoUi.content.enterContentName(contentName);
await umbracoUi.content.enterTextstring(contentText);
await umbracoUi.content.clickSaveButton();
// 断言
await umbracoUi.content.waitForContentToBeCreated();
expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy();
const contentData = await umbracoApi.document.getByName(contentName);
expect(contentData.values[0].value).toBe(contentText);
});
test('can publish content', async ({ umbracoApi, umbracoUi }) => {
// 编排
const dataTypeData = await umbracoApi.dataType.getByName(dataTypeName);
const docTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(
documentTypeName, dataTypeName, dataTypeData.id
);
await umbracoApi.document.createDocumentWithTextContent(
contentName, docTypeId, contentText, dataTypeName
);
// 执行
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
await umbracoUi.content.clickActionsMenuForContent(contentName);
await umbracoUi.content.clickPublishActionMenuOption();
await umbracoUi.content.clickConfirmToPublishButton();
// 断言
await umbracoUi.content.doesSuccessNotificationHaveText(
NotificationConstantHelper.success.published
);
const contentData = await umbracoApi.document.getByName(contentName);
expect(contentData.variants[0].state).toBe('Published');
});
tree-example 演示了自定义树扩展的端到端测试:
位置:umbraco-backoffice/examples/tree-example/Client/
# 运行端到端测试(需要运行 Umbraco)
URL=https://localhost:44325 \
UMBRACO_USER_LOGIN=admin@example.com \
UMBRACO_USER_PASSWORD=yourpassword \
npm run test:e2e # 7 个测试
关键文件:
tests/playwright.e2e.config.ts - 包含身份验证设置的端到端配置tests/auth.setup.ts - 使用 testhelpers 进行身份验证tests/tree-e2e.spec.ts - 针对“设置”侧边栏中自定义树的测试notes-wiki 演示了具有持久数据和CRUD 操作的端到端测试:
位置:umbraco-backoffice/examples/notes-wiki/Client/
# 运行端到端测试(带数据重置)
URL=https://localhost:44325 \
UMBRACO_USER_LOGIN=admin@example.com \
UMBRACO_USER_PASSWORD=yourpassword \
UMBRACO_DATA_PATH=/path/to/App_Data \
npm run test:e2e # 16 个测试
关键文件:
tests/playwright.e2e.config.ts - 包含用于数据重置的 globalSetup 的配置tests/global-setup.ts - 在测试前将数据重置为种子状态tests/test-seed-data.json - 已知的测试数据(笔记、文件夹)tests/notes-wiki-e2e.spec.ts - CRUD 和导航测试它演示了什么:
goToSection('notes') 测试自定义部分当您的扩展持久化数据(JSON 文件、数据库等)时,测试需要可预测的起始状态。
添加 globalSetup 以在测试前重置数据:
playwright.e2e.config.ts:
export default defineConfig({
// ... 其他配置
globalSetup: './global-setup.ts',
});
global-setup.ts:
import { FullConfig } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
async function globalSetup(config: FullConfig) {
const dataPath = process.env.UMBRACO_DATA_PATH;
if (!dataPath) {
console.warn('⚠️ UMBRACO_DATA_PATH not set. Skipping data reset.');
return;
}
const targetFile = path.join(dataPath, 'MyExtension/data.json');
const seedFile = path.join(__dirname, 'test-seed-data.json');
// 确保目录存在
fs.mkdirSync(path.dirname(targetFile), { recursive: true });
// 将种子数据复制到目标位置
fs.copyFileSync(seedFile, targetFile);
console.log('🌱 Reset data to seed state');
}
export default globalSetup;
test-seed-data.json:
{
"items": [
{ "id": "test-1", "name": "Test Item 1" },
{ "id": "test-2", "name": "Test Item 2" }
]
}
添加 UMBRACO_DATA_PATH 以定位您的 Umbraco 的 App_Data 文件夹:
UMBRACO_DATA_PATH=/path/to/Umbraco.Web.UI/App_Data npm run test:e2e
自定义部分与 testhelpers 的 goToSection() 方法配合使用 - 传递部分路径名:
// 部分路径名 - 与您在 section/constants.ts 中定义的内容匹配
const MY_SECTION = 'my-section';
// 使用 testhelpers 导航到自定义部分的助手函数
async function goToMySection(umbracoUi: any) {
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(MY_SECTION);
await umbracoUi.page.waitForTimeout(500);
}
test('should navigate to custom section', async ({ umbracoUi }) => {
await goToMySection(umbracoUi);
// 断言 - 您的仪表板或树应该可见
await expect(umbracoUi.page.getByText('Welcome')).toBeVisible({ timeout: 15000 });
});
// 要验证该部分存在于部分栏中:
test('should display my section', async ({ umbracoUi }) => {
await umbracoUi.goToBackOffice();
await expect(umbracoUi.page.getByRole('tab', { name: 'My Section' })).toBeVisible({ timeout: 15000 });
});
测试树项上的实体操作。使用 umbracoUi.page,因为 testhelpers 不覆盖自定义实体操作。
重要提示: Umbraco 中的实体操作在下拉菜单内渲染为按钮,而不是直接作为 menuitem 角色。最可靠的方法是使用“查看操作”按钮,而不是右键单击:
test('should show delete action via actions button', async ({ umbracoUi }) => {
await goToMySection(umbracoUi);
// 等待树项
const itemLink = umbracoUi.page.getByRole('link', { name: 'My Item' });
await itemLink.waitFor({ timeout: 15000 });
// 悬停以显示操作按钮
await itemLink.hover();
// 点击“查看操作”按钮以打开下拉菜单
const actionsButton = umbracoUi.page.getByRole('button', { name: "View actions for 'My Item'" });
await actionsButton.click();
// 等待下拉菜单并检查操作(操作是 BUTTONS,不是 menuitems!)
await umbracoUi.page.waitForTimeout(500);
const deleteButton = umbracoUi.page.getByRole('button', { name: 'Delete' });
const renameButton = umbracoUi.page.getByRole('button', { name: 'Rename' });
// 断言 - 至少应有一个操作可见
await expect(deleteButton.or(renameButton)).toBeVisible({ timeout: 5000 });
});
test('should delete item via actions menu', async ({ umbracoUi }) => {
await goToMySection(umbracoUi);
const itemLink = umbracoUi.page.getByRole('link', { name: 'Item to Delete' });
await itemLink.waitFor({ timeout: 15000 });
// 悬停并打开操作菜单
await itemLink.hover();
await umbracoUi.page.getByRole('button', { name: "View actions for 'Item to Delete'" }).click();
// 点击删除按钮
await umbracoUi.page.getByRole('button', { name: 'Delete' }).click();
// 确认删除(如果出现模态框)
const confirmButton = umbracoUi.page.getByRole('button', { name: /Confirm|Delete/i });
if (await confirmButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await confirmButton.click();
}
// 断言 - 该项应消失
await expect(itemLink).not.toBeVisible({ timeout: 15000 });
});
右键单击也有效,但操作按钮方法更可靠:
// 右键单击方法(不如操作按钮可靠)
await itemLink.click({ button: 'right' });
await umbracoUi.page.waitForTimeout(500);
await umbracoUi.page.getByRole('button', { name: 'Delete' }).click();
对于自定义扩展,使用 umbracoUi.page 进行 UI 交互。对于核心 Umbraco 内容,更倾向于使用 umbracoApi 助手进行设置/拆卸。
test('should create new item', async ({ umbracoUi }) => {
await goToMySection(umbracoUi);
// 悬停在父文件夹上并使用创建按钮
const folderLink = umbracoUi.page.getByRole('link', { name: 'Parent Folder' });
await folderLink.hover();
// 限定到特定菜单以避免与多个项产生歧义
const folderMenu = umbracoUi.page.getByRole('menu').filter({ hasText: 'Parent Folder' });
const createButton = folderMenu.getByRole('button', { name: 'Create Note' });
await createButton.click();
// 断言 - 工作区应打开并带有“新建”指示器
await expect(umbracoUi.page.locator('my-workspace-editor')).toBeVisible({ timeout: 15000 });
});
当存在多个具有相似元素的树项时,限定选择器以避免歧义:
// 错误 - 当多个文件夹都有“创建”按钮时会产生歧义
const createButton = page.getByRole('button', { name: 'Create' });
// 正确 - 限定到特定文件夹的菜单
const folderMenu = page.getByRole('menu').filter({ hasText: 'My Folder' });
const createButton = folderMenu.getByRole('button', { name: 'Create' });
test('should update item', async ({ umbracoUi }) => {
await goToMySection(umbracoUi);
// 导航到项
await umbracoUi.page.getByRole('link', { name: 'Test Item' }).click();
await umbracoUi.page.locator('my-workspace-editor').waitFor({ timeout: 15000 });
// 更新字段
const titleInput = umbracoUi.page.locator('uui-input#title');
await titleInput.clear();
await titleInput.fill('Updated Title');
// 保存
await umbracoUi.page.getByRole('button', { name: /Save/i }).click();
// 等待保存完成
await umbracoUi.page.waitForTimeout(2000);
// 断言 - 标题应反映更改
await expect(umbracoUi.page.getByText('Updated Title')).toBeVisible();
});
# 运行所有端到端测试
npm run test:e2e
# 以 UI 模式运行(可视化调试)
npm run test:e2e:ui
# 运行特定测试文件
npx playwright test tests/e2e/my-extension.spec.ts
# 运行带有特定标签的测试
npx playwright test --grep "@smoke"
# 以调试模式运行
npx playwright test --debug
确保在 playwright.config.ts 中设置了 testIdAttribute: 'data-mark'。
.env 凭据是否正确STORAGE_STAGE_PATH 是否已设置npx playwright install chromium为了在没有真实 Umbraco 后端的情况下进行更快的测试,使用模拟后台方法。
调用:skill: umbraco-mocked-backoffice
| 方面 | 真实后端(本技能) | MSW 模式 |
|---|---|---|
| 设置 | 运行 Umbraco 实例 | 克隆 Umbraco-CMS,npm install |
| 身份验证 | 必需 | 不需要 |
| 速度 | 较慢 | 更快 |
| 用例 | 集成/验收 | UI/组件测试 |
每周安装次数
69
仓库
GitHub 星标数
17
首次出现
2026年2月4日
安全审计
安装于
github-copilot51
opencode20
codex20
cursor19
gemini-cli18
amp18
End-to-end testing for Umbraco backoffice extensions using Playwright and @umbraco/playwright-testhelpers. This approach tests against a real running Umbraco instance, validating complete user workflows.
Use @umbraco/playwright-testhelpers for core Umbraco operations :
| Package | Purpose | Why Required |
|---|---|---|
@umbraco/playwright-testhelpers | UI and API helpers | Handles auth, navigation, core entity CRUD |
@umbraco/json-models-builders | Test data builders | Creates valid Umbraco entities with correct structure |
Why use testhelpers for core Umbraco?
Umbraco uses data-mark instead of data-testid - testhelpers handle this
Auth token management is complex - testhelpers manage STORAGE_STAGE_PATH
API setup/teardown requires specific payload formats - builders ensure correctness
Selectors change between versions - testhelpers abstract these away
// WRONG - Raw Playwright for core Umbraco (brittle) await page.goto('/umbraco'); await page.fill('[name="email"]', 'admin@example.com');
// CORRECT - Testhelpers for core Umbraco import { test } from '@umbraco/playwright-testhelpers';
test('my test', async ({ umbracoApi, umbracoUi }) => { await umbracoUi.goToBackOffice(); await umbracoUi.login.enterEmail('admin@example.com'); });
For custom extensions , use umbracoUi.page (raw Playwright) because testhelpers don't know about your custom elements:
test('my custom extension', async ({ umbracoUi }) => {
// Testhelpers for core navigation
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.settings);
// Raw Playwright for YOUR custom elements
await umbracoUi.page.getByRole('link', { name: 'My Custom Item' }).click();
await expect(umbracoUi.page.locator('my-custom-workspace')).toBeVisible();
});
| Use Testhelpers For | Use umbracoUi.page For |
|---|---|
| Login/logout | Custom tree items |
| Navigate to ANY section (including custom) | Custom workspace elements |
| Create/edit documents via API | Custom entity actions |
| Built-in UI interactions | Custom UI components |
Umbraco-CMS/tests/Umbraco.Tests.AcceptanceTestAdd to package.json:
{
"devDependencies": {
"@playwright/test": "^1.56",
"@umbraco/playwright-testhelpers": "^17.0.15",
"@umbraco/json-models-builders": "^2.0.42",
"dotenv": "^16.3.1"
},
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug"
}
}
Then run:
npm install
npx playwright install chromium
Version Compatibility : Match testhelpers to your Umbraco version:
| Umbraco | Testhelpers |
|---|---|
| 17.1.x (pre-release) | 17.1.0-beta.x |
| 17.0.x | ^17.0.15 |
| 14.x | ^14.x |
Create playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export const STORAGE_STATE = join(__dirname, 'tests/e2e/.auth/user.json');
// CRITICAL: Testhelpers read auth tokens from this file
process.env.STORAGE_STAGE_PATH = STORAGE_STATE;
export default defineConfig({
testDir: './tests/e2e',
timeout: 30 * 1000,
expect: { timeout: 5000 },
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: process.env.CI ? 'line' : 'html',
use: {
baseURL: process.env.UMBRACO_URL || 'https://localhost:44325',
trace: 'retain-on-failure',
ignoreHTTPSErrors: true,
// CRITICAL: Umbraco uses 'data-mark' not 'data-testid'
testIdAttribute: 'data-mark',
},
projects: [
{
name: 'setup',
testMatch: '**/*.setup.ts',
},
{
name: 'e2e',
testMatch: '**/*.spec.ts',
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
ignoreHTTPSErrors: true,
storageState: STORAGE_STATE,
},
},
],
});
| Setting | Value | Why Required |
|---|---|---|
testIdAttribute | 'data-mark' | Umbraco uses data-mark, not data-testid |
STORAGE_STAGE_PATH | Path to user.json | Testhelpers read auth tokens from this file |
ignoreHTTPSErrors | true |
WithouttestIdAttribute: 'data-mark', all getByTestId() calls will fail.
Create tests/e2e/auth.setup.ts:
import { test as setup } from '@playwright/test';
import { STORAGE_STATE } from '../../playwright.config';
import { ConstantHelper, UiHelpers } from '@umbraco/playwright-testhelpers';
setup('authenticate', async ({ page }) => {
const umbracoUi = new UiHelpers(page);
await umbracoUi.goToBackOffice();
await umbracoUi.login.enterEmail(process.env.UMBRACO_USER_LOGIN!);
await umbracoUi.login.enterPassword(process.env.UMBRACO_USER_PASSWORD!);
await umbracoUi.login.clickLoginButton();
await umbracoUi.login.goToSection(ConstantHelper.sections.settings);
await page.context().storageState({ path: STORAGE_STATE });
});
Create .env (add to .gitignore):
UMBRACO_URL=https://localhost:44325
UMBRACO_USER_LOGIN=admin@example.com
UMBRACO_USER_PASSWORD=yourpassword
UMBRACO_DATA_PATH=/path/to/Umbraco.Web.UI/App_Data # Optional: for data reset
| Variable | Required | Purpose |
|---|---|---|
UMBRACO_URL | Yes | Backoffice URL |
UMBRACO_USER_LOGIN | Yes | Admin email |
UMBRACO_USER_PASSWORD | Yes | Admin password |
UMBRACO_DATA_PATH | No | App_Data path for test data reset (see "Testing with Persistent Data") |
my-extension/
├── src/
│ └── ...
├── tests/
│ └── e2e/
│ ├── .auth/
│ │ └── user.json # Auth state (gitignored)
│ ├── auth.setup.ts # Authentication
│ └── my-extension.spec.ts
├── playwright.config.ts
├── .env # Gitignored
├── .env.example
└── package.json
import { test } from '@umbraco/playwright-testhelpers';
test('my test', async ({ umbracoApi, umbracoUi }) => {
// umbracoApi - API helpers for setup/teardown
// umbracoUi - UI helpers for backoffice interaction
});
test('can create content', async ({ umbracoApi, umbracoUi }) => {
// Arrange - Setup via API
await umbracoApi.documentType.createDefaultDocumentType('TestDocType');
// Act - Perform user actions via UI
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
await umbracoUi.content.clickActionsMenuAtRoot();
// Assert - Verify results
expect(await umbracoApi.document.doesNameExist('TestContent')).toBeTruthy();
});
test.afterEach(async ({ umbracoApi }) => {
await umbracoApi.document.ensureNameNotExists(contentName);
await umbracoApi.documentType.ensureNameNotExists(documentTypeName);
});
Document Types:
await umbracoApi.documentType.createDefaultDocumentType('TestDocType');
await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(
'TestDocType', 'Textstring', dataTypeData.id
);
await umbracoApi.documentType.ensureNameNotExists('TestDocType');
Documents:
await umbracoApi.document.createDefaultDocument('TestContent', docTypeId);
await umbracoApi.document.createDocumentWithTextContent(
'TestContent', docTypeId, 'value', 'Textstring'
);
await umbracoApi.document.publish(contentId);
await umbracoApi.document.ensureNameNotExists('TestContent');
Data Types:
const dataType = await umbracoApi.dataType.getByName('Textstring');
await umbracoApi.dataType.create('MyType', 'Umbraco.TextBox', 'Umb.PropertyEditorUi.TextBox', []);
For complex test data, use @umbraco/json-models-builders:
import { DocumentTypeBuilder, DocumentBuilder } from '@umbraco/json-models-builders';
test('create complex document type', async ({ umbracoApi }) => {
// Build a document type with multiple properties
const docType = new DocumentTypeBuilder()
.withName('Article')
.withAlias('article')
.addGroup()
.withName('Content')
.addTextBoxProperty()
.withAlias('title')
.withLabel('Title')
.done()
.addRichTextProperty()
.withAlias('body')
.withLabel('Body')
.done()
.done()
.build();
await umbracoApi.documentType.create(docType);
});
Why use builders?
Navigation:
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
await umbracoUi.content.goToContentWithName('My Page');
When testing custom tree extensions (e.g., in Settings), use this pattern to handle async loading and scrolling:
test('should click custom tree item', async ({ umbracoUi }) => {
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.settings);
// 1. Wait for your tree heading (custom trees often at bottom of sidebar)
await umbracoUi.page.getByRole('heading', { name: 'My Tree' }).waitFor({ timeout: 15000 });
// 2. Scroll into view (important - sidebar may be long)
await umbracoUi.page.getByRole('heading', { name: 'My Tree' }).scrollIntoViewIfNeeded();
// 3. Wait for tree items to load (async from API)
const item1Link = umbracoUi.page.getByRole('link', { name: 'Item 1' });
await item1Link.waitFor({ timeout: 15000 });
// 4. Click the item
await item1Link.click();
// Assert workspace loads
await expect(umbracoUi.page.locator('my-tree-workspace-editor')).toBeVisible({ timeout: 15000 });
});
Why this pattern?
getByRole('link', { name: '...' }) is more reliable than generic umb-tree-item selectorsumb-tree-item, causing selector conflictsContent Actions:
await umbracoUi.content.clickActionsMenuAtRoot();
await umbracoUi.content.clickCreateActionMenuOption();
await umbracoUi.content.chooseDocumentType('TestDocType');
await umbracoUi.content.enterContentName('My Page');
await umbracoUi.content.enterTextstring('My text value');
await umbracoUi.content.clickSaveButton();
Constants:
import { ConstantHelper } from '@umbraco/playwright-testhelpers';
ConstantHelper.sections.content
ConstantHelper.sections.settings
ConstantHelper.buttons.save
ConstantHelper.buttons.saveAndPublish
import { expect } from '@playwright/test';
import { ConstantHelper, NotificationConstantHelper, test } from '@umbraco/playwright-testhelpers';
const contentName = 'TestContent';
const documentTypeName = 'TestDocType';
const dataTypeName = 'Textstring';
const contentText = 'Test content text';
test.afterEach(async ({ umbracoApi }) => {
await umbracoApi.document.ensureNameNotExists(contentName);
await umbracoApi.documentType.ensureNameNotExists(documentTypeName);
});
test('can create content', { tag: '@smoke' }, async ({ umbracoApi, umbracoUi }) => {
// Arrange
const dataTypeData = await umbracoApi.dataType.getByName(dataTypeName);
await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(
documentTypeName, dataTypeName, dataTypeData.id
);
// Act
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
await umbracoUi.content.clickActionsMenuAtRoot();
await umbracoUi.content.clickCreateActionMenuOption();
await umbracoUi.content.chooseDocumentType(documentTypeName);
await umbracoUi.content.enterContentName(contentName);
await umbracoUi.content.enterTextstring(contentText);
await umbracoUi.content.clickSaveButton();
// Assert
await umbracoUi.content.waitForContentToBeCreated();
expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy();
const contentData = await umbracoApi.document.getByName(contentName);
expect(contentData.values[0].value).toBe(contentText);
});
test('can publish content', async ({ umbracoApi, umbracoUi }) => {
// Arrange
const dataTypeData = await umbracoApi.dataType.getByName(dataTypeName);
const docTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(
documentTypeName, dataTypeName, dataTypeData.id
);
await umbracoApi.document.createDocumentWithTextContent(
contentName, docTypeId, contentText, dataTypeName
);
// Act
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
await umbracoUi.content.clickActionsMenuForContent(contentName);
await umbracoUi.content.clickPublishActionMenuOption();
await umbracoUi.content.clickConfirmToPublishButton();
// Assert
await umbracoUi.content.doesSuccessNotificationHaveText(
NotificationConstantHelper.success.published
);
const contentData = await umbracoApi.document.getByName(contentName);
expect(contentData.variants[0].state).toBe('Published');
});
The tree-example demonstrates E2E testing for a custom tree extension:
Location : umbraco-backoffice/examples/tree-example/Client/
# Run E2E tests (requires running Umbraco)
URL=https://localhost:44325 \
UMBRACO_USER_LOGIN=admin@example.com \
UMBRACO_USER_PASSWORD=yourpassword \
npm run test:e2e # 7 tests
Key files:
tests/playwright.e2e.config.ts - E2E configuration with auth setuptests/auth.setup.ts - Authentication using testhelperstests/tree-e2e.spec.ts - Tests for custom tree in Settings sidebarThe notes-wiki demonstrates E2E testing with persistent data and CRUD operations :
Location : umbraco-backoffice/examples/notes-wiki/Client/
# Run E2E tests (with data reset)
URL=https://localhost:44325 \
UMBRACO_USER_LOGIN=admin@example.com \
UMBRACO_USER_PASSWORD=yourpassword \
UMBRACO_DATA_PATH=/path/to/App_Data \
npm run test:e2e # 16 tests
Key files:
tests/playwright.e2e.config.ts - Config with globalSetup for data resettests/global-setup.ts - Resets data to seed state before teststests/test-seed-data.json - Known test data (notes, folders)tests/notes-wiki-e2e.spec.ts - CRUD and navigation testsWhat it demonstrates:
goToSection('notes')When your extension persists data (JSON files, database, etc.), tests need predictable starting state.
Add globalSetup to reset data before tests:
playwright.e2e.config.ts:
export default defineConfig({
// ... other config
globalSetup: './global-setup.ts',
});
global-setup.ts:
import { FullConfig } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
async function globalSetup(config: FullConfig) {
const dataPath = process.env.UMBRACO_DATA_PATH;
if (!dataPath) {
console.warn('⚠️ UMBRACO_DATA_PATH not set. Skipping data reset.');
return;
}
const targetFile = path.join(dataPath, 'MyExtension/data.json');
const seedFile = path.join(__dirname, 'test-seed-data.json');
// Ensure directory exists
fs.mkdirSync(path.dirname(targetFile), { recursive: true });
// Copy seed data to target
fs.copyFileSync(seedFile, targetFile);
console.log('🌱 Reset data to seed state');
}
export default globalSetup;
test-seed-data.json:
{
"items": [
{ "id": "test-1", "name": "Test Item 1" },
{ "id": "test-2", "name": "Test Item 2" }
]
}
Add UMBRACO_DATA_PATH to locate your Umbraco's App_Data folder:
UMBRACO_DATA_PATH=/path/to/Umbraco.Web.UI/App_Data npm run test:e2e
Custom sections work with testhelpers' goToSection() method - pass the section pathname:
// Section pathname - matches what you defined in section/constants.ts
const MY_SECTION = 'my-section';
// Helper to navigate to custom section using testhelpers
async function goToMySection(umbracoUi: any) {
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(MY_SECTION);
await umbracoUi.page.waitForTimeout(500);
}
test('should navigate to custom section', async ({ umbracoUi }) => {
await goToMySection(umbracoUi);
// Assert - your dashboard or tree should be visible
await expect(umbracoUi.page.getByText('Welcome')).toBeVisible({ timeout: 15000 });
});
// To verify the section exists in the section bar:
test('should display my section', async ({ umbracoUi }) => {
await umbracoUi.goToBackOffice();
await expect(umbracoUi.page.getByRole('tab', { name: 'My Section' })).toBeVisible({ timeout: 15000 });
});
Testing entity actions on tree items. Uses umbracoUi.page since testhelpers don't cover custom entity actions.
Important: Entity actions in Umbraco are rendered as buttons inside the dropdown menu, not as menuitem roles directly. The most reliable approach is to use the "View actions" button rather than right-click:
test('should show delete action via actions button', async ({ umbracoUi }) => {
await goToMySection(umbracoUi);
// Wait for tree item
const itemLink = umbracoUi.page.getByRole('link', { name: 'My Item' });
await itemLink.waitFor({ timeout: 15000 });
// Hover to reveal action buttons
await itemLink.hover();
// Click the "View actions" button to open dropdown
const actionsButton = umbracoUi.page.getByRole('button', { name: "View actions for 'My Item'" });
await actionsButton.click();
// Wait for dropdown and check for actions (actions are BUTTONS, not menuitems!)
await umbracoUi.page.waitForTimeout(500);
const deleteButton = umbracoUi.page.getByRole('button', { name: 'Delete' });
const renameButton = umbracoUi.page.getByRole('button', { name: 'Rename' });
// Assert - at least one action should be visible
await expect(deleteButton.or(renameButton)).toBeVisible({ timeout: 5000 });
});
test('should delete item via actions menu', async ({ umbracoUi }) => {
await goToMySection(umbracoUi);
const itemLink = umbracoUi.page.getByRole('link', { name: 'Item to Delete' });
await itemLink.waitFor({ timeout: 15000 });
// Hover and open actions menu
await itemLink.hover();
await umbracoUi.page.getByRole('button', { name: "View actions for 'Item to Delete'" }).click();
// Click delete button
await umbracoUi.page.getByRole('button', { name: 'Delete' }).click();
// Confirm deletion (if modal appears)
const confirmButton = umbracoUi.page.getByRole('button', { name: /Confirm|Delete/i });
if (await confirmButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await confirmButton.click();
}
// Assert - item should be gone
await expect(itemLink).not.toBeVisible({ timeout: 15000 });
});
Right-click also works but the actions button approach is more reliable:
// Right-click approach (less reliable than actions button)
await itemLink.click({ button: 'right' });
await umbracoUi.page.waitForTimeout(500);
await umbracoUi.page.getByRole('button', { name: 'Delete' }).click();
For custom extensions, use umbracoUi.page for UI interactions. For core Umbraco content, prefer umbracoApi helpers for setup/teardown.
test('should create new item', async ({ umbracoUi }) => {
await goToMySection(umbracoUi);
// Hover over parent folder and use Create button
const folderLink = umbracoUi.page.getByRole('link', { name: 'Parent Folder' });
await folderLink.hover();
// Scope to specific menu to avoid ambiguity with multiple items
const folderMenu = umbracoUi.page.getByRole('menu').filter({ hasText: 'Parent Folder' });
const createButton = folderMenu.getByRole('button', { name: 'Create Note' });
await createButton.click();
// Assert - workspace should open with "New" indicator
await expect(umbracoUi.page.locator('my-workspace-editor')).toBeVisible({ timeout: 15000 });
});
When multiple tree items exist with similar elements, scope selectors to avoid ambiguity:
// WRONG - ambiguous when multiple folders have "Create" buttons
const createButton = page.getByRole('button', { name: 'Create' });
// CORRECT - scoped to specific folder's menu
const folderMenu = page.getByRole('menu').filter({ hasText: 'My Folder' });
const createButton = folderMenu.getByRole('button', { name: 'Create' });
test('should update item', async ({ umbracoUi }) => {
await goToMySection(umbracoUi);
// Navigate to item
await umbracoUi.page.getByRole('link', { name: 'Test Item' }).click();
await umbracoUi.page.locator('my-workspace-editor').waitFor({ timeout: 15000 });
// Update field
const titleInput = umbracoUi.page.locator('uui-input#title');
await titleInput.clear();
await titleInput.fill('Updated Title');
// Save
await umbracoUi.page.getByRole('button', { name: /Save/i }).click();
// Wait for save to complete
await umbracoUi.page.waitForTimeout(2000);
// Assert - header should reflect change
await expect(umbracoUi.page.getByText('Updated Title')).toBeVisible();
});
# Run all E2E tests
npm run test:e2e
# Run with UI mode (visual debugging)
npm run test:e2e:ui
# Run specific test file
npx playwright test tests/e2e/my-extension.spec.ts
# Run with specific tag
npx playwright test --grep "@smoke"
# Run in debug mode
npx playwright test --debug
Ensure testIdAttribute: 'data-mark' is set in playwright.config.ts.
.env credentials are correctSTORAGE_STAGE_PATH is setnpx playwright install chromiumFor faster testing without a real Umbraco backend, use the mocked backoffice approach.
Invoke : skill: umbraco-mocked-backoffice
| Aspect | Real Backend (this skill) | MSW Mode |
|---|---|---|
| Setup | Running Umbraco instance | Clone Umbraco-CMS, npm install |
| Auth | Required | Not required |
| Speed | Slower | Faster |
| Use case | Integration/acceptance | UI/component testing |
Weekly Installs
69
Repository
GitHub Stars
17
First Seen
Feb 4, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
github-copilot51
opencode20
codex20
cursor19
gemini-cli18
amp18
Skills CLI 使用指南:AI Agent 技能包管理器安装与管理教程
46,600 周安装
前端专家技能:HTMX、React、Next.js、Tailwind CSS、Chakra UI 全栈开发指南
69 周安装
PUA Pro - 自进化AI助手平台,支持KPI报告、述职答辩、代码美化及PUA排行榜
511 周安装
Nx工作区模式最佳实践指南:架构设计、项目配置与CI优化
120 周安装
UI动画设计原则指南:缓动、时长、编排与最佳实践 | 动效设计专家
177 周安装
Figma 设计转代码技能:实现像素级精准开发与设计系统集成
1,500 周安装
无服务器与微服务开发指南:FastAPI、AWS Lambda、Azure Functions 架构实践
109 周安装
| For local dev with self-signed certs |