umbraco-e2e-testing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseUmbraco E2E Testing
Umbraco 端到端(E2E)测试
End-to-end testing for Umbraco backoffice extensions using Playwright and . This approach tests against a real running Umbraco instance, validating complete user workflows.
@umbraco/playwright-testhelpers使用Playwright和对Umbraco后台扩展进行端到端(E2E)测试。该方法针对真实运行的Umbraco实例进行测试,验证完整的用户工作流。
@umbraco/playwright-testhelpersCritical: Use Testhelpers for Core Umbraco
重要提示:针对Umbraco核心功能使用测试助手
Use for core Umbraco operations:
@umbraco/playwright-testhelpers| Package | Purpose | Why Required |
|---|---|---|
| UI and API helpers | Handles auth, navigation, core entity CRUD |
| Test data builders | Creates valid Umbraco entities with correct structure |
Why use testhelpers for core Umbraco?
- Umbraco uses instead of
data-mark- testhelpers handle thisdata-testid - 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
typescript
// 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');
});针对Umbraco核心操作使用:
@umbraco/playwright-testhelpers| 包 | 用途 | 必要性说明 |
|---|---|---|
| UI和API助手 | 处理认证、导航、核心实体的增删改查(CRUD) |
| 测试数据构建器 | 创建结构正确的有效Umbraco实体 |
为什么针对Umbraco核心功能使用测试助手?
- Umbraco使用而非
data-mark- 测试助手可处理该差异data-testid - 认证令牌管理复杂 - 测试助手负责管理
STORAGE_STAGE_PATH - API的创建/销毁需要特定的负载格式 - 构建器可确保格式正确
- 选择器会随版本变化 - 测试助手可抽象掉这些细节
typescript
// 错误示例 - 直接使用Playwright处理Umbraco核心功能(易出错)
await page.goto('/umbraco');
await page.fill('[name="email"]', 'admin@example.com');
// 正确示例 - 使用测试助手处理Umbraco核心功能
import { test } from '@umbraco/playwright-testhelpers';
test('my test', async ({ umbracoApi, umbracoUi }) => {
await umbracoUi.goToBackOffice();
await umbracoUi.login.enterEmail('admin@example.com');
});When to Use Raw Playwright
何时直接使用Playwright
For custom extensions, use (raw Playwright) because testhelpers don't know about your custom elements:
umbracoUi.pagetypescript
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 |
|---|---|
| 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 |
针对自定义扩展,使用(原生Playwright),因为测试助手无法识别你的自定义元素:
umbracoUi.pagetypescript
test('my custom extension', async ({ umbracoUi }) => {
// 使用测试助手处理核心导航
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();
});| 使用测试助手的场景 | 使用 |
|---|---|
| 登录/登出 | 自定义树节点 |
| 导航至任意板块(包括自定义板块) | 自定义工作区元素 |
| 通过API创建/编辑文档 | 自定义实体操作 |
| 内置UI交互 | 自定义UI组件 |
When to Use
适用场景
- Testing complete user workflows
- Testing data persistence
- Testing authentication/authorization
- Acceptance testing before release
- Integration testing with real API responses
- 测试完整的用户工作流
- 测试数据持久化
- 测试认证/授权
- 发布前的验收测试
- 结合真实API响应的集成测试
Related Skills
相关技能
- umbraco-testing - Master skill for testing overview
- umbraco-playwright-testhelpers - Full reference for the testhelpers package
- umbraco-test-builders - JsonModels.Builders for test data
- umbraco-mocked-backoffice - Test without real backend (faster)
- umbraco-testing - 测试概述的核心技能
- umbraco-playwright-testhelpers - 测试助手包的完整参考
- umbraco-test-builders - 用于测试数据的JsonModels.Builders
- umbraco-mocked-backoffice - 无需真实后端的测试(速度更快)
Documentation
文档资源
- Playwright: https://playwright.dev/docs/intro
- Reference tests:
Umbraco-CMS/tests/Umbraco.Tests.AcceptanceTest
- Playwright: https://playwright.dev/docs/intro
- 参考测试:
Umbraco-CMS/tests/Umbraco.Tests.AcceptanceTest
Setup
安装配置
Dependencies
依赖项
Add to :
package.jsonjson
{
"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:
bash
npm install
npx playwright install chromiumVersion Compatibility: Match testhelpers to your Umbraco version:
| Umbraco | Testhelpers |
|---|---|
| 17.1.x (pre-release) | |
| 17.0.x | |
| 14.x | |
添加至:
package.jsonjson
{
"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"
}
}然后运行:
bash
npm install
npx playwright install chromium版本兼容性:确保测试助手版本与Umbraco版本匹配:
| Umbraco版本 | 测试助手版本 |
|---|---|
| 17.1.x(预发布版) | |
| 17.0.x | |
| 14.x | |
Configuration
配置文件
Create :
playwright.config.tstypescript
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,
},
},
],
});创建:
playwright.config.tstypescript
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');
// 重要:测试助手会从此文件读取认证令牌
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,
},
},
],
});Critical Settings
关键设置
| Setting | Value | Why Required |
|---|---|---|
| | Umbraco uses |
| Path to user.json | Testhelpers read auth tokens from this file |
| | For local dev with self-signed certs |
Without , all calls will fail.
testIdAttribute: 'data-mark'getByTestId()| 设置项 | 值 | 必要性说明 |
|---|---|---|
| | Umbraco使用 |
| user.json的路径 | 测试助手从此文件读取认证令牌 |
| | 适配本地开发环境的自签名证书 |
如果未设置,所有调用都会失败。
testIdAttribute: 'data-mark'getByTestId()Authentication Setup
认证配置
Create :
tests/e2e/auth.setup.tstypescript
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 });
});创建:
tests/e2e/auth.setup.tstypescript
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 });
});Environment Variables
环境变量
Create (add to ):
.env.gitignorebash
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 |
|---|---|---|
| Yes | Backoffice URL |
| Yes | Admin email |
| Yes | Admin password |
| No | App_Data path for test data reset (see "Testing with Persistent Data") |
创建文件(需添加至):
.env.gitignorebash
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的App_Data文件夹路径,用于测试数据重置(详见"持久化数据测试") |
Directory Structure
目录结构
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.jsonmy-extension/
├── src/
│ └── ...
├── tests/
│ └── e2e/
│ ├── .auth/
│ │ └── user.json # 认证状态(已加入git忽略)
│ ├── auth.setup.ts # 认证配置
│ └── my-extension.spec.ts
├── playwright.config.ts
├── .env # 已加入git忽略
├── .env.example
└── package.jsonPatterns
测试模式
Test Fixtures
测试夹具
typescript
import { test } from '@umbraco/playwright-testhelpers';
test('my test', async ({ umbracoApi, umbracoUi }) => {
// umbracoApi - API helpers for setup/teardown
// umbracoUi - UI helpers for backoffice interaction
});typescript
import { test } from '@umbraco/playwright-testhelpers';
test('my test', async ({ umbracoApi, umbracoUi }) => {
// umbracoApi - 用于测试创建/销毁的API助手
// umbracoUi - 用于后台交互的UI助手
});AAA Pattern (Arrange-Act-Assert)
AAA模式(Arrange-Act-Assert)
typescript
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();
});typescript
test('can create content', async ({ umbracoApi, umbracoUi }) => {
// Arrange(准备)- 通过API设置测试环境
await umbracoApi.documentType.createDefaultDocumentType('TestDocType');
// Act(执行)- 通过UI执行用户操作
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
await umbracoUi.content.clickActionsMenuAtRoot();
// Assert(断言)- 验证结果
expect(await umbracoApi.document.doesNameExist('TestContent')).toBeTruthy();
});Idempotent Cleanup
幂等清理
typescript
test.afterEach(async ({ umbracoApi }) => {
await umbracoApi.document.ensureNameNotExists(contentName);
await umbracoApi.documentType.ensureNameNotExists(documentTypeName);
});typescript
test.afterEach(async ({ umbracoApi }) => {
await umbracoApi.document.ensureNameNotExists(contentName);
await umbracoApi.documentType.ensureNameNotExists(documentTypeName);
});API Helpers (umbracoApi)
API助手(umbracoApi)
Document Types:
typescript
await umbracoApi.documentType.createDefaultDocumentType('TestDocType');
await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(
'TestDocType', 'Textstring', dataTypeData.id
);
await umbracoApi.documentType.ensureNameNotExists('TestDocType');Documents:
typescript
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:
typescript
const dataType = await umbracoApi.dataType.getByName('Textstring');
await umbracoApi.dataType.create('MyType', 'Umbraco.TextBox', 'Umb.PropertyEditorUi.TextBox', []);文档类型操作:
typescript
await umbracoApi.documentType.createDefaultDocumentType('TestDocType');
await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(
'TestDocType', 'Textstring', dataTypeData.id
);
await umbracoApi.documentType.ensureNameNotExists('TestDocType');文档操作:
typescript
await umbracoApi.document.createDefaultDocument('TestContent', docTypeId);
await umbracoApi.document.createDocumentWithTextContent(
'TestContent', docTypeId, 'value', 'Textstring'
);
await umbracoApi.document.publish(contentId);
await umbracoApi.document.ensureNameNotExists('TestContent');数据类型操作:
typescript
const dataType = await umbracoApi.dataType.getByName('Textstring');
await umbracoApi.dataType.create('MyType', 'Umbraco.TextBox', 'Umb.PropertyEditorUi.TextBox', []);Using Builders for Complex Data
使用构建器处理复杂数据
For complex test data, use :
@umbraco/json-models-builderstypescript
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?
- Fluent API makes complex structures readable
- Ensures valid payload structure for Umbraco API
- Handles required fields and defaults
- Type-safe in TypeScript
针对复杂测试数据,使用:
@umbraco/json-models-builderstypescript
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);
});为什么使用构建器?
- 流畅API使复杂结构更易读
- 确保Umbraco API接收的负载格式有效
- 处理必填字段和默认值
- 在TypeScript中具备类型安全性
UI Helpers (umbracoUi)
UI助手(umbracoUi)
Navigation:
typescript
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
await umbracoUi.content.goToContentWithName('My Page');导航操作:
typescript
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
await umbracoUi.content.goToContentWithName('My Page');Testing Custom Trees in Sidebar
测试侧边栏中的自定义树
When testing custom tree extensions (e.g., in Settings), use this pattern to handle async loading and scrolling:
typescript
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?
- Custom trees are often at the bottom of the Settings sidebar
- Tree items load asynchronously from your API
- Using is more reliable than generic
getByRole('link', { name: '...' })selectorsumb-tree-item - Built-in trees (Document Types, etc.) also use , causing selector conflicts
umb-tree-item
Content Actions:
typescript
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:
typescript
import { ConstantHelper } from '@umbraco/playwright-testhelpers';
ConstantHelper.sections.content
ConstantHelper.sections.settings
ConstantHelper.buttons.save
ConstantHelper.buttons.saveAndPublish测试自定义树扩展(例如在设置板块中)时,使用以下模式处理异步加载和滚动:
typescript
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 });
});为什么使用该模式?
- 自定义树通常位于设置侧边栏的底部
- 树节点从API异步加载
- 使用比通用的
getByRole('link', { name: '...' })选择器更可靠umb-tree-item - 内置树(如文档类型等)也使用,会导致选择器冲突
umb-tree-item
内容操作:
typescript
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();常量工具:
typescript
import { ConstantHelper } from '@umbraco/playwright-testhelpers';
ConstantHelper.sections.content
ConstantHelper.sections.settings
ConstantHelper.buttons.save
ConstantHelper.buttons.saveAndPublishExamples
测试示例
Complete Test
完整测试用例
typescript
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');
});typescript
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');
});Working Example: tree-example
实际示例:tree-example
The demonstrates E2E testing for a custom tree extension:
tree-exampleLocation:
umbraco-backoffice/examples/tree-example/Client/bash
undefinedtree-example位置:
umbraco-backoffice/examples/tree-example/Client/bash
undefinedRun E2E tests (requires running Umbraco)
运行E2E测试(需先启动Umbraco)
URL=https://localhost:44325
UMBRACO_USER_LOGIN=admin@example.com
UMBRACO_USER_PASSWORD=yourpassword
npm run test:e2e # 7 tests
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 setup
- `tests/auth.setup.ts` - Authentication using testhelpers
- `tests/tree-e2e.spec.ts` - Tests for custom tree in Settings sidebarURL=https://localhost:44325
UMBRACO_USER_LOGIN=admin@example.com
UMBRACO_USER_PASSWORD=yourpassword
npm run test:e2e # 共7个测试用例
UMBRACO_USER_LOGIN=admin@example.com
UMBRACO_USER_PASSWORD=yourpassword
npm run test:e2e # 共7个测试用例
关键文件:
- `tests/playwright.e2e.config.ts` - 包含认证配置的E2E配置文件
- `tests/auth.setup.ts` - 使用测试助手完成认证
- `tests/tree-e2e.spec.ts` - 针对设置侧边栏中自定义树的测试Working Example: notes-wiki (Full-Stack with Data Reset)
实际示例:notes-wiki(带数据重置的全栈测试)
The demonstrates E2E testing with persistent data and CRUD operations:
notes-wikiLocation:
umbraco-backoffice/examples/notes-wiki/Client/bash
undefinednotes-wiki位置:
umbraco-backoffice/examples/notes-wiki/Client/bash
undefinedRun E2E tests (with data reset)
运行E2E测试(带数据重置)
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
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 reset
- `tests/global-setup.ts` - Resets data to seed state before tests
- `tests/test-seed-data.json` - Known test data (notes, folders)
- `tests/notes-wiki-e2e.spec.ts` - CRUD and navigation tests
**What it demonstrates:**
- Testing a custom section using `goToSection('notes')`
- Resetting file-based data before each test run
- Testing tree navigation, folders, and workspaces
- Entity actions via "View actions" button (more reliable than right-click)
- Dashboard and workspace view testing
---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个测试用例
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')`导航至自定义板块
- 每次测试前重置基于文件的数据
- 测试树导航、文件夹和工作区
- 通过"查看操作"按钮执行实体操作(比右键点击更可靠)
- 测试仪表盘和工作区视图
---Testing Extensions with Persistent Data
带持久化数据的扩展测试
When your extension persists data (JSON files, database, etc.), tests need predictable starting state.
当你的扩展需要持久化数据(JSON文件、数据库等)时,测试需要可预测的初始状态。
Global Setup Pattern
全局配置模式
Add to reset data before tests:
globalSetupplaywright.e2e.config.ts:
typescript
export default defineConfig({
// ... other config
globalSetup: './global-setup.ts',
});global-setup.ts:
typescript
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:
json
{
"items": [
{ "id": "test-1", "name": "Test Item 1" },
{ "id": "test-2", "name": "Test Item 2" }
]
}添加以在测试前重置数据:
globalSetupplaywright.e2e.config.ts:
typescript
export default defineConfig({
// ... 其他配置
globalSetup: './global-setup.ts',
});global-setup.ts:
typescript
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,跳过数据重置。');
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('🌱 已将数据重置为初始状态');
}
export default globalSetup;test-seed-data.json:
json
{
"items": [
{ "id": "test-1", "name": "Test Item 1" },
{ "id": "test-2", "name": "Test Item 2" }
]
}Environment Variable
环境变量
Add to locate your Umbraco's App_Data folder:
UMBRACO_DATA_PATHbash
UMBRACO_DATA_PATH=/path/to/Umbraco.Web.UI/App_Data npm run test:e2e添加以指定Umbraco的App_Data文件夹路径:
UMBRACO_DATA_PATHbash
UMBRACO_DATA_PATH=/path/to/Umbraco.Web.UI/App_Data npm run test:e2eTesting Custom Sections
自定义板块测试
Custom sections work with testhelpers' method - pass the section pathname:
goToSection()typescript
// 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 });
});自定义板块可与测试助手的方法配合使用 - 传入板块路径名即可:
goToSection()typescript
// 板块路径名 - 与你在section/constants.ts中定义的一致
const MY_SECTION = 'my-section';
// 使用测试助手导航至自定义板块的辅助函数
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 });
});Context Menu (Entity Actions) Testing
上下文菜单(实体操作)测试
Testing entity actions on tree items. Uses since testhelpers don't cover custom entity actions.
umbracoUi.pageImportant: Entity actions in Umbraco are rendered as buttons inside the dropdown menu, not as roles directly. The most reliable approach is to use the "View actions" button rather than right-click:
menuitemtypescript
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 });
});测试树节点上的实体操作。由于测试助手不覆盖自定义实体操作,需使用。
umbracoUi.page重要提示:Umbraco中的实体操作在下拉菜单中以按钮形式呈现,而非直接作为角色。最可靠的方式是使用"查看操作"按钮,而非右键点击:
menuitemtypescript
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();
// 等待下拉菜单加载并检查操作项(操作项是按钮,而非菜单项!)
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 });
});Alternative: Right-Click Context Menu
替代方案:右键上下文菜单
Right-click also works but the actions button approach is more reliable:
typescript
// 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();右键点击也可生效,但使用操作按钮的方式更可靠:
typescript
// 右键点击方式(比操作按钮方式可靠性低)
await itemLink.click({ button: 'right' });
await umbracoUi.page.waitForTimeout(500);
await umbracoUi.page.getByRole('button', { name: 'Delete' }).click();CRUD Testing Patterns
CRUD测试模式
For custom extensions, use for UI interactions. For core Umbraco content, prefer helpers for setup/teardown.
umbracoUi.pageumbracoApi针对自定义扩展,使用进行UI交互。针对Umbraco核心内容,优先使用助手进行测试环境的创建/销毁。
umbracoUi.pageumbracoApiCreate via Actions Menu (Custom Extension)
通过操作菜单创建(自定义扩展)
typescript
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 });
});typescript
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 });
});Scoping to Specific Tree Items
限定到特定树节点
When multiple tree items exist with similar elements, scope selectors to avoid ambiguity:
typescript
// 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' });当存在多个包含相似元素的树节点时,需限定选择器范围以避免歧义:
typescript
// 错误示例 - 当多个文件夹都有"创建"按钮时会产生歧义
const createButton = page.getByRole('button', { name: 'Create' });
// 正确示例 - 限定到特定文件夹的菜单
const folderMenu = page.getByRole('menu').filter({ hasText: 'My Folder' });
const createButton = folderMenu.getByRole('button', { name: 'Create' });Update and Save
更新并保存
typescript
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();
});typescript
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();
});Running Tests
运行测试
bash
undefinedbash
undefinedRun all E2E tests
运行所有E2E测试
npm run test:e2e
npm run test:e2e
Run with UI mode (visual debugging)
以UI模式运行测试(可视化调试)
npm run test:e2e:ui
npm run test:e2e:ui
Run specific test file
运行指定测试文件
npx playwright test tests/e2e/my-extension.spec.ts
npx playwright test tests/e2e/my-extension.spec.ts
Run with specific tag
运行带特定标签的测试
npx playwright test --grep "@smoke"
npx playwright test --grep "@smoke"
Run in debug mode
以调试模式运行测试
npx playwright test --debug
---npx playwright test --debug
---Troubleshooting
故障排除
getByTestId() not finding elements
getByTestId()无法找到元素
Ensure is set in playwright.config.ts.
testIdAttribute: 'data-mark'确保在playwright.config.ts中设置了。
testIdAttribute: 'data-mark'Authentication fails
认证失败
- Check credentials are correct
.env - Ensure Umbraco instance is running
- Verify is set
STORAGE_STAGE_PATH
- 检查文件中的凭据是否正确
.env - 确保Umbraco实例已启动
- 验证是否正确设置
STORAGE_STAGE_PATH
Tests timeout
测试超时
- Increase timeouts in config
- Ensure Umbraco is responsive
- Check for JS errors in browser console
- 在配置文件中增加超时时间
- 确保Umbraco实例响应正常
- 检查浏览器控制台中的JS错误
Tests fail in CI
测试在CI环境中失败
- Ensure Umbraco instance is accessible
- Set environment variables in CI
- Use
npx playwright install chromium
- 确保Umbraco实例可访问
- 在CI环境中设置正确的环境变量
- 运行
npx playwright install chromium
Alternative: MSW Mode (No Backend Required)
替代方案:MSW模式(无需后端)
For 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 |
如需无需真实Umbraco后端的快速测试,可使用模拟后台方式。
调用方式:
skill: umbraco-mocked-backoffice| 维度 | 真实后端模式(本技能) | MSW模式 |
|---|---|---|
| 环境准备 | 需运行Umbraco实例 | 克隆Umbraco-CMS,执行npm install |
| 认证 | 需要 | 不需要 |
| 速度 | 较慢 | 较快 |
| 适用场景 | 集成/验收测试 | UI/组件测试 |