Bootstrap

playwright前端自动化测试

 一、playwright介绍及安装:

1、介绍:

        端到端测试(End to end)是现代应用中的一种常见的测试方式,它模仿用户在客户端的 UI 界面执行操作。与单元测试(Unit Test)不同,前者主要通过测试函数的输入、输出、抛出错误等数据,确保其能够可靠完成工作;而后者更加关注在应用客户端场景下,一个完整的操作链是否能够完成,界面的内容信息、布局样式是否符合预期。

相比起经典的测试工具 CypressSeleniumPlaywright 具有非常大的优势:

  • API 设计更加合理,甚至提供了可视化生成测试代码的能力,使用难度较低;
  • 支持多线程执行测试,具有更快的测试执行速度;
  • 支持所有的现代浏览器,包括 ChromiumWebKitFirefox 等;

2、安装:

pnpm create playwright

 安装好后的项目目录

 3、默认浏览器内核配置:

浏览器内核:chromium  firefox  webkit 

4、运行测试案例:

里面自带有测试案例 example.spec.ts,执行命令:

npx playwright test

5、报错及解决方案:

发现报错了...

这是因为 playwright 默认的timeout时间为30秒,过了30秒测试案例没跑完,测会抛出异常;

解决办法:

  • 延长超时时间
  • 给访问页面的goto方法,配置参数 { waitUntil: 'domcontentloaded' };

思考:在实际项目中,使用playwright的goto方法访问一个页面时,容易碰到load未加载导致超时的问题,如何解决?
解答:这是因为默认情况下,goto 方法默认通过页面的 load 事件判定访问成功,而 load 事件的触发是要求页面中所有资源加载完成的,而 domcontentloaded 只要求 HTML 文档完成解析;
1、DOMContentLoaded:当初始的 HTML 文档被完全加载和解析完成之后,在页面能显示内容的时候,DOMContentLoaded 事件被触发,而无需等待样式表、图像和子框架的完全加载。
2、load:当整个页面及所有依赖资源(JS,CSS,图片等全部加载完)如样式表和图片都已完成加载时,将触发 load 事件。它与 DOMContentLoaded 不同,后者只要页面 DOM 加载完成就触发,无需等待依赖资源的加载。
所以解决方案就是在使用goto方法的时候,配置waitUntil: 'domcontentloaded';这样goto方法不需要等到页面所有资源都加载完成才有返回值;

6、HTML测试报告:

 7、在UI模式下运行示例测试:

npx playwright test --ui

8、在Head模式下运行浏览器: 

要在 head 模式下运行测试,请使用 --headed 标志。 这将使你能够直观地看到 Playwright 如何与网站交互。

 npx playwright test --headed

 相当于在测试的时候,打开了浏览器,实时展示测试的场景;

9、在不同浏览器上的测试:

要指定要在哪个浏览器上运行测试,请使用 --project 标志,后跟浏览器名称。

npx playwright test --project webkit

要指定多个浏览器来运行测试,请多次使用 --project 标志,后跟每个浏览器的名称。

npx playwright test --project webkit --project firefox

10、运行特定测试

要运行单个测试文件,请传入要运行的测试文件的名称。

npx playwright test landing-page.spec.ts

 要从不同目录运行一组测试文件,请传入要在其中运行测试的目录的名称。

npx playwright test tests/todo-page/ tests/landing-page/

要运行具有特定标题的测试,请使用 -g 标志,后跟测试标题。

npx playwright test -g "add a todo item"

11、跟踪查看器 

Playwright Trace Viewer 是一个 GUI 工具,可让你探索测试中记录的 Playwright 跟踪,这意味着你可以前后浏览测试的每个操作,并直观地查看每个操作期间发生的情况。

import { defineConfig } from '@playwright/test';
export default defineConfig({
  retries: process.env.CI ? 2 : 0, // set to 2 when running on CI
  // ...
  use: {
    trace: 'on-first-retry', // 记录每个测试的第一次重试的跟踪
  },
});

 执行命令:

npx playwright test --trace on

打开报告页面:

npx playwright show-report

 点击标题,可以打开详情页面

滚动条下拉,点击trace,可以下载一个压缩文件;

返回上一页面,点击View trace,可以打开如下页面,可以详细看到测试轨迹; 

12、Vscode playwright插件安装:

在vscode扩展里面搜索 Playwright Test for VSCode,并安装:

 13、安装 Playwright Test for VSCode 插件遇到的问题:

思考:分享vscode playwright插件安装碰到的问题与解决过程;
解答:
1、playwright在vscode里面有一个插件,Playwright Test for VSCode,是一个非常有用的插件,使用起来很方便;
2、在vscode里面安装好Playwright Test for VSCode后,打开测试界面,发现是空白的!,以为是插件没安装好,重复卸载安装几次后,在测试界面依然是空白的...,重启vscode,再次打开测试界面,发现有内容了,第一坑。。。
3、编写好一个组件测试案例,再次打开vscode的测试界面,发现刚才的测试案例加载出来了,但是点击测试图标,没有反应。那是什么问题?依旧重启vscode,没有解决。上网搜索,发现需要安装@playwright/test依赖,安装好后问题解决,第二坑。。。

安装好后的示意图:

这样,你写的测试案例,都会检索出来根据一定的层级在这里列出来

二、写测试案例

1、运行代码生成器:

使用 codegen 命令运行测试生成器,后跟要为其生成测试的网站的 URL。 URL 是可选的,你始终可以在没有它的情况下运行命令,然后将 URL 直接添加到浏览器窗口中。

npx playwright codegen demo.playwright.dev/todomvc

 执行上面命令,会打开两个窗口;你在做边页面做的所有操作,都会记录到右边;

 codegen记录的页面操作:

import { test, expect } from '@playwright/test';

test('test', async ({ page }) => {
  await page.goto('https://demo.playwright.dev/todomvc/');
  await page.goto('https://demo.playwright.dev/todomvc/#/');
  await page.locator('html').click();
  await page.getByPlaceholder('What needs to be done?').fill('学习');
  await page.getByPlaceholder('What needs to be done?').press('Enter');
  await page.getByPlaceholder('What needs to be done?').fill('休息');
  await page.getByPlaceholder('What needs to be done?').press('Enter');
  await page.getByPlaceholder('What needs to be done?').fill('吃午餐');
  await page.getByPlaceholder('What needs to be done?').press('Enter');
  await page.getByText('学习').click();
  await page.getByText('休息').click();
  await page.getByRole('link', { name: 'Completed' }).click();
  await page.getByText('All Active Completed').click();
  await page.getByText('吃午餐').click();
  await page.getByText('All Active Completed').click();
  await page.getByRole('link', { name: 'All' }).click();
  await page.getByRole('link', { name: 'Active' }).click();
  await page.getByText('休息').click();
  await page.getByText('休息').click({
    button: 'right'
  });
  await page.getByText('休息').click();
  await page.getByText('学习').click();
});

思考:playwright测试代码生成器优缺点与适用场景?
解答:测试代码生成器 是 Playwright 的一个主要卖点,生成器会根据我们在浏览器中的交互与操作,自动生成对应的测试代码。
缺点:
1、对页面元素的定位不够精准,难以适应 DOM 结构的修改。(使用 testid 可以大幅改善)
2、生成的代码只有元素定位与交互操作的部分,只能证明“流程可以跑通”,但是由于缺少断言,无法确保“流程正确”。
3、生成的代码重复度很高,不利于后期的修改调整,往往只能推倒重来。
适用场景:
1、补充存量业务用例时,当前迭代周期交付压力大,编写测试用例的时间不足,至少能够以这种方式实现“从无到有”。
2、新开发的特性,后续存在着大幅修改和迭代的可能,但至少要确保业务能够跑通。
3、这种低规格的用例作为一种临时方案,在时间充裕或者业务稳定后,会被更加精细化的用例逐步替代。

2、生成定位器

你可以使用测试生成器生成 locators

  • 按 'Record' 按钮停止录音,然后会出现 'Pick Locator' 按钮。
  • 单击 'Pick Locator' 按钮,然后将鼠标悬停在浏览器窗口中的元素上,即可查看每个元素下方高亮的定位器。
  • 要选择定位器,请单击要定位的元素,该定位器的代码将显示在“选择定位器”按钮旁边的定位器在线运行中。
  • 然后,你可以在定位器在线运行中编辑定位器以对其进行微调,并查看浏览器窗口中高亮的匹配元素。
  • 使用复制按钮复制定位器并将其粘贴到你的代码中。

3、配置:

playwright.config.ts

3.1、基本配置:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  // 在“tests”目录中查找相对于此配置文件的测试文件。
  testDir: 'tests',

  // 并行运行所有测试。
  fullyParallel: true,

  // 如果您不小心离开了测试,则使构建在CI上失败。只在源代码中。
  forbidOnly: !!process.env.CI,

  // 只能在CI上重试。
  retries: process.env.CI ? 2 : 0,

  // 选择退出CI上的并行测试。
  workers: process.env.CI ? 1 : undefined,

  // 报告者使用
  reporter: 'html',

  use: {
    // 在' await page.goto('/') '等操作中使用的基本URL。
    baseURL: 'http://127.0.0.1:3000',

    // 重新尝试失败的测试时收集跟踪。
    trace: 'on-first-retry',
  },
  // 为主要浏览器配置项目。
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
  // 在开始测试之前运行本地开发服务器。
  webServer: {
    command: 'npm run start',
    url: 'http://127.0.0.1:3000',
    reuseExistingServer: !process.env.CI,
  },
});
选项描述
testConfig.forbidOnly如果任何测试被标记为 test.only,是否会错误退出。 对 CI 很有用。
testConfig.fullyParallel让所有文件中的所有测试并行运行。 详细信息请参见 /并行性和分片
testConfig.projects在多种配置或多个浏览器上运行测试
testConfig.reporter报告器使用。 请参阅 测试报告器 了解有关哪些报告器可用的更多信息。
testConfig.retries每次测试的最大重试次数。 请参阅 测试重试 了解有关重试的更多信息。
testConfig.testDir包含测试文件的目录。
testConfig.use带有 use{} 的选项
testConfig.webServer要在测试期间启动服务器,请使用 webServer 选项
testConfig.workers用于并行测试的最大并发工作进程数。 也可以设置为逻辑 CPU 核心的百分比,例如 '50%'.。 详细信息请参见 /并行性和分片

3.2、过滤测试

import { defineConfig } from '@playwright/test';

export default defineConfig({
  // Glob模式或正则表达式来忽略测试文件。
  testIgnore: '*test-assets',

  // 匹配测试文件的全局模式或正则表达式。
  testMatch: '*todo-tests/*.spec.ts',
});

3.3、高级配置:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  // Folder for test artifacts such as screenshots, videos, traces, etc.
  outputDir: 'test-results',

  // path to the global setup files.
  globalSetup: require.resolve('./global-setup'),

  // path to the global teardown files.
  globalTeardown: require.resolve('./global-teardown'),

  // Each test is given 30 seconds.
  timeout: 30000,

});
testConfig.globalSetup全局安装文件的路径。 在所有测试之前将需要并运行该文件。 它必须导出单个函数。
testConfig.globalTeardown全局拆卸文件的路径。 在所有测试之后将需要并运行该文件。 它必须导出单个函数。
testConfig.outputDir用于测试工件的文件夹,例如屏幕截图、视频、痕迹等。
testConfig.timeoutPlaywright 对每次测试强制执行 timeout,默认为 30 秒。 测试函数、fixtures、beforeEach 和 afterEach 钩子所花费的时间包含在测试超时中。

3.4、期望选项

import { defineConfig } from '@playwright/test';

export default defineConfig({
  expect: {
    // expect()应该等待条件满足的最大时间。
    timeout: 5000,
    toHaveScreenshot: {
      // 可以不同的可接受的象素数量,默认情况下不设置。
      maxDiffPixels: 10,
    },
    toMatchSnapshot: {
      // 不同的像素的可接受比例
      // 像素的总数,介于0和1之间。
      maxDiffPixelRatio: 0.1,
    },
  },
});
选项描述
testConfig.expect网络优先断言 和 expect(locator).toHaveText() 一样,默认都有 5 秒的单独超时。 这是 expect() 等待条件满足的最长时间。 了解有关 测试并预期超时 以及如何为单个测试设置它们的更多信息。
expect(page).toHaveScreenshot()expect(locator).toHaveScreeshot() 方法的配置。
expect(value).toMatchSnapshot()expect(locator).toMatchSnapshot() 方法的配置。

4、测试使用选项:

1、基本选项:

设置所有测试的基本 URL 和存储状态:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    // 在' await page.goto('/') '等操作中使用的基本URL。
    baseURL: 'http://127.0.0.1:3000',

    // 使用给定的存储状态填充上下文。
    storageState: 'state.json',
  },
});
选项描述
testOptions.baseURL用于上下文中所有页面的基本 URL。 允许仅使用路径进行导航,例如 page.goto('/settings')
testOptions.storageState使用给定的存储状态填充上下文。 对于轻松验证很有用,了解更多

 其它如下,略。。。

模拟选项

网络选项

录音选项

其它选项

更多浏览器和上下文选项

显式上下文创建和选项继承

配置范围

测试使用选项 | Playwright 中文网 (nodejs.cn)

5、注释功能:

Playwright Test 支持测试注释来处理失败、不稳定、跳过、焦点和标签测试:

  • test.skip() 将测试标记为不相关。 Playwright 测试不运行这样的测试。 当测试在某些配置中不适用时使用此注释。
  • test.fail() 将测试标记为失败。 Playwright Test 将运行此测试并确保它确实失败。 如果测试没有失败,Playwright 测试将会诉说。
  • test.fixme() 将测试标记为失败。 与 fail 注释相反,Playwright 测试不会运行此测试。 当运行测试缓慢或崩溃时使用 fixme
  • test.slow() 将测试标记为慢速并将测试超时增加三倍。

注释可以用于单个测试或一组测试。 注释可以是有条件的,在这种情况下,它们在条件为真时应用。 注释可能取决于测试装置。 同一个测试可能有多个注释,可能处于不同的配置中。

5.1、集中测试:

你可以集中一些测试。 当存在集中测试时,仅运行这些测试。

test.skip('skip this test', async ({ page }) => {
  // This test is not run
});

5.2、有条件得跳过测试:

你可以根据情况跳过某些测试。

test('skip this test', async ({ page, browserName }) => {
  test.skip(browserName === 'firefox', 'Still working on it');
});

5.3、群组测试

你可以对测试进行分组,为它们指定一个逻辑名称,或者在组的钩子之前/之后确定范围。

import { test, expect } from '@playwright/test';

test.describe('two tests', () => {
  test('one', async ({ page }) => {
    // ...
  });

  test('two', async ({ page }) => {
    // ...
  });
});

5.4、标签测试

有时你想将测试标记为 @fast 或 @slow,并且只运行具有特定标记的测试。 我们建议你为此使用 --grep 和 --grep-invert 命令行标志:

import { test, expect } from '@playwright/test';

test('Test login page @fast', async ({ page }) => {
  // ...
});

test('Test full report @slow', async ({ page }) => {
  // ...
});

然后你将只能运行该测试:

npx playwright test --grep @fast

或者,如果你想要相反的结果,你可以跳过带有特定标签的测试:

npx playwright test --grep-invert @slow

要运行包含任一标签(逻辑 OR 运算符)的测试:

npx playwright test --grep "@fast|@slow"

5.5、有条件地跳过一组测试:

test.describe('chromium only', () => {
  test.skip(({ browserName }) => browserName !== 'chromium', 'Chromium only!');

  test.beforeAll(async () => {
    // This hook is only run in Chromium.
  });

  test('test 1', async ({ page }) => {
    // This test is only run in Chromium.
  });

  test('test 2', async ({ page }) => {
    // This test is only run in Chromium.
  });
});

5.6、在 beforeEach 钩子中使用 fixme

为了避免运行 beforeEach 钩子,你可以在钩子本身中添加注释。


test.beforeEach(async ({ page, isMobile }) => {
  test.fixme(isMobile, '设置页面还不能在手机上工作');
  await page.goto('http://localhost:3000/settings');
});

test('user profile', async ({ page }) => {
  await page.getByText('My Profile').click();
  // ...
});

6、模拟:

Playwright 为选定的台式机、平板电脑和移动设备提供了使用 playwright.devices 的 设备参数注册表。 它可用于模拟特定设备的浏览器行为,例如用户代理、屏幕尺寸、视口以及是否启用了触摸。 所有测试都将使用指定的设备参数运行。

模拟 | Playwright 中文网 (nodejs.cn)

7、重试:

Playwright 支持 测试重试。 启用后,失败的测试将重试多次,直到通过,或达到最大重试次数。 默认情况下,不会重试失败的测试。

# 对失败的测试重试3次
npx playwright test --retries=3

也可以在配置文件中配置

import { defineConfig } from '@playwright/test';

export default defineConfig({
  // 对失败的测试重试3次
  retries: 3,
});

Playwright 测试将测试分类如下:

  • "passed" - 第一次运行时通过的测试;
  • "flaky" - 第一次运行失败,但重试时通过的测试;
  • "failed" - 第一次运行失败且所有重试均失败的测试。

7.1、串行模式

使用 test.describe.serial() 对相关测试进行分组,以确保它们始终按顺序一起运行。 如果其中一项测试失败,则跳过所有后续测试。 组中的所有测试都会一起重试。

import { test } from '@playwright/test';

test.describe.configure({ mode: 'serial' });

test.beforeAll(async () => { /* ... */ });
test('first good', async ({ page }) => { /* ... */ });
test('second flaky', async ({ page }) => { /* ... */ });
test('third good', async ({ page }) => { /* ... */ });

当没有 retries 运行时,失败后的所有测试都会被跳过:

7.2、在测试之间重用单页

Playwright Test 为每个测试创建一个独立的 页面 对象。 但是,如果你想在多个测试之间重用单个 页面 对象,你可以在 test.beforeAll() 中创建自己的对象并在 test.afterAll() 中关闭它。

import { test, type Page } from '@playwright/test';

test.describe.configure({ mode: 'serial' });

let page: Page;

test.beforeAll(async ({ browser }) => {
  page = await browser.newPage();
});

test.afterAll(async () => {
  await page.close();
});

test('runs first', async () => {
  await page.goto('https://playwright.nodejs.cn/');
});

test('runs second', async () => {
  await page.getByText('Get Started').click();
});

8、超时:

Playwright Test 对于各种任务有多个可配置的超时。

暂停默认描述
测试超时30000 毫秒每个测试的超时,包括测试、钩子和夹具:
默认设置
config = { timeout: 60000 }
覆盖
test.setTimeout(120000)
期望超时5000 毫秒每个断言的超时:
默认设置
config = { expect: { timeout: 10000 } }
覆盖
expect(locator).toBeVisible({ timeout: 10000 })

8.1、测试超时

Playwright Test 强制每个测试超时,默认为 30 秒。 测试函数、装置、beforeEach 和 afterEach 钩子所花费的时间包含在测试超时中。

8.1.1、在配置中设置测试超时

import { defineConfig } from '@playwright/test';

export default defineConfig({
  timeout: 5 * 60 * 1000,
});

8.1.2、设置单个测试的超时时间

import { test, expect } from '@playwright/test';

test('slow test', async ({ page }) => {
  test.slow(); // 将默认超时设置为三倍的简单方法
  // ...
});

test('very slow test', async ({ page }) => {
  test.setTimeout(120000);
  // ...
});

8.1.3、更改 beforeEach 钩子的超时

import { test, expect } from '@playwright/test';

test.beforeEach(async ({ page }, testInfo) => {
  // Extend timeout for all tests running this hook by 30 seconds.
  testInfo.setTimeout(testInfo.timeout + 30000);
});

8.1.4、更改 beforeAll/afterAll 钩子的超时

beforeAll 和 afterAll 钩子具有单独的超时,默认情况下等于测试超时。 你可以通过在钩子内调用 testInfo.setTimeout() 为每个钩子单独更改它。

import { test, expect } from '@playwright/test';

test.beforeAll(async () => {
  // Set timeout for this hook.
  test.setTimeout(60000);
});

8.2、期望超时

像 expect(locator).toHaveText() 这样的 Web 优先断言有一个单独的超时,默认为 5 秒。 断言超时与测试超时无关。 它会产生以下错误:

example.spec.ts:3:1 › basic test ===========================

Error: expect(received).toHaveText(expected)

Expected string: "my text"
Received string: ""
Call log:
  - expect.toHaveText with timeout 5000ms
  - waiting for "locator('button')"

8.2.1、在配置中设置期望超时

import { defineConfig } from '@playwright/test';

export default defineConfig({
  expect: {
    timeout: 10 * 1000,
  },
});

8.3、全局超时

Playwright Test 支持整个测试运行超时。 这可以防止出现问题时过度使用资源。 没有默认的全局超时,但你可以在配置中设置合理的超时,例如一小时。 全局超时会产生以下错误:

Running 1000 tests using 10 workers

  514 skipped
  486 passed
  Timed out waiting 3600s for the entire test run

你可以在配置中设置全局超时:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  globalTimeout: 60 * 60 * 1000,
});

8.4、高级:底层超时

超时 | Playwright 中文网 (nodejs.cn)

8.5、思考:playwright 的几种超时时间的区别,及设置方法总结。

解答:playwright 有6种超时时间,
1、测试超时:运行每个测试案例的时间,默认30秒;
在playwright.config.ts配置文件中配置timeout,也可以设置单独的每个test案例的测试时间,使用test.slow()方法或者test.setTimeout(120000)等方法
2、期望超时:只expect的执行超时时间,默认5秒;
在playwright.config.ts配置文件中配置expect:{timeout:10000};
或者在断言里面设置:await expect(page.getByRole('button')).toHaveText('Sign in', { timeout: 10000 });
3、全局超时:支持整个测试运行超时,这可以防止出现问题时过度使用资源;
在playwright.config.ts配置文件中配置globalTimeout: 60 * 60 * 1000;
4、操作超时:
在playwright.config.ts配置文件中配置actionTimeout: 10 * 1000,
5、导航超时:
在playwright.config.ts配置文件中配置navigationTimeout: 30 * 1000,
6、底层超时:这些是测试运行程序预先配置的底层超时,你不需要更改它们。 

三、行动:

1、文本输入:

使用 locator.fill() 是填写表单字段的最简单方法。 它聚焦元素并使用输入的文本触发 input 事件。 它适用于 <input><textarea> 和 [contenteditable] 元素。

// Text input
await page.getByRole('textbox').fill('Peter');

// Date input
await page.getByLabel('Birth date').fill('2020-02-02');

// Time input
await page.getByLabel('Appointment time').fill('13:15');

// Local datetime input
await page.getByLabel('Local time').fill('2020-03-02T05:15');

2、复选框和单选按钮

使用 locator.setChecked() 是选中和取消选中复选框或单选按钮的最简单方法。 此方法可用于 input[type=checkbox]

// Check the checkbox
await page.getByLabel('I agree to the terms above').check();

// Assert the checked state
expect(page.getByLabel('Subscribe to newsletter')).toBeChecked();

// Select the radio button
await page.getByLabel('XL').check();

3、选择选项

使用 locator.selectOption() 选择 <select> 元素中的一个或多个选项。 你可以指定选项 value 或 label 进行选择。 可以选择多个选项。

// Single selection matching the value or label
await page.getByLabel('Choose a color').selectOption('blue');

// Single selection matching the label
await page.getByLabel('Choose a color').selectOption({ label: 'Blue' });

// Multiple selected items
await page.getByLabel('Choose multiple colors').selectOption(['red', 'green', 'blue']);

4、鼠标点击

执行简单的人工点击。

// Generic click
await page.getByRole('button').click();

// Double click
await page.getByText('Item').dblclick();

// Right click
await page.getByText('Item').click({ button: 'right' });

// Shift + click
await page.getByText('Item').click({ modifiers: ['Shift'] });

// Hover over element
await page.getByText('Item').hover();

// Click the top left corner
await page.getByText('Item').click({ position: { x: 0, y: 0 } });

4.1、强制点击

有时,应用使用不平凡的逻辑,其中悬停元素会将其与拦截点击的另一个元素重叠。 此行为与元素被覆盖且点击被分派到其他地方的错误没有区别。 如果你知道正在发生这种情况,则可以绕过 actionability 检查并强制单击:

await page.getByRole('button').click({ force: true });

4.2、程序化点击

如果你对在真实条件下测试应用不感兴趣,并且希望通过任何可能的方式模拟点击,则可以通过简单地使用 locator.dispatchEvent() 在元素上调度点击事件来触发 HTMLElement.click() 行为:

await page.getByRole('button').dispatchEvent('click');

5、输入字符

提醒:

大多数时候,你应该使用 locator.fill() 输入文本。 请参阅上面的 文本输入 部分。 仅当页面上有特殊键盘处理时才需要键入字符。

在字段中逐个字符输入,就好像用户使用 locator.pressSequentially() 的真实键盘一样。

// Press keys one by one
await page.locator('#area').pressSequentially('Hello World!');

此方法将发出所有必要的键盘事件,以及所有 keydownkeyupkeypress 事件。 你甚至可以在按键之间指定可选的 delay 以模拟真实的用户行为。

6、按键和快捷键

// Hit Enter
await page.getByText('Submit').press('Enter');

// Dispatch Control+Right
await page.getByRole('textbox').press('Control+ArrowRight');

// Press $ sign on keyboard
await page.getByRole('textbox').press('$');

locator.press() 方法聚焦所选元素并产生单个击键。 它接受在键盘事件的 keyboardEvent.key 属性中发出的逻辑键名称:

locator.press() 方法聚焦所选元素并产生单个击键。 它接受在键盘事件的 keyboardEvent.key 属性中发出的逻辑键名称:

英The locator.press() method focuses the selected element and produces a single keystroke. It accepts the logical key names that are emitted in the keyboardEvent.key property of the keyboard events:

Backquote, Minus, Equal, Backslash, Backspace, Tab, Delete, Escape,
ArrowDown, End, Enter, Home, Insert, PageDown, PageUp, ArrowRight,
ArrowUp, F1 - F12, Digit0 - Digit9, KeyA - KeyZ, etc.
  • 你也可以指定要生成的单个字符,例如 "a" 或 "#"
  • 还支持以下修改快捷方式: Shift, Control, Alt, Meta

简单版本产生单个字符。 该字符区分大小写,因此 "a" 和 "A" 将产生不同的结果。

// <input id=name>
await page.locator('#name').press('Shift+A');

// <input id=name>
await page.locator('#name').press('Shift+ArrowLeft');

还支持 "Control+o" 或 "Control+Shift+T" 等快捷方式。 当使用修饰符指定时,修饰符将被按下并在按下后续键的同时保持不变。

请注意,你仍然需要在 Shift-A 中指定大写 A 来生成大写字符。 Shift-a 产生一个小写字母,就像你切换了 CapsLock 一样。

7、上传文件

你可以使用 locator.setInputFiles() 方法选择要上传的输入文件。 它期望第一个参数指向类型为 "file" 的 输入元素。 可以在数组中传递多个文件。 如果某些文件路径是相对的,则它们将相对于当前工作目录进行解析。 空数组会清除选定的文件。

// Select one file
await page.getByLabel('Upload file').setInputFiles(path.join(__dirname, 'myfile.pdf'));

// Select multiple files
await page.getByLabel('Upload files').setInputFiles([
  path.join(__dirname, 'file1.txt'),
  path.join(__dirname, 'file2.txt'),
]);

// Remove all the selected files
await page.getByLabel('Upload file').setInputFiles([]);

// Upload buffer from memory
await page.getByLabel('Upload file').setInputFiles({
  name: 'file.txt',
  mimeType: 'text/plain',
  buffer: Buffer.from('this is test')
});

如果你手头没有输入元素(它是动态创建的),你可以处理 page.on('filechooser') 事件或在操作时使用相应的等待方法:

// Start waiting for file chooser before clicking. Note no await.
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByLabel('Upload file').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'myfile.pdf'));

8、焦点元素

对于处理焦点事件的动态页面,你可以使用 locator.focus() 将给定元素聚焦。

await page.getByLabel('Password').focus();

9、拖放

你可以使用 locator.dragTo() 执行拖放操作。 该方法将:

  • 将鼠标悬停在要拖动的元素上。
  • 按鼠标左键。
  • 将鼠标移动到将接收掉落的元素。
  • 释放鼠标左键。

await page.locator('#item-to-be-dragged').dragTo(page.locator('#item-to-drop-at'));

9.1、手动拖动

如果你想精确控制拖动操作,请使用更底层的方法,例如 locator.hover()mouse.down()mouse.move() 和 mouse.up()

await page.locator('#item-to-be-dragged').hover();
await page.mouse.down();
await page.locator('#item-to-drop-at').hover();
await page.mouse.up();

四、断言:

默认情况下,断言超时设置为 5 秒。 了解有关 各种超时 的更多信息。

1、自动重试断言:

以下断言将重试,直到断言通过或达到断言超时。 请注意,重试断言是异步的,因此你必须对它们进行 await

断言描述
await expect(locator).toBeAttached()元素已附加
await expect(locator).toBeChecked()复选框被选中
await expect(locator).toBeDisabled()元素被禁用
await expect(locator).toBeEditable()元素可编辑
await expect(locator).toBeEmpty()容器是空的
await expect(locator).toBeEnabled()元素已启用
await expect(locator).toBeFocused()元素已聚焦
await expect(locator).toBeHidden()元素不可见
await expect(locator).toBeInViewport()元素与视口相交
await expect(locator).toBeVisible()元素可见
await expect(locator).toContainText()元素包含文本
await expect(locator).toHaveAttribute()元素具有 DOM 属性
await expect(locator).toHaveClass()元素具有类属性
await expect(locator).toHaveCount()列表有确切的子级数量
await expect(locator).toHaveCSS()元素具有 CSS 属性
await expect(locator).toHaveId()元素有一个 ID
await expect(locator).toHaveJSProperty()元素具有 JavaScript 属性
await expect(locator).toHaveScreenshot()元素有截图
await expect(locator).toHaveText()元素与文本匹配
await expect(locator).toHaveValue()输入有一个值
await expect(locator).toHaveValues()选择已选择的选项
await expect(page).toHaveScreenshot()页面有截图
await expect(page).toHaveTitle()页面有标题
await expect(page).toHaveURL()页面有一个 URL
await expect(response).toBeOK()响应状态为 OK

2、不重试断言

这些断言允许测试任何条件,但不会自动重试。 大多数时候,网页异步显示信息,并且使用非重试断言可能会导致不稳定的测试。  auto-retrying

尽可能首选自动重试断言。 对于需要重试的更复杂的断言,请使用 expect.poll 或 expect.toPass

断言描述
expect(value).toBe()值是一样的
expect(value).toBeCloseTo()数量大致相等
expect(value).toBeDefined()值不是 undefined
expect(value).toBeFalsy()值是假的,例如 false0null 等
expect(value).toBeGreaterThan()数量超过
expect(value).toBeGreaterThanOrEqual()数量大于或等于
expect(value).toBeInstanceOf()对象是类的实例
expect(value).toBeLessThan()数量小于
expect(value).toBeLessThanOrEqual()数量小于或等于
expect(value).toBeNaN()值为 NaN
expect(value).toBeNull()值为 null
expect(value).toBeTruthy()值是真实的,即不是 false0null 等。
expect(value).toBeUndefined()值为 undefined
expect(value).toContain()字符串包含子字符串
expect(value).toContain()数组或集合包含一个元素
expect(value).toContainEqual()数组或集合包含相似元素
expect(value).toEqual()值相似 - 深度相等和模式匹配
expect(value).toHaveLength()数组或字符串有长度
expect(value).toHaveProperty()对象有一个属性
expect(value).toMatch()字符串与正则表达式匹配
expect(value).toMatchObject()对象包含指定的属性
expect(value).toStrictEqual()值相似,包括属性类型
expect(value).toThrow()函数抛出错误
expect(value).any()匹配类/原语的任何实例
expect(value).anything()匹配任何东西
expect(value).arrayContaining()数组包含特定元素
expect(value).closeTo()数量大致相等
expect(value).objectContaining()对象包含特定属性
expect(value).stringContaining()字符串包含子字符串
expect(value).stringMatching()字符串与正则表达式匹配

3、否定匹配器

一般来说,通过在匹配器前面添加 .not,我们可以预期相反的情况成立:

expect(value).not.toEqual(0);
await expect(locator).not.toContainText('some text');

4、软断言

默认情况下,失败的断言将终止测试执行。 Playwright 还支持软断言: 失败的软断言 不要 终止测试执行,但将测试标记为失败。

// 做一些检查,当测试失败时不会停止。
await expect.soft(page.getByTestId('status')).toHaveText('Success');
await expect.soft(page.getByTestId('eta')).toHaveText('1 day');

// …然后继续测试,检查更多的东西。
await page.getByRole('link', { name: 'next page' }).click();
await expect.soft(page.getByRole('heading', { name: 'Make another order' })).toBeVisible();

在测试执行期间的任何时候,你都可以检查是否存在任何软断言失败:

// Make a few checks that will not stop the test when failed...
await expect.soft(page.getByTestId('status')).toHaveText('Success');
await expect.soft(page.getByTestId('eta')).toHaveText('1 day');

// Avoid running further if there were soft assertion failures.
expect(test.info().errors).toHaveLength(0);

请注意,软断言仅适用于 Playwright 测试运行程序。

5、自定义期望消息

你可以指定自定义错误消息作为 expect 函数的第二个参数,例如:

await expect(page.getByText('Name'), 'should be logged in').toBeVisible();

这同样适用于软断言:

expect.soft(value, 'my soft assertion').toBe(56);

6、expect.configure

你可以创建自己的预配置 expect 实例以拥有自己的默认值,例如 timeout 和 soft

const slowExpect = expect.configure({ timeout: 10000 });
await slowExpect(locator).toHaveText('Submit');

// 总是做soft的断言。
const softExpect = expect.configure({ soft: true });
await softExpect(locator).toHaveText('Submit');

7、expect.poll

你可以使用 expect.poll 将任何同步 expect 转换为异步轮询。

以下方法将轮询给定函数,直到返回 HTTP 状态 200:

await expect.poll(async () => {
  const response = await page.request.get('https://api.example.com');
  return response.status();
}, {
  // 自定义错误消息,可选。
  message: 'make sure API eventually succeeds', // 定制错误消息
  // 投票10秒;默认为5秒。通过0来禁用超时。
  timeout: 10000,
}).toBe(200);

你还可以指定自定义轮询间隔:

await expect.poll(async () => {
  const response = await page.request.get('https://api.example.com');
  return response.status();
}, {
  // Probe, wait 1s, probe, wait 2s, probe, wait 10s, probe, wait 10s, probe
  // ... Defaults to [100, 250, 500, 1000].
  intervals: [1_000, 2_000, 10_000],
  timeout: 60_000
}).toBe(200);

8、expect.toPass

你可以重试代码块,直到它们成功通过。

await expect(async () => {
  const response = await page.request.get('https://api.example.com');
  expect(response.status()).toBe(200);
}).toPass();

 9、断言示例:

import { test, expect } from '@playwright/test';

test('搜索测试', async ({ page }) => {
  // 访问掘金首页
  await page.goto('https://juejin.cn/');

  // 在搜索输入框中输入 Vue 并确认
  await page.getByPlaceholder('探索稀土掘金').click();
  await page.getByPlaceholder('搜索文章/小册/标签/用户').fill('Vue');
  await page.getByRole('search').getByRole('img').click();

  // 默认选中的标签应该是【综合】,并通过类名检查综合标签的选中样式是否符合预期
  const common = page.getByRole('link', { name: '综合', exact: true })
  await expect(common.locator('..')).toHaveClass(/router-link-exact-active/);
  // 检查页面 url,路径:/search,查询参数:query=Vue&type=0
  await expect(page).toHaveURL(/.*\/search\?query=Vue&type=0/);

  // 在搜索结果页面切换类别标签
  const article = page.getByRole('link', { name: '文章', exact: true });
  await article.click();
  await expect(article.locator('..')).toHaveClass(/router-link-exact-active/);
  await expect(page).toHaveURL(/.*\/search\?query=Vue&type=2/);

  const classes = page.getByRole('main').getByRole('link', { name: '课程', exact: true });
  await classes.click();
  await expect(classes.locator('..')).toHaveClass(/router-link-exact-active/);
  await expect(page).toHaveURL(/.*\/search\?query=Vue&type=12/);

  const tags = page.getByRole('link', { name: '标签', exact: true });
  await tags.click();
  await expect(tags.locator('..')).toHaveClass(/router-link-exact-active/);
  await expect(page).toHaveURL(/.*\/search\?query=Vue&type=9/);

  const users = page.getByRole('link', { name: '用户', exact: true });
  await users.click();
  await expect(users.locator('..')).toHaveClass(/router-link-exact-active/);
  await expect(page).toHaveURL(/.*\/search\?query=Vue&type=1/);
});

五、定位器

定位器本身也是一种断言,也能起到等待的效果。当定位器暂时没选取到任何元素时,测试进程也会保持等待与重试,直到选中目标元素或者测试超时、用例失败。

1、选择定位器

locator 方法是定位元素最通用的方法,用法与 jquery 的 $ 方法类似,接收 css 选择器 或 xpath 选择器,返回选中的元素列表。

import { test } from '@playwright/test';

test('test', async ({ page }) => {
  // 选取 class 为 item 的元素
  page.locator('.item')

  // 选取 id 为 app 的 <div></div> 元素
  page.locator('div#app')

  // 支持 xPath 选择器
  // 参考:https://playwright.dev/docs/other-locators#xpath-locator
  // 参考:https://playwright.dev/docs/locators#locate-by-css-or-xpath
  const target = page.locator('xpath=//button');

  // xpath 选取父元素
  target.locator('..')  
})

2、语义定位

在定位 API 中还有一系列通过语义实现定位的方法。

import { test } from '@playwright/test';

test('test', async ({ page }) => {
  /**
   * 通过元素内部的文字定位元素
   * 
   * https://playwright.dev/docs/locators#locate-by-text
   */
  page.getByText('首页')


  /**
   * 通过 alt / title 属性定位元素
   * 
   * https://playwright.dev/docs/locators#locate-by-alt-text
   * 
   * https://playwright.dev/docs/locators#locate-by-title
   * 
   * 例如:
   * 
   * <img src="..." alt="稀土掘金" class="logo-img">
   * 
   * <img src="..." title="稀土掘金" class="logo-img">
   */
  page.getByAltText('稀土掘金');
  page.getByTitle('稀土掘金');

  /**
   * 通过输入框的占位文本定位元素
   * 
   * https://playwright.dev/docs/locators#locate-by-placeholder
   * 
   * 例如:
   * 
   * <input type="search" maxlength="64" placeholder="探索稀土掘金" class="search-input isResourceVisible">
   */
  page.getByPlaceholder('探索稀土掘金');

  /**
   * 通过无障碍技术 ARIA 角色来定位元素
   * 
   * https://playwright.dev/docs/locators#locate-by-role
   */
  page.getByRole('link', { name: '首页' });

  /**
   * 通过约定好的测试 id 定位元素(推荐)
   * 
   * https://playwright.dev/docs/locators#locate-by-test-id
   * 
   * 例如:
   * 
   * <button data-testid="submit-btn">Button</button>
   */
  page.getByTestId('submit-btn');  
});

 语义定位比起选择器定位在使用上要简便一些,并且由于选择的依据更偏向于业务属性,不容易因为页面 DOM 结构的调整而导致定位失效。

其中,Locator.getByTestId 用起来比较方便,这种方式需要测试人员与前端开发人员沟通,为需要测试的元素加上 data-testid 属性,这样 Locator.getByTestId 就可以唯一地选中我们期望的对象。这种方式增加了前端人员与测试人员的沟通成本,但好处是不言而喻的。

3、设置自定义测试 id 属性

配置:

// playwright.config.ts 
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    testIdAttribute: 'data-pw'
  }
});

 定义:

<button data-pw="directions">Itinéraire</button>

 使用:

await page.getByTestId('directions').click(); 

六、前/后置操作钩子

前/后置操作钩子 前/后置操作钩子一般用于处理测试用例的公共逻辑,或设置用例之间共享的资源。

  • test.beforeEach 每个测试用例执行前触发
  • test.afterEach 每个测试用例执行后触发
  • test.beforeAll 所有测试用例执行前触发
  • test.afterAll 所有测试用例执行完触发
import { test } from '@playwright/test';

// 全局钩子对每一个用例都生效
test.beforeEach(async ({ page }) => {
  // 没个用例执行前先跳转到起始 url
  await page.goto('https://juejin.cn/');
});

test.describe('group A', () => {
  // 分组内的钩子只对当前分组中的用例生效
  test.beforeEach(async ({ page }) => {
    // ...
  });

  test('my test', async ({ page }) => {
    // ...
  });

  // 在 my test 用例完成后执行,可以用来删除测试遗留数据
  test.afterEach(async ({ page }) => {
  // ...
  });
});

七、案例

1、使用codegen,--save记住登录信息,下次直接使用load的时候可以直接跳过登录

存储登录信息

npx playwright codegen --save-storage=auth.json http://localhost:4000

使用登录信息,直接跳过登录页面

npx playwright codegen --load-storage=auth.json?http://localhost:4000

执行第一条--save命令的时候,你输入的登录信息,会记录在项目更目录下的auto.json文件中;

退出,再次输入第二条命令,会直接跳过登录;

2、登录测试鉴权

很多系统的功能都需要先完成用户登录后才能够使用。因此在端到端测试中,处理用户登录是一个经典的问题。

首先在 playwright.config.ts 中,定义一个新的测试工程 setup,专门用于登录鉴权的初始化。其他正式的测试工程都必须在其后执行。

规定 .auth 目录存放浏览器缓存,setup 工程将登录后的浏览器缓存往其中写入,正式测试工程执行前将从中读出缓存。

playwright配置根目录:

// playwright.config.ts
baseURL: 'http://localhost:4000',
// playwright.config.ts
export default defineConfig({
  // ...
  projects: [
+   // setup 工程只执行 tests 目录下以 .setup.ts 结尾的文件。在所有正式测试执行前先完成鉴权初始化
+   { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
-     use: { ...devices['Desktop Chrome'] },
+     use: {
+       ...devices['Desktop Chrome'],
+       // setup 完成鉴权后,浏览器缓存状态会保存在此,正式的测试工程在执行前通过此文件恢复浏览器缓存,进而获取了用户登录态
+       storageState: '.auth/user.json',
+     },
+     // 必须在 setup 完成鉴权后执行
+     dependencies: ['setup'],
    },
    {
      name: 'firefox',
-     use: { ...devices['Desktop Firefox'] },
+     use: {
+       ...devices['Desktop Firefox'],
+       storageState: '.auth/user.json',
+     },
+     dependencies: ['setup'],
    },
    {
      name: 'webkit',
-     use: { ...devices['Desktop Safari'] },
+     use: {
+       ...devices['Desktop Safari'],
+       storageState: '.auth/user.json',
+     },
+     dependencies: ['setup'],
    },
  ]
});

.auth 目录不应该入仓,应补充到 .gitignore 中。

# .gitignore
# ...
+/.auth

 登录文件配置:也可以采用env来配置账号密码这些

// auth.setup.ts
import { test as setup, expect } from '@playwright/test';

const authFile = '.auth/user.json';

setup('authenticate', async ({ page }) => {
  // Perform authentication steps. Replace these actions with your own.
  await page.goto('#/login');
  await page.getByPlaceholder('Please enter participant code').fill('CP004');
  await page.getByPlaceholder('Please enter Username').fill('admin');
  await page.getByPlaceholder('Please enter your password').fill('Aa@123456');
  await page.getByRole('button', { name: 'Submit' }).click();
  // Wait until the page receives the cookies.
  //
  // Sometimes login flow sets cookies in the process of several redirects.
  // Wait for the final URL to ensure that the cookies are actually set.
  await page.waitForURL('#/');
  // Alternatively, you can wait until the page reaches a state where all cookies are set.
  // await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible();

  // End of authentication steps.

  await page.context().storageState({ path: authFile });
});

配置好了之后,就可以写其它测试案例了,会自动执行登录功能;

3、API接口测试

import { test } from '@playwright/test';

test('接口检查', async ({ page }) => {
  // 访问掘金首页
  await page.goto('https://juejin.cn/');

  const promise = page.waitForResponse(
    (res) => res.url().includes('/user_api/v1/author/recommend'));

  const res = await promise;
  const data = await res.json();
  expect(data.err_no).toBe(0);
})

4、CM系统用户管理的增删查改功能测试案例

配置好鉴权、启动CM系统本地服务器,然后就可以执行这个案例了;

import { test,expect,Page } from '@playwright/test';

// 获取请求数据的方法
const getDataList = async (page:Page) => {
  const promise = await page.waitForResponse((res) => {
    return res.url().includes('/user/list');
  });
  const res = await promise;
  const data = await res.json();
  return data.list.length;
}

test.describe('新增用户表单测试', () => {
  let startLength = 0, endLength = 0;
  test.beforeEach(async ({ page }) => {
    test.setTimeout(120000);
    await page.goto('#/');
    await page.locator('li').filter({ hasText: 'Administration' }).locator('i').first().click();
    await page.getByText('User Management').click();
    startLength = await getDataList(page);
  });

  test('不录入内容', async ({page}) => {
    await page.getByRole('button', { name: 'Create User' }).click();
    await page.getByRole('button', { name: 'Create', exact: true }).click();
    await expect(await page.getByText('Please enter your password')).toBeVisible();
    await expect(await page.getByText('Please select user role')).toBeVisible();
    await expect(await page.getByText('Please enter Username')).toBeVisible();
    // await page.getByRole('button', { name: 'Cancel' }).click();
  })

  test('录入密码不符合规范', async ({page}) => {
    await page.getByRole('button', { name: 'Create User' }).click();
    await page.getByPlaceholder('Please enter your password').fill('Aa');
    await page.getByRole('button', { name: 'Create', exact: true }).click();
    await expect(await page.getByText('Minimum length of 8')).toBeVisible();
    // await page.getByRole('button', { name: 'Cancel' }).click();
  })

  test('正确的录入,并新增用户', async ({page}) => {
    await page.getByRole('button', { name: 'Create User' }).click();
    await page.getByPlaceholder('Please enter your password').fill('Aa@123456');
    await page.getByPlaceholder('Please select a role').click();
    await page.getByRole('tooltip', { name: 'user' }).getByRole('listitem').click();
    await page.getByRole('button', { name: 'Create', exact: true }).click();
    await page.getByRole('button', { name: 'OK' }).click();
  
    endLength = await getDataList(page);
    console.log(2,'endLength',endLength)
    expect(endLength - startLength).toBe(1);
  })

  test('删除用户', async ({ page }) => {
    await page.getByRole('button', { name: 'Delete' }).first().click();
    await page.getByRole('button', { name: 'Yes' }).click();
    endLength = await getDataList(page);
    expect(endLength - startLength).toBe(-1);
  });

  test('修改用户密码', async ({ page }) => {
    await page.getByRole('button', { name: 'Reset password' }).first().click();
  
    await page.getByPlaceholder('Please enter your password').fill('Aa@1234567');
    await page.getByPlaceholder('Please enter Confirm Password').fill('Aa@1234567');
    await page.getByLabel('Reset Password').getByRole('button', { name: 'Reset password' }).click();
  });

})




5、组件测试

5.1、elememt-ui Select组件测试

// components/Components.ts
import { Page, Locator } from '@playwright/test';

export class Component {
  protected page: Page;

  protected locator: Locator;

  /**
   * @param page 组件所在的页面对象 
   * @param locator 如何定位组件的根元素,传入字符串通过 getByTestId 获取,或者直接传入一个 locator
   */
  constructor(page: Page, locator: Locator | string = '') {
    this.page = page;
    if (typeof locator === 'string') {
      this.locator = locator ? page.getByTestId(locator) : page.locator('body');
    } else {
      this.locator = locator ? page.locator('body').locator(locator) : page.locator('body');
    }
  }
}

接着建立 Select 类继承组件基类,正式处理下拉多选框的测试逻辑。这里用到了相对灵活的 Locate.evaluate 方法获取页面元素的实例对象,为下拉框(后续要被移动到 body 下)提前设置 testid,方便之后的定位。

// components/Select.ts
import { Locator, expect } from '@playwright/test';
import { Component } from './Component';

export class Select extends Component {
  /** 记录已选中的选项 */
  selected = new Set<string>();

  /** 用当前日期生成下拉框的 testid */
  popupId = new Date().getTime().toString();

  /** 是否激活下拉框 */
  private isPopupInitialized = false;

  /** 选择框主体,用于触发点击交互 */
  get el() {
    return this.locator.getByRole('textbox');
  }

  /**
   * 激活下拉框,并且遍历指定的 option 选项
   * @param selections 每个选项的文字
   * @param callback 对每个遍历到的选项的处理方式
   */
  private async traceSelections(
    selections: string[],
    callback: (data: SelectionData) => Promise<void>,
  ) {
    if (!this.isPopupInitialized) {
      /**
       * 下拉框还未激活时,处于组件根元素内部,第一次点击激活后才会被移动到 body 中。
       * 
       * 在此之前先完成定位,并通过 evaluate 方法提前给下拉框设置 testid,方便之后定位
       * 
       * evaluate 方法说明:https://playwright.dev/docs/api/class-locator#locator-evaluate
       */
      await this.locator.locator('.el-select-dropdown').evaluate((node, id) => {
        node.dataset.testid = id;
      }, this.popupId);
      this.isPopupInitialized = true;
    }

    // 点击激活下拉框
    await this.el.click();

    // 用设置好的 testid 可以轻松定位下拉框
    const popover = this.page.getByTestId(this.popupId);

    for (let i = 0; i < selections.length; i++) {
      const target = popover.getByText(selections[i], { exact: true });
      const text = await target.textContent();

      if (!text) {
        throw new Error('Selection has not text content!');
      }

      // 定位到文字的父元素,即容器元素,容器元素上有各种类名,可以用来判断选项的状态
      const wrapper = target.locator('..');

      const isSelected = this.selected.has(text);
      const classNames = await wrapper.getAttribute('class');

      // 容器元素有 is-disabled 类代表不可选
      const isDisabled = classNames ? classNames.includes('is-disabled') : false;

      // 执行自定义处理方法
      await callback({
        target,
        wrapper,
        text,
        isSelected,
        isDisabled,
      });
    }

    await this.el.blur();
  }

  /** 选中目标选项 */
  async checkSelections(...selections: string[]) {
    await this.traceSelections(selections, async ({
      target, wrapper, text, isSelected, isDisabled,
    }) => {
      if (!isSelected) {
        await target.click();
        this.selected.add(text);
      }
      if (isDisabled) {
        throw new Error('Option is disabled!');
      }
      // 通过容器是否具有 selected 类,判断是否成功选中选项
      await expect(wrapper).toHaveClass(/selected/);
    });
  }

  /** 取消目标选项 */
  async uncheckSelections(...selections: string[]) {
    await this.traceSelections(selections, async ({
      target, wrapper, text, isSelected, isDisabled,
    }) => {
      if (isSelected) {
        await target.click();
        this.selected.delete(text);
      }
      if (isDisabled) {
        throw new Error('Option is disabled!');
      }
      await expect(wrapper).not.toHaveClass(/selected/);
    });
  }
}

interface SelectionData {
  target: Locator;
  wrapper: Locator;
  text: string;
  isSelected: boolean;
  isDisabled: boolean;
}

完成封装后,我们建立 tests/element.spec.ts 文件验证一下。

// tests/element.spec.ts
import { test } from '@playwright/test';
import { Select } from '../components/Select'

test('测试多选框', async ({ page }) => {
  await page.goto('https://element.eleme.cn/#/zh-CN/component/select', { waitUntil: 'domcontentloaded' });

  const target = page.locator('.el-select').nth(4);
  const select = new Select(page, target);

  await select.checkSelections('黄金糕', '双皮奶');
})

5.2、测试element ui表单功能

// components/Input.ts
import { Component } from './Component';

export class Input extends Component {
  /** 定位 el-input 组件中的 <input> 原生元素 */
  get el() {
    return this.locator.getByRole('textbox');
  }

  /** 输入内容 */
  async input(value: string) {
    await this.el.fill(value);
  }
}
// components/FormItem.ts
import { expect, Page, Locator } from '@playwright/test';
import { Component } from './Component';

export class FormItem<T extends Component> extends Component {
  /** form-item 内部的输入元素,可以为任意输入组件 */
  target: T;

  constructor(
    InputConstructor: typeof Component,
    page: Page,
    locator: Locator | string = '',
  ) {
    super(page, locator);
    this.target = new InputConstructor(page, locator) as T;
  }

  /** 定位到 el-form-item 的校验对象 */
  get errorEl() {
    return this.locator.locator('.el-form-item__error');
  }

  /** el-form-item 校验成功 */
  async checkSuccess() {
    await expect(this.locator).toHaveClass(/is-success/);
  }

  /** el-form-item 校验失败 */
  async checkError(errTxt: RegExp | string = '') {
    await expect(this.locator).toHaveClass(/is-error/);
    if (errTxt) {
      await expect(this.errorEl).toContainText(errTxt);
    }
  }
}
// components/index.ts
export * from './Component';
export * from './Input';
export * from './Select';
export * from './FormItem';

接着我们创建 views 目录,存放对页面的封装。同样要注意在 tsconfig.json 中的 include 字段中补充此目录。

与组件相同,我们定义页面的基类,对页面测试共有的逻辑进行封装。因为页面本身是复杂一些的组件,因此继承了组件类。

// views/View.ts
import { Page } from '@playwright/test';
import { Component } from '../components';

export class View extends Component {
  protected url: string;

  constructor(page: Page, url: string) {
    super(page);
    this.url = url;
  }

  /** 页面的访问操作时公共行为,适合放在基类中 */
  async visit() {
    await this.page.goto(this.url, { waitUntil: 'domcontentloaded' });
  }
}
// views/LoginView.ts
import { Page } from '@playwright/test';
import { Input, FormItem } from '../components';
import { View } from './View'


export class LoginView extends View {
  /** 页面的第五个案例作为我们的测试区域 */
  get root() {
    return this.page.locator('.source').nth(4)
  }

  /** 获取区域内所有的表单项  */
  get formItems() {
    return this.root.locator('.el-form-item');
  }

  /** 页面由组件组成 */
  pwd = new FormItem<Input>(Input, this.page, this.formItems.nth(0));
  confirmPwd = new FormItem<Input>(Input, this.page, this.formItems.nth(1));
  age = new FormItem<Input>(Input, this.page, this.formItems.nth(2));
  submitBtn = this.root.getByRole('button', { name: '提交' });
  resetBtn = this.root.getByRole('button', { name: '重置' });

  constructor(page: Page) {
    super(page, 'https://element.eleme.cn/#/zh-CN/component/form');
  }

  /** 表单输入操作 */
  async input(
    pwd: string = '',
    confirmPwd: string = '',
    age: string = '',
  ) {
    await this.pwd.target.input(pwd);
    await this.confirmPwd.target.input(confirmPwd);
    await this.age.target.input(age);
  }

  /** 表单输入并提交 */
  async inputAndSubmit(
    pwd: string = '',
    confirmPwd: string = '',
    age: string = '',
  ) {
    await this.input(pwd, confirmPwd, age);
    await this.submitBtn.click();
  }
}


// views/index.ts
export * from './View';
export * from './LoginView';
// views/index.ts
export * from './View';
export * from './LoginView';

最后,在 tests/element.spec.ts 里面补充用例代码,可以看到 inputAndSubmit 方法高频被复用,代码非常简洁,语义也比较清晰。

// tests/element.spec.ts
import { test, expect } from '@playwright/test';
import { LoginView } from '../views';

// 省略前面的无关用例

// 新增用例
test.describe('测试表单', () => {
  let view: LoginView

  test.beforeEach(async ({ page }) => {
    view = new LoginView(page);
    await view.visit();
  });

  test('不同的密码', async () => {
    await view.inputAndSubmit('123', '1234', '19');
    await view.confirmPwd.checkError(/密码不一致/);
  })

  test('年龄不满18岁', async () => {
    await view.inputAndSubmit('123', '123', '15');
    await view.age.checkError(/18岁/);
  })

  test('存在未填项', async () => {
    await view.inputAndSubmit('', '', '19');
    await view.pwd.checkError(/输入密码/);
    await view.confirmPwd.checkError(/输入密码/);
  })

  test('输入内容重置', async () => {
    await view.input('123', '123', '19');
    await view.resetBtn.click();
    await expect(view.pwd.target.el).toHaveValue('');
    await expect(view.confirmPwd.target.el).toHaveValue('');
    await expect(view.age.target.el).toHaveValue('');
  })

  test('成功输入', async () => {
    await view.inputAndSubmit('123', '123', '19');
    await view.pwd.checkSuccess();
    await view.confirmPwd.checkSuccess();
    await view.age.checkSuccess();
  })
})

创建一个vite+vue3的项目

pnpm create vite

按照playwright
pnpm create playwright@latest -- --ct

显示测试报告

 pnpm exec playwright show-report

请通过运行 `npm i --save-dev @playwright/test` 安装 Playwright Test
快捷键如何打开?

主动更新快照(好像作用不大)

pnpm test-ct --update-snapshots  

打开报告
pnpm test-ct --trace on

自动记录用户在页面的操作,生成自动测试案例(缺点:只有操作,没有验证)

http://localhost:5175/:当前要记录的浏览器所在页面的地址(根据实际情况变化)

npx playwhite codegen http://localhost:3333

存储登录信息

npx playwright codegen --save-storage=auth.json http://localhost:3333

使用登录信息,直接跳过登录页面

npx playwright codegen --load-storage=auth.json?http://localhost:3333

 

;