umbraco-e2e-testing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Umbraco E2E Testing

Umbraco 端到端(E2E)测试

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.
使用Playwright和
@umbraco/playwright-testhelpers
对Umbraco后台扩展进行端到端(E2E)测试。该方法针对真实运行的Umbraco实例进行测试,验证完整的用户工作流。

Critical: Use Testhelpers for Core Umbraco

重要提示:针对Umbraco核心功能使用测试助手

Use
@umbraco/playwright-testhelpers
for core Umbraco operations:
PackagePurposeWhy Required
@umbraco/playwright-testhelpers
UI and API helpersHandles auth, navigation, core entity CRUD
@umbraco/json-models-builders
Test data buildersCreates 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
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
用途必要性说明
@umbraco/playwright-testhelpers
UI和API助手处理认证、导航、核心实体的增删改查(CRUD)
@umbraco/json-models-builders
测试数据构建器创建结构正确的有效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
umbracoUi.page
(raw Playwright) because testhelpers don't know about your custom elements:
typescript
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 ForUse
umbracoUi.page
For
Login/logoutCustom tree items
Navigate to ANY section (including custom)Custom workspace elements
Create/edit documents via APICustom entity actions
Built-in UI interactionsCustom UI components
针对自定义扩展,使用
umbracoUi.page
(原生Playwright),因为测试助手无法识别你的自定义元素:
typescript
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();
});
使用测试助手的场景使用
umbracoUi.page
的场景
登录/登出自定义树节点
导航至任意板块(包括自定义板块)自定义工作区元素
通过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

文档资源



Setup

安装配置

Dependencies

依赖项

Add to
package.json
:
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:
bash
npm install
npx playwright install chromium
Version Compatibility: Match testhelpers to your Umbraco version:
UmbracoTesthelpers
17.1.x (pre-release)
17.1.0-beta.x
17.0.x
^17.0.15
14.x
^14.x
添加至
package.json
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"
  }
}
然后运行:
bash
npm install
npx playwright install chromium
版本兼容性:确保测试助手版本与Umbraco版本匹配:
Umbraco版本测试助手版本
17.1.x(预发布版)
17.1.0-beta.x
17.0.x
^17.0.15
14.x
^14.x

Configuration

配置文件

Create
playwright.config.ts
:
typescript
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.ts
typescript
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

关键设置

SettingValueWhy Required
testIdAttribute
'data-mark'
Umbraco uses
data-mark
, not
data-testid
STORAGE_STAGE_PATH
Path to user.jsonTesthelpers read auth tokens from this file
ignoreHTTPSErrors
true
For local dev with self-signed certs
Without
testIdAttribute: 'data-mark'
, all
getByTestId()
calls will fail.
设置项必要性说明
testIdAttribute
'data-mark'
Umbraco使用
data-mark
而非
data-testid
STORAGE_STAGE_PATH
user.json的路径测试助手从此文件读取认证令牌
ignoreHTTPSErrors
true
适配本地开发环境的自签名证书
如果未设置
testIdAttribute: 'data-mark'
,所有
getByTestId()
调用都会失败。

Authentication Setup

认证配置

Create
tests/e2e/auth.setup.ts
:
typescript
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.ts
typescript
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
.env
(add to
.gitignore
):
bash
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
VariableRequiredPurpose
UMBRACO_URL
YesBackoffice URL
UMBRACO_USER_LOGIN
YesAdmin email
UMBRACO_USER_PASSWORD
YesAdmin password
UMBRACO_DATA_PATH
NoApp_Data path for test data reset (see "Testing with Persistent Data")
创建
.env
文件(需添加至
.gitignore
):
bash
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
后台地址
UMBRACO_USER_LOGIN
管理员邮箱
UMBRACO_USER_PASSWORD
管理员密码
UMBRACO_DATA_PATH
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.json

my-extension/
├── src/
│   └── ...
├── tests/
│   └── e2e/
│       ├── .auth/
│       │   └── user.json       # 认证状态(已加入git忽略)
│       ├── auth.setup.ts       # 认证配置
│       └── my-extension.spec.ts
├── playwright.config.ts
├── .env                        # 已加入git忽略
├── .env.example
└── package.json

Patterns

测试模式

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-builders
:
typescript
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-builders
typescript
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
    getByRole('link', { name: '...' })
    is more reliable than generic
    umb-tree-item
    selectors
  • Built-in trees (Document Types, etc.) also use
    umb-tree-item
    , causing selector conflicts
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.saveAndPublish

Examples

测试示例

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
tree-example
demonstrates E2E testing for a custom tree extension:
Location:
umbraco-backoffice/examples/tree-example/Client/
bash
undefined
tree-example
展示了自定义树扩展的E2E测试:
位置
umbraco-backoffice/examples/tree-example/Client/
bash
undefined

Run 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

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 sidebar
URL=https://localhost:44325
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
notes-wiki
demonstrates E2E testing with persistent data and CRUD operations:
Location:
umbraco-backoffice/examples/notes-wiki/Client/
bash
undefined
notes-wiki
展示了带持久化数据CRUD操作的E2E测试:
位置
umbraco-backoffice/examples/notes-wiki/Client/
bash
undefined

Run 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

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个测试用例

关键文件:
- `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
globalSetup
to reset data before tests:
playwright.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" }
  ]
}
添加
globalSetup
以在测试前重置数据:
playwright.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
UMBRACO_DATA_PATH
to locate your Umbraco's App_Data folder:
bash
UMBRACO_DATA_PATH=/path/to/Umbraco.Web.UI/App_Data npm run test:e2e

添加
UMBRACO_DATA_PATH
以指定Umbraco的App_Data文件夹路径:
bash
UMBRACO_DATA_PATH=/path/to/Umbraco.Web.UI/App_Data npm run test:e2e

Testing Custom Sections

自定义板块测试

Custom sections work with testhelpers'
goToSection()
method - pass the section pathname:
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
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:
typescript
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中的实体操作在下拉菜单中以按钮形式呈现,而非直接作为
menuitem
角色。最可靠的方式是使用"查看操作"按钮,而非右键点击:
typescript
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
umbracoUi.page
for UI interactions. For core Umbraco content, prefer
umbracoApi
helpers for setup/teardown.
针对自定义扩展,使用
umbracoUi.page
进行UI交互。针对Umbraco核心内容,优先使用
umbracoApi
助手进行测试环境的创建/销毁。

Create 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
undefined
bash
undefined

Run 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
testIdAttribute: 'data-mark'
is set in playwright.config.ts.
确保在playwright.config.ts中设置了
testIdAttribute: 'data-mark'

Authentication fails

认证失败

  • Check
    .env
    credentials are correct
  • Ensure Umbraco instance is running
  • Verify
    STORAGE_STAGE_PATH
    is set
  • 检查
    .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
AspectReal Backend (this skill)MSW Mode
SetupRunning Umbraco instanceClone Umbraco-CMS, npm install
AuthRequiredNot required
SpeedSlowerFaster
Use caseIntegration/acceptanceUI/component testing
如需无需真实Umbraco后端的快速测试,可使用模拟后台方式。
调用方式
skill: umbraco-mocked-backoffice
维度真实后端模式(本技能)MSW模式
环境准备需运行Umbraco实例克隆Umbraco-CMS,执行npm install
认证需要不需要
速度较慢较快
适用场景集成/验收测试UI/组件测试