前端E2E自动化测试开发——基于Playwright

3,610 阅读11分钟

平时我们写完代码都会写单测来测试代码的可靠性,但是单测只能测试某个函数或者某个组件的可靠性,但是当全部的组件和函数组合成一个网页的时候,这时候就需要QA来大显身手了,但是QA也不是万能的,他们平时只测试新功能,对于没有改动的旧功能通常就没有测试了。但是,我们新改动的代码极有可能影响旧功能,所以,我们的项目上线之前必须经过一次全面的自动化测试来确保功能完整,一般我们把这个过程叫做E2E(end to end) —— 端对端测试。

本篇我们就一个例子来深入一下e2e的写法。

前置知识

e2e测试主要功能是模拟用户操作,然后判断页面有没有呈现预想的状态,比较核心的操作我概括为以下几点

  1. 使用CSS选择器获取页面的一些DOM
  2. 调用测试框架的能力模拟用户操作,如点击、悬浮、输入
  3. 调用类似jest框架的方法(如expect)去判断DOM的状态是否满足预期

由于web页面的操作非常复杂,基于以上几点可以演变出非常多的操作,当然也带出非常多问题,下面我会使用e2e给一个大家比较熟的网站做测试,同时也会讲解写e2e的一些注意点和技巧。

e2e不一定是那一块功能对应的开发者来写,在大多数情况下是整个项目成熟完后再进行补充,这样可以避免因项目需求的频繁改动而付出很大精力去更新e2e

所以我们完全有机会为别人的项目补充e2e case(实例,下同),当然提前知道项目的原理和DOM树的结构会更有利于e2e case的开发。

技术选型

自动化测试工具我们选择Playwright

playwright 是由微软公司 2020 年初发布的新一代自动化测试工具,playwright 基于 jest 的 e2e 测试框架,其在 jest 的基础上集成了仅用一个 API 即可自动执行 Chromium、Firefox、WebKit 等主流浏览器自动化操作,从而实现便捷化自动化测试。

如果屏幕前的你使用其他工具,也请放心食用,他们的API大同小异,基本功能也大致相同。在这里我把经常用到的apiurl贴在这里,方便同学们查询文档。

Page:playwright.dev/docs/api/cl…

ElementHandle:playwright.dev/docs/api/cl…

其他API都在左边的侧边栏里,也可以点击右上角的Search直接搜索

!!需要注意的是,Playwright不支持在MacOS 12系统上运行(11及以下可以),需自行安装虚拟机

确认需求

本期的“大冤种”网站是 www.baidu.com,我会就百度搜索首页来提出一些测试点,可以参考一下

先说明一下,以下功能需要先登录

  1. 点击搜索框,未输入时,会自动调起历史记录框,历史记录显示正常
  2. 历史记录显示正常,无重叠
  3. 如果历史记录达到4条或以上,右下角有“关闭历史”、“删除历史”、“更多历史”三个按钮
  4. 点击“关闭历史”,自动弹出搜索设置界面,可以关闭历史记录,关闭后再点击搜索框就不再显示历史记录
  5. 点击“删除历史”可以删除全部历史
  6. 点击“更多历史”会进入个人中心,在里面可以正常删除历史记录

以上测试需求都是围绕“历史记录”展开,属于一块功能,可以放在一起测试

准备工作

e2e可以作为一个单独的项目,所以我们先初始化一个项目;先新建一个空文件名为e2e-case,运行如下命令

npm init playwright@latest

然后一路回车

我们使用Typescript来写;在tests文件夹下面放测试用例,后面Playwright就会去这个文件夹下面找测试用例;GitHub Action workflow我们先false;最后一个是下载browsers,如果是第一次init,需要等一会儿下载,browsers是测试用的浏览器,我们后面就是用使用Chromium内核的Chrome来进行测试。

初始化完后的目录结构如上,在package.json添加script

"scripts": {
  "test": "playwright test"
},

运行npm run test就会自动执行tests目录下面所有的case,我们平时调试的时候只需要运行指定的case,我们在tests目录下创建一个baidu.spec.ts(所有的case文件名习惯使用.spec.ts作为后缀),再添加命令

"scripts": {
  "debug": "playwright test baidu.spec.ts"
},

如果是多层级目录,只需要增加目录层级即可,详情可参考文档

接下来需要配置一下playwright.config.ts,我们这里改几个参数即可,详情可参考文档

const config: PlaywrightTestConfig = {
  reporter: 'line', // case的运行报告显示在终端即可
  projects: [  // 只需要使用Chrome浏览器
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
      },
    },
    // {
    //   name: 'firefox',
    //   use: {
    //     ...devices['Desktop Firefox'],
    //   },
    // },

    // {
    //   name: 'webkit',
    //   use: {
    //     ...devices['Desktop Safari'],
    //   },
    // },
  ],
}

在写case之前请务必手动模拟一遍操作,观察鼠标点击的位置和动画响应时间。

正式开始

如果有同学在初始化完直接运行case,那么是看不到正在运行浏览器的,第一步我们先把浏览器显示出来,方便我们观察程序的效果和调试

/**
 * @file PC首页-搜索历史
 * @author plutoLam
 */


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

let page: Page;
let browser: Browser;

test.beforeAll(async () => {
  browser = await chromium.launch({
    headless: false
  });
  const context = await browser.newContext();
  page = await context.newPage();
});

test('搜索历史测试', async () => {
  const indexUrl = 'https://www.baidu.com/';
  await page.goto(indexUrl);
  await page.waitForTimeout(3000);
});

一个.spec.ts文件下面可以有多个tset,在所有test的前面可以调用test.beforeAll方法,作为所有tset的做初始化操作。我在beforeAll里面调起浏览器并获取page对象,page对象是最重要的对象之一,里面包含了大部分核心操作。

test里面的大部分操作都是异步操作,所以使用异步函数,而且下面的异步操作都会返回promise,都要记得使用await

运行npm run debug,你能看到进去了百度首页,停留三秒后消失,并且在终端显示通过了一个case,我使用了page.waitForTimeout让页面静止了三秒,这也是调试常用的手段之一,测试过程的操作总是在几毫秒之间闪过去,如果要在特定的位置停下来,就可以用这个方法。或者可以使用page.screenshot在各个部分截图,这样可以看到一些瞬间的动画,而且Playwright在操作的时候,你也是可以手动干预的,也可以看控制台。

登录

第一步我们需要先登录,百度在登录后可能会让你进行安全验证,所以需要你自己先登录一遍,之后playwirght运行的浏览器是无痕浏览器,不会有之前的登录状态。这里做登录操作只是为了演示,如果是自己的项目,也可以直接调用登录接口,登录功能的验证放在其他case

登录的思路很简单,先点击右上角的登录按钮,分别点击和输入账号密码,点击登录按钮

test('搜索历史测试', async () => {
    const indexUrl = 'https://www.baidu.com/'
    await page.goto(indexUrl);

    // 获取登录按钮
    const loginButton = await page.$('#s-top-loginbtn') as ElementHandle;
    // 点击
    await loginButton.click();

    // 等待登录框出现
    await page.waitForSelector('.tang-body');

    // 获取表单
    const form = await page.$('.pass-form.pass-form-normal') as ElementHandle;
    const useNameInput = await form.$('.pass-text-input-userName') as ElementHandle;
    await useNameInput.click();
    // 输入账号
    await page.keyboard.type('123456');

    const pwdInput = await form.$('.pass-text-input-password') as ElementHandle;
    await pwdInput.click();

    // 输入密码
    await page.keyboard.type('123456');

    const sumitButton = await form.$('.pass-form-item-submit') as ElementHandle;
    await sumitButton.click();

    // 等待页面稳定无网络请求
    await page.waitForLoadState('networkidle')
});

1. 慎用waitForTimeout

page.$()的参数是一个字符串,传入的css选择器与传入querySelector的一样,得到一个ElementHandle对象,上面有很多DOM元素的操作方法,比如说click(),点击登录按钮后登录框弹出需要等待网络请求,对于没有动画效果的弹框出现过程,可以用page.waitForSelector方法,它会等待DOM元素被选中后才继续执行。

有同学就说:"那我用waitForTimeout等个几秒行不行呢?",可以是可以,但是网络情况是不稳定的,如果你等个一秒,那下次请求时间超过一秒了,那就不行了,要是请求时间极快,那继续等的时间也是浪费的。

2. 如何获取元素

在网页中可能有很多类名相同的元素,所以尽量使用id来获取DOM元素,如果没有id,那就要确保class的唯一性,所以我先获取了表单的DOM,再在form的基础上去获取里面的input,因为我发现pass-text-input-password类名是不唯一的,因为我确定了form的唯一性,再调用form.$()获取的DOM元素一定是唯一的,这也是准确获取DOM元素的一个思路。

3. 等待页面刷新

输入密码后点击“登录”按钮后,页面会刷新,涉及到页面刷新的等待可以调用page.waitForLoadState,因为这里的刷新有很多http请求,所以选择'networkidle'作为参数,作用是等待页面没有网络请求后500ms后继续执行。如果是页面第一次加载可以使用默认参数'load',等待load事件被触发。

我们将登录过程抽离出来成一个函数,在根目录新建一个utils/common.ts文件,调用时将page实例传进去

// utils/common.ts
export async function login(page: Page) {
  // 登录操作
}

// baidu.spec.ts
import { login } from '../utils/common';
test('搜索历史测试', async () => {
    // ...
    await login(page)
});

如果发现登录后需要手机验证,可以用page.waitForTimeout等久一会验证后结束本次debug

制造历史记录

如果你登录后点击搜索框出现历史记录,那你平时可能经常用百度搜索,但是有的同学的账号是没有历史记录的,为了保证e2e的通用性,需要保证这个case在每种情况下都试用;那没有历史记录怎么办?自己搜索几条不就有了。当然这里只是为了演示(毕竟没有测试搜索功能的需求),如果是自己的项目可以使用page.request来请求几条数据。

/**
 * 创造历史记录
 *
 * @param {Page} page page对象
 * @param {number} count 历史记录条数
 */
export async function createHis(page: Page, count: number) {
    for (let i = 0; i < count; i++) {
        const searchBox = await page.waitForSelector('#kw') as ElementHandle;
        await searchBox.click();
        await page.keyboard.type("test");
        // 等待http请求的response
        await page.waitForResponse(response => response.url().indexOf('/sugrec') !== -1 && response.status() === 200);
        const searchBtn = await page.$('.s_btn_wr');

        // 设置点击位置
        await searchBtn?.click({
            position: {
                x: 10,
                y: 10
            }
        });

        // 等待请求完成
        await page.waitForResponse(response => response.url().indexOf('pc/pcsearch') !== -1 && response.status() === 200);
    }
}

// baidu.spec.ts
await createHis(page, 5);

注意以下几点

  • 在循环里面写异步操作时不要使用Array.forEachArray.forEach操作是同步方法,所以无法直接使用async进行我们理想中的异步操作,建议使用常规for循环
  • 一定要等待请求response后才能继续操作,感兴趣的同学可以看看waitForResponse的回调函数的参数response里的方法可以返回些什么,可以自定义一些操作
  • 这里其实不用设置clickposition也是可以,这里是为了提醒一下:position默认是0,0,也就是左上角,对某些DOM的边缘进行click操作可能是没有效果的,如果click之后没反应就可以试试设置一下position点击DOM的中间位置(获取x,y坐标计算中点位置)

经过以上的操作后效果如下

打开下拉框

create history完毕之后,我们这时候是处在搜索结果页的,需要goto到首页

// baidu.spec.ts
await page.goto(indexUrl);

注意这一行不要写在createHis方法里面,goto到首页只是这个case的需求,不要耦合到公共方法中。

接下来我们要点击搜索框打开下拉框,我们先自己试一遍,同时打开控制台的Network

可以看到他同时有动画和网络请求,然后我们再看看打开前的DOM(我就叫他bgsug吧)

打开后

那我们就根据网络请求、style或者上面提到waitForSelector的来判断他是否打开

笔者试过网络请求和waitForSelector,且在不同的模拟网速下实验,跑了很多次依旧有小概率会失败,可能是因为它的那个动画影响了Playwright的判断,后面想出了一个比较保险的判断方法,代码如下

/**
* 打开his
*
* @param {Page} page page对象
*/
export async function openHis(page: Page) {
    const searchBox2 = await page.$('#kw') as ElementHandle;
    const box = await searchBox2.boundingBox();
    if (box) {
        await page.mouse.click(box.x + 10, box.y + 10);
    }

    // @ts-ignore
    for (const item of Array(10)) {
        await page.waitForTimeout(500);
        const res = await page.evaluate(() => {
            const his = document.querySelector('.bdsug') as Element;
            return window.getComputedStyle(his);
        });
        if (res.display === 'block') {
            return;
        }
    }
}

大概意思是每隔一小段时间就判断一次bgsugstyledisplay属性是否为block,如果十次循环结束后,也就是5000ms后,还没有变为block的话,就默认历史下拉框不正常显示(下面会判断)。

这里使用了page.evaluate()这个方法,这个方法的回调函数里面是浏览器环境,可以调用documentwindow的方法,比如说可以改变scrollTop去模拟滚动事件;也可以在里面获取DOM节点的一些属性。

这里我没有找到Playwright支持类似原生window.getComputedStyle的方法,所以我调用了page.evaluate()来利用浏览器能力,在evaluate的回调函数里return出去的值会被Promise.resolve包裹,用await解包后就可以获得。

验证下拉框

打开历史下拉框后就需要判断bgsug是否显示正常

/**
* 检测his是否存在
*
* @param {Page} page page对象
* @return {Boolean} his是否存在,true为存在,false为不存在
*/
export async function checkHis(page: Page) {
    const form = await page.$('#form') as ElementHandle;
    const his = await form.$('.bdsug') as ElementHandle;
    if (!his) return false;
    const res = await page.evaluate(() => {
        const his = document.querySelector('.bdsug') as Element;
        return window.getComputedStyle(his);
    });
    return res.display === 'block';
}

// baidu.spec.ts
import { ... , expect } from '@playwright/test';
expect(await checkHis(page)).toBe(true);

考虑这个判断是一个公共功能,可能在别的地方也会触发bgsug弹出,这里同样抽出一个公共方法,

因为在没有弹出的情况下,bgsug也是存在于DOM树中的,这里可以在控制台使用document.querySelector('.bdsug')验证一下,是可以选中的。所以不能只判断bgsug是否存在,还需判断styledisplay属性是否为block

可以看到我的expect是写在case中的,大家在写的时候尽量不要把断言写在工具函数与之耦合,因为下一次的业务就有可能和这次的断言不一样,降低耦合才能提高工具函数的使用率。

接下来验证一下历史记录是否显示正常、无重叠。

/**
* 检测his中的query是否对齐
*
* @param {Page} page page对象
*/
export async function checkHisQuery(page: Page) {
    const form = await page.$('#form') as ElementHandle;
    const his = await form.$('.bdsug') as ElementHandle;

    // 监控his的li是否重叠
    // 取出所有的li
    const hisList = await his.$$('ul>li') as ElementHandle[];
    const liBoundingBox = [];
    for (const li of hisList) {
        const obj = await li.boundingBox();
        liBoundingBox.push(obj?.y);
    }

    // 去重
    const hisListSet = new Set(liBoundingBox);

    // 没有重复的y值
    return hisListSet.size === liBoundingBox.length;
}

// baidu.spec.ts
expect(await checkHisQuery(page)).toBe(true);

这里我是使用了ElementHandle$$方法,这个方法就类似document.querySelectorAll,选出符合选择器的DOM数组,我取出每一个DOMy值,没有相同的y值即没重叠。

历史记录操作

关闭历史按钮

我们先测试“关闭历史”按钮

// 关闭历史按钮
const closeHisBtn = await page.$('.setup_storeSug') as ElementHandle;
expect(closeHisBtn).toBeTruthy();

// 关闭历史
await closeHisBtn.click();
// 等待设置框出现
await page.waitForSelector('.pfpanel-bd');
const closeBtn = await page.$('#sh_2') as ElementHandle;
await closeBtn.click();
await confirmSetting(page);
// 关闭后记得打开!!
await openHisRecord(page);
// common.ts
/**
* 在搜索设置中确认
*
* @param {Page} page page对象
*/
export async function confirmSetting(page: Page) {
    const confirm = await page.$('.prefpanelgo') as ElementHandle;
    await confirm.click();
    await page.keyboard.down('Enter');

    // 等待设置框消失
    await page.waitForFunction(selector => !document.querySelector(selector), '.pfpanel-bd');

    // 等待刷新完成
    await page.waitForResponse(response => response.url().indexOf('/sugrec?prod=pc_hi') != -1 && response.status() === 200);
}

我们用断言先验证“关闭历史”按钮显示是否显示正常,点击后等待设置框出现,点击“关闭”后点击“保存设置”,然后我使用键盘的回车键来确认,最后等待设置框消失和http请求的response返回成功。

如果你是一步一步做的,可能会发现在调试完关闭历史操作后,前面的获取bgsug报错了,没有成功弹出,因为你关闭了历史但没有打开呀;所以,为了在测试某个功能前能确保它处于开启状态,我们在测试前也要自动给他打开,这也是保证case顺利执行的条件之一。

如果历史记录被关掉,只能从右上角进入设置

/**
* 在搜索设置中打开历史记录
*
* @param {Page} page page对象
*/
export async function openHisRecord(page: Page) {
    const setting = await page.$('#s-usersetting-top') as ElementHandle;
    await setting.hover();
    const searchSetting = await page.$('.setpref') as ElementHandle;
    await searchSetting.click();
    await page.waitForSelector('.pfpanel-bd');

    // 开启历史
    const displayBtn = await page.$('#sh_1') as ElementHandle;
    await displayBtn.click();
    await confirmSetting(page);
}

// baidu.spec.ts
await login(page)
await openHisRecord(page);
await createHis(page, 5);

在最开始进来的时候先跑一下openHisRecord函数,然后关闭历史后要记得打开!

关闭历史后判断一下bdsug是否打不开,在这之前先改造一下openHis函数,因为在关闭历史记录后就获取不到bgsug了,而将null传入window.getComputedStyle会报错。

export async function openHis(page: Page) {
    // ...
    // @ts-ignore
    for (const item of Array(5)) {
        await page.waitForTimeout(500);
        const res = await page.evaluate(() => {
            const his: Element | null = document.querySelector('.bdsug');
            if (his === null) {
                return -1;
            }
            return window.getComputedStyle(his);
        });
        if (res === -1 || res.display === 'block') {
            return;
        }
    }
}

更多历史按钮

如果我们先测试“删除历史”按钮,那后面就没历史记录了,所以先测试“更多历史按钮”,有时候自己调整测试的顺序也是一个技巧。

// 点击更多历史去个人中心
await openHis(page);

// 更多历史按钮
const moreHisBtn = await page.$('.more_storeSug') as ElementHandle;
expect(moreHisBtn).toBeTruthy();

// 不能跳多页面,goto到href
const href = await moreHisBtn.getAttribute('href') ?? '';
expect(href).toBeTruthy();
await page.goto(href);

await page.waitForLoadState();

// 拿到今天最后一条记录
const historyBox = await page.$('div[class^="history-box"]') as ElementHandle;
const historyBoxLi = await historyBox.$('ul>li') as ElementHandle;

// 删除记录
await historyBoxLi.hover();
const deleteBtn = await historyBoxLi.$('i') as ElementHandle;
await deleteBtn.click();
await page.waitForResponse(response => response.url().indexOf('/data/usrdelete') !== -1 && response.status() === 200);

// 回到首页
await page.goto(indexUrl);
await openHis(page);

笔者为了不多开一个标签页,获取了a标签上的href属性,直接goto到了个人主页,如果按照我们平时的操作,是多开一个标签页后关闭这个标签页或直接切换回前面一个标签页,在这里笔者还是找不到有什么可行的方法,笔者试过await page.keyboard.press('Control+PageUp')试图去触发浏览器的快捷键,好像也不行,如果大家有什么方法可以实现这个需求的话可以在评论区留言。

这里我拿到了最顶部的一条记录将其删除,如果收到http请求返回成功则表示将其删除。这里知道我前面为什么要create五条记录了吗,就是为了在这儿删除一条,然后剩下四条继续后面的测试。

可以看到我用div[class^="history-box]选择器来选择那个DOM,有时候我们的style scope会把类名拼上哈希值,就可以选择这种选择器。

删除历史按钮

// 删除历史按钮
const deleteHisBtn = await page.$('.del_all_storeSug') as ElementHandle;
expect(deleteHisBtn).toBeTruthy();
await deleteHisBtn.click();
await page.waitForResponse(response => response.url().indexOf('data/usrclear') !== -1 && response.status() === 200);

这样就完成最后一个任务了。

关闭浏览器

在测试结束后手动关闭浏览器,避免其他错误

test(...)

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

最后附上整个过程的gif

接入工作流

我们写e2e的目的就是当其他项目push到仓库时自动运行,由于篇幅所限,这部分后面再给小伙伴们呈现。

总结

虽然本次测试的功能没那么复杂,但是写起e2e真的是很麻烦,要考虑到很多细节,小伙伴们在开发时一定要打开浏览器观察,笔者之前因为某些原因在没有浏览器观察的情况下开发,很多时候出了问题都找不到原因在哪里。建议同学们在写的时候一步一步来,这样方便调试,减少出错。

代码已上传github:github.com/plutoLam/e2…

希望这篇文章对屏幕前的你有帮助,原创不易,欢迎点赞、收藏、转发、关注~~!