使用 Playwright 搭建前端自动化测试工程 (下)

本文的操作需要在上篇建立的测试工程的基础上执行,上篇回顾:使用 Playwright 搭建前端自动化测试工程 (上)

如何编写测试用例也是一个问题,如果各位曾实践过前端 E2E 测试的话,应该会对以下问题有所体会:

  • 难以唯一定位到一个 DOM 元素。
  • 测试用例有大量的重复代码。
  • 做某些断言的时机难以掌握,太早了相关数据还未准备好,太晚了(使用 sleep)影响测试效率。
  • 写好了测试用例,但是页面修改导致用例失效。
  • 前端 E2E 测试用例不是简单的脚本,编写困难,且需要反复调试,认真设计其工作量不亚于开发页面。

我个人在去年用 Cypress 搭建自动化测试时,因为这些困扰而最终弃坑。本次使用 Playwright 重建 UI 测试体系时仍然遇到了类似的困扰。

这些问题,有的因为 Playwright 的易用性更强而得以解决,有些需要我们积累更多的经验、加深对工具使用的理解,但是更多的需要从哲学的角度去思考取舍与 trade-off。

目前业界对于是否应该做 UI 自动化测试也在争论不休。对于哪些业务适合做这种测试,大部分人持有这样的观点:

  • 业务的维护周期足够长。
  • 业务的需求不会频繁地、破坏性地变动。
  • UI 与交互已经进入稳定的阶段。
  • 回归测试任务繁多。

我做测试的动机是项目组没有测试人员,开发同学在本职工作足够忙碌的情况下,没有足够的心力保证每次版本发布后的功能回归测试,导致线上总是出现低级错误。我需要基本的防护网来兜底,尽早发现错误。

我只是一个前端开发,并不是专业的测试人员,因此本文的重点不会放在方法论的陈述上,而关注测试用例的具体编写实践。在我们的项目中,为了应对不同的现实情况,会有三种不同“规格”的测试用例并存。下面我将给大家介绍在 Playwright 框架下的三种不同“规格”的用例编写,在过程中穿插对 Playwright 功能的讲解。

低规格:自动生成测试代码

测试代码生成器Playwright 的一个主要卖点,生成器会根据我们在浏览器中的交互与操作,自动生成对应的测试代码。

使用代码生成器能够以极小的时间成本生成测试用例,但是这样的用例也存在着缺点:

  • 对页面元素的定位不够精准,难以适应 DOM 结构的修改。(使用 testid 可以大幅改善)
  • 生成的代码只有元素定位与交互操作的部分,只能证明“流程可以跑通”,但是由于缺少断言,无法确保“流程正确”。
  • 生成的代码重复度很高,不利于后期的修改调整,往往只能推倒重来。

我们会在以下场景中使用这种方式生成代码:

  • 补充存量业务用例时,当前迭代周期交付压力大,编写测试用例的时间不足,至少能够以这种方式实现“从无到有”。
  • 新开发的特性,后续存在着大幅修改和迭代的可能,但至少要确保业务能够跑通。

这种低规格的用例作为一种临时方案,在时间充裕或者业务稳定后,会被更加精细化的用例逐步替代。

通过以下命令能够启动测试代码生成器:

npx playwright codegen

上篇 中我们解决了登录鉴权问题,在确保成功执行过 setup 工程的情况下,通过命令 npx playwright codegen --load-storage=.auth/user.json 启动,可以加载保存在本地的浏览器缓存,从而在完成登录鉴权的状态下生成测试代码。我们在工程中的 package.json 中声明这条命令,之后通过 pnpm run codegen 启动代码生成器。

{
  // ...
  "scripts": {
    // ...
    "codegen": "playwright codegen --load-storage=.auth/user.json"
  },
}

在浏览器中我们执行的任意操作都会在右侧的编辑器中生成代码。新建 tests/juejin.spec.ts,将生成的代码复制进文件,运行 npx playwright test --ui 并在操作界面中执行刚生成的测试文件。

动画.gif

// 自动生成的代码
import { test, expect } from '@playwright/test';






test.use({
  storageState: '.auth/user.json'
});



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




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



  // 在搜索结果页面切换类别标签
  await page.getByRole('link', { name: '文章', exact: true }).click();
  await page.getByRole('main').getByRole('link', { name: '课程' }).click();
  await page.getByRole('link', { name: '标签' }).click();
  await page.getByRole('link', { name: '用户' }).click();
});

2023-06-15-10-15-58.png

这样我们就顺利地通过代码生成器实现了一个测试用例,这个用例验证了以下操作流程:

  • 在搜索框内输入文字,点击搜索能够正确跳转到搜索结果页。
  • 搜索结果页具有文章、课程、标签、用户四个分类标签。(注意:只证明了标签在搜索结果页存在,但是没有验证标签是否能正确跳转)

中规格:通过 API 手写测试用例

为了弥补自动生成测试代码的种种缺陷,对于重要性更高的业务,在时间相对充裕的时候,我们还是要回归 Playwright 的 API,对自动生成的测试用例加以补充,使其更加精准可靠。

  • 使用测试 API,更好地组织测试用例。
  • 使用定位 API,让元素定位更加精准,甚至能够适应一定程度的页面修改。
  • 使用断言 API,在流程跑通的基础上,增加正确性的校验。
  • 使用等待 API,处理难以把握的断言时机。

测试 API

Playwright 提供的 test 方法用于声明测试用例,这个方法上也挂载了丰富的钩子,用于控制测试进程。完整用法需要阅读官方的 API 文档

测试分组
通过 test.describe 方法,可以对逻辑相近的测试用例进行分组。

test.describe('提交表单', () => {
  test('A 类型表单提交', async ({ page }) => {
    // ...
  });


  test('B 类型表单提交', async ({ page }) => {
    // ...
  });
});

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

  • 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 }) => {
  // ...
  });
});

定位 API

学习或测试 Playwright 页面定位的最佳方式就是打开代码生成器,在浏览器界面按 F12 打开控制台,window.playwright 对象上挂了所有的定位 API,直接在控制台调用 API 就可以直观地获得输出结果。

2023-06-15-15-22-47.png

2023-06-15-15-24-06.png

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

选择器定位

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('..')  
})

更多的选择器定位方法请参考:其他定位方式

语义定位

在定位 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 就可以唯一地选中我们期望的对象。这种方式增加了前端人员与测试人员的沟通成本,但好处是不言而喻的。

纯选择器定位难以抵挡 DOM 结构或者页面布局的调整,非常脆弱;其他语义定位虽然不依赖于 DOM 结构,但是业务层面的修改依然会导致用例失效。当下,国内大部分 Web 应用都不重视 H5 的语义化,无障碍技术的理念还没有深入人心,大部分语义定位使用起来依然困难。而 testid 方案既能唯一精准地定位到目标元素,也能无视页面布局的调整,抵抗大部分的业务调整,即使出现了大幅度重构,这个 data-testid 属性也能够清晰地向开发人员表明:这是一个关键测试元素,请评估是否修改或调整测试用例。

断言 API

通常断言

如果你熟悉 JestVitest 等单元测试框架,那么对这类断言应该是在了解不过了。

// 值断言
expect(2).toBe(2);





// 对象比较断言
expect({ code: 0 }).toEqual({ code: 0 })









// 错误断言
expect(() => {
  throw new Error('Something bad');
}).toThrow(/something/);

同样地,可以同过 .not 实现否定断言。否定断言也可以应用于其他断言类型。

expect(1).not.toBe(2);

在 UI 测试中,这类对值的校验除了检查接口返回的数据,出现频率不高。页面元素的状态才是我们更加关心的。

页面断言

除了通常断言以外,其他所有复杂的断言类型都是异步任务。我们可以观察 .d.ts 声明中这个 API 的返回值,凡是返回 Promise<XXX> 的,都是异步任务,不要忘记通过 async/await 进行调用。

2023-06-15-19-04-33.png

// 检查页面标题是否为 稀土掘金
await expect(page).toHaveTitle('稀土掘金');





// 检查页面 URL 是否包含 /login
await expect(page).toHaveURL(/.*\/login/);

定位器断言

定位器断言可以对定位 API 选中的元素的状态进行检验,它是测试用例中具有最高的使用频率。

const headerElement = page.getByTestId('my-header')
// 检查标题元素是否包含类名:title
await expect(headerElement).toHaveClass(/title/);
// 检查标题是否为:Title
await expect(headerElement).toContainText(/Title/);









const inputElement = page.getByRole('textbox');
// 检查输入框是否聚焦
await expect(inputElement).toBeFocused();
// 检查输入框的值是否为纯数字
await expect(inputElement).toHaveValue(/[0-9]+/);

除了以上高频断言外,还有一些其他类型的断言,读者可以自行阅读官方文档:

学习了各种断言后,我们可以在先前生成器实现的 tests/juejun.spec.ts 基础上补充一些断言。

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/);
});

增加了断言后,我们的用例不再只是过一遍流程,对于正确性的检查也严谨了许多。

2023-06-15-20-32-54.png

等待 API

有的时候,我们需要在页面的某个事件发生后,或者某个网络请求返回结果后,再进行断言,这就需要进行等待。

Playwright 官方文档旗帜鲜明地反对 sleep(time) 这种等待固定时间的写法,他们甚至将 Page.waitForTimeout 方法标记为废弃(deprecate)。目前官方推荐使用的等待 API 可以前往文档查看:Page.waitForxxx

在实际应用中,最常见的场景是等待网络请求返回数据(waitForResponse)。下面给出一个检查掘金首页 /user_api/v1/author/recommend 接口的例子。

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);
})

高规格:设计测试对象状态机

E2E 测试的用例代码往往比单元测试要复杂得多。单元测试通常只关心函数的输入与输出,但是 E2E 测试需要模拟用户的实际操作,具有更长的过程链条。E2E 测试的用例之间也容易呈现出共有逻辑多,分支逻辑少的特点。

当业务重要性高,流程复杂度高,涉及的测试用例数量多时,我们便不再能忍受相同内容的定位器(page.locate(...))、断言器(expect(...))的反复出现,希望能够做一些封装,消除重复代码,使测试代码的可靠性、可维护性、可读性更强。

如何测试组件

一些基于现代前端框架的组件,如 Ant Designelement-ui 等,它们是对一系列 HTML 元素的封装,内部实现比较复杂(有些组件甚至采用非标准实现,例如用 div 模拟 input),往往难以测试。

在调用组件时,代码是这样的(代码案例来自 element-ui Select 基础多选):

<el-select v-model="value1" multiple placeholder="请选择">
  <el-option
    v-for="item in options"
    :key="item.value"
    :label="item.label"
    :value="item.value">
  </el-option>
</el-select>

而实际生成的内容却是这样的

<!-- el-select 本体 -->
<div class="el-select">
  <div class="el-select__tags" style="width: 100%; max-width: 208px;">
    <span>
      <span class="el-tag el-tag--info el-tag--small el-tag--light">
        <span class="el-select__tags-text">双皮奶</span>
        <i class="el-tag__close el-icon-close"></i>
      </span>
      <span class="el-tag el-tag--info el-tag--small el-tag--light">
        <span class="el-select__tags-text">黄金糕</span>
        <i class="el-tag__close el-icon-close"></i>
      </span>
    </span>
  </div>
  <div class="el-input el-input--suffix">
    <input type="text" readonly="readonly" autocomplete="off" placeholder="" class="el-input__inner" style="height: 40px;">
    <span class="el-input__suffix">
      <span class="el-input__suffix-inner">
        <i class="el-select__caret el-input__icon el-icon-arrow-up"></i>
      </span>
    </span>
  </div>
</div>


<!-- 这部分下拉弹框被动态添加到了 <body> 下 -->
<div
  class="el-select-dropdown el-popper is-multiple"
  style="min-width: 240px; transform-origin: center top; z-index: 2009; position: absolute; top: 463px; left: 301px;"
  x-placement="bottom-start">
  <div class="el-scrollbar" style="">
    <div class="el-select-dropdown__wrap el-scrollbar__wrap" style="margin-bottom: -17px; margin-right: -17px;">
      <ul class="el-scrollbar__view el-select-dropdown__list">
        <li class="el-select-dropdown__item selected hover">
          <span>黄金糕</span>
        </li>
        <li class="el-select-dropdown__item selected">
          <span>双皮奶</span>
        </li>
        <li class="el-select-dropdown__item">
          <span>蚵仔煎</span>
        </li>
        <li class="el-select-dropdown__item">
          <span>龙须面</span>
        </li>
        <li class="el-select-dropdown__item">
          <span>北京烤鸭</span>
        </li>
      </ul>
    </div>
    <div class="el-scrollbar__bar is-horizontal">
      <div class="el-scrollbar__thumb" style="transform: translateX(0%);"></div>
    </div>
    <div class="el-scrollbar__bar is-vertical">
      <div class="el-scrollbar__thumb" style="transform: translateY(0%);"></div>
    </div>
  </div>
  <div x-arrow="" class="popper__arrow" style="left: 35px;"></div>
</div>

即使我们给组件打上 data-testid 标记,通过 page.getByTestId 也只能定位到最外层的 <div>,如果要模拟其功能,必然需要进一步使用 locator 进行深层定位,如何定位动态的 DOM 更是困难。对于这种复杂但是稳定(基础组件的逻辑是不变的)的特性,是值得专门设计代码封装测试行为的。

我们选取上面例子中提到的 elememt-ui 多选 Select,用代码说明如何封装组件测试逻辑。

animate-select.gif

在测试工程中建立 components 目录,用于存放对组件的封装。注意在 tsconfig.json 中的 include 字段中补充此目录。

首先定义测试组件基类 Component,处理页面对象保存、组件根元素定位保存的公共逻辑。

// 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('黄金糕', '双皮奶');
})

2023-06-16-20-01-13.png

封装页面测试行为

一个页面的多个测试用例可能包含同样的操作。例如提交表单,假如要测试不同的提交的方式,甚至包括验证失败的场景,那么填写某个表单项这一行为就会在多个用例中出现。

我们可以把页面看做一个复杂一些的组件,页面的内部由多个组件构成,每一项测试行为都视为几个组件行为的组合。

下面我们以 element-ui Form 自定义校验规则 作为例子,将这个简单的表单看做一个页面,用实际的代码展示如何设计页面的测试代码。

animate-form.gif

首先我们补充一下 InputFormItem 这两个组件的封装。随着组件数量的增多,我们还需要添加 components/index.ts 文件作为组件导出的索引。

// 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' });
  }


}

之后声明 LoginView 类继承页面基类,一方面将需要被测试的组件定义为类的成员,另一方面将高频测试操作封装为公用方法。这里我们对表单填写、表单提交两个常见操作进行了封装。

// 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';

最后,在 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();
  })
})

2023-06-17-17-50-06.png

其他测试要点

善用 test.afterEach 销毁遗留数据

有些测试行为是会在系统重产生遗留数据的,对生产环境做回归测试的时候,我们需要这些临时数据被及时清除。test.afterEach 钩子就适合做这样的操作。受限于条件,下面的例子是伪代码说明。

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







test.describe('测试表单', () => {
  let view: FormView
  let formId: number | null = null;









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



  test('表单提交A', async () => {
    await view.input({
      title: '111',
      content: '222',
      tags: ['A', 'B']
    })



    // 提交表单成功时会生成新的 id,将其保存下来
    formId = await view.submit();
  })


  test('表单提交B', async () => {
    await view.input({
      title: '222',
      content: '444',
      desc: '666'
    })

    formId = await view.submit();
  })


  test.afterEach(async ({ request }) => {
    if (formId) {
      // formId 不为空代表新建了测试数据,调用删除接口及时删除新产生的数据
      const res = await request.post(`/delete/${formId}`);
    }
  })
})

page.gotoload 事件

这里提及一个在实际测试中容易遇到的问题。大家可能注意到,我在使用 page.goto 方法时,总是使用 waitUntil: 'domcontentloaded' 的选项,这是因为默认情况下,goto 方法通过页面的 load 事件判定访问成功,而 load 事件的触发是要求页面中所有资源加载完成的,而 domcontentloaded 只要求 HTML 文档完成解析。

很多时候自动化测试是在 CI 执行机上运行,CI 执行机对网页中的某些资源存在网络不通的问题时,就会导致 load 事件迟迟不触发,最终测试超时失败。大家在测试的时候需要注意这个细节,根据实际情况选择给 page.goto 方法加上 waitUntil: 'domcontentloaded',还是致力于去解决执行机的网络问题。

DOMContentLoaded和Load的区别

直观演示实例

更多可能性

Playwright 的使用潜力远不止于此。我们的测试程序可以实现更高度的自动化,官方为其接入 CI 系统提供了充分的支持。另外,由于 Playwright 基于浏览器运行,其模拟用户行为的特点,也非常适合用来做爬虫程序。

持续集成

自动化测试的最终归属还是要与 CI / CD 系统集成,实现完全的自动化:回归测试自动化、报告归档自动化。

Docker 这篇官方文档介绍了如何在容器中执行 Playwright 测试,并且提供了预装好所有测试依赖的容器。

持续集成 这篇官方文档介绍了如何使用 Github Action 以及其他常见的 CI 系统,自动执行 Playwright 测试任务。

由于我的工作环境中,代码托管和 CI 系统都是公司内特有的,我参考这些文档专门定制了一套落地方案,因为不具有普适性,所以就不做详细说明了,这里只是描述一下大体思路:

  • 前端代码仓每次合并代码后,触发流水线执行对测试环境的测试。
  • 每日定时执行测试流水线进行回归测试。
  • 测试流水线执行失败会给相关人员推送告警消息。
  • 测试流水线无论成功失败都自动将报告部署到一个静态文件服务中。
  • 在一个 VitePress 搭建的文档代码仓中,定时执行归档测试报告的流水线(获取静态文件中部署的报告,自动生成 md 文档并更新)。

2023-06-18-16-02-22.png

爬虫工具

Playwright 应该是现代爬虫工具的不二选择:

  • 模拟用户行为访问目标网站。目标网站即使不是服务端渲染,内容通过 Vue 等现代前端框架动态生成,也能顺利读取到网页内容。
  • Locate.evaluate 方法可以获取 Dom 节点的信息。
  • Page.waitForResponse 方法可以拦截页面中网络请求,并获取数据。

总结

Playwright 介绍的下半部分,我从如何写测试用例的角度分享了使用心得,希望能够对大家有所帮助。

回过头来看前面的内容,可以发现即使有了 Playwright 这样优秀的工具,UI 自动化测试的痛点还是成本高昂,使用自动生成的代码就难以顾及测试的可靠性,而设计可靠的测试代码要求测试工程师对前端也有相当多的了解。让前端工程师来写测试用例吗?可能在当下的环境,大部分前端工程师不认为这是一个投入产出比足够高的方向。

除了经验的分享,我在这里也抛出了自己的困惑,欢迎在自动化测试方面更有经验的朋友能指出我的不足,帮助我打开新的思路。

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYpyOSlU' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片