跳到正文

在 Playwright 中测试 ​

用 Playwright 写小程序的 E2E 测试:在浏览器里打开小程序的页面,点击按钮,检查页面上的文字、页面的 data、小程序发出的请求。 CI 里无头运行,Linux 上不需要开发者工具。

fixture 是什么 ​

fixture 是 Playwright 的概念:测试框架在每个用例开始前准备好、作为参数传进来的对象。Playwright 自带的 page(浏览器页面)就是一个 fixture。

@open-wxa/wxa-runner-playwright 提供了一个叫 miniProgram 的 fixture。用了它,就不用自己启动 wxa-runner、打开浏览器、等小程序启动, 用例里直接写要测的东西:

ts
test('点击「详情」进入详情页', async ({ miniProgram }) => {
  await miniProgram.open('pages/index/index');
  await miniProgram.current.locator('#detail').click();
  await expect(miniProgram).toHavePath('pages/detail/detail');
});

不想多装一个包,或者用的不是 Playwright,可以直接调用浏览器里的测试 API。

快速上手 ​

1. 安装

bash
pnpm add -D @playwright/test @open-wxa/wxa-runner-playwright

用例在本机安装的 Google Chrome 里运行。没装 Chrome 时,运行 npx playwright install chrome。

2. 新建 playwright.config.ts

  • 原生小程序:放在 project.config.json 所在目录
  • uni-app、Taro 等框架:放在源码根目录,用 wxaProject 指向编译产物目录。跑测试前先构建出这个目录
ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import type { WxaOptions } from '@open-wxa/wxa-runner-playwright';

export default defineConfig<WxaOptions>({
  testDir: 'e2e',
  use: {
    channel: 'chrome',
    // uni-app、Taro:编译产物目录,相对这个配置文件
    // wxaProject: './dist/dev/mp-weixin',
    // setup 文件,相对这个配置文件
    // wxaServerOptions: { setup: './wxa.setup.ts' },
  }
});

3. 写第一个用例

ts
// e2e/index.spec.ts
import { test, expect } from '@open-wxa/wxa-runner-playwright';

test('首页能打开', async ({ miniProgram }) => {
  await miniProgram.open();
  // 换成你的首页路径
  await expect(miniProgram).toHavePath('pages/index/index');
});

test 和 expect 要从 @open-wxa/wxa-runner-playwright 导入,不是 @playwright/test:这样用例里才有 miniProgram, expect 才有 toHavePath 等断言。

4. 运行

bash
npx playwright test            # 无头运行全部用例
npx playwright test --headed   # 打开浏览器窗口,看着它运行
npx playwright test --ui       # 图形界面,可以一步步回看

写用例 ​

下面是一个完整的例子:下单前 mock 登录、拦截下单接口,点击提交后检查跳转、页面 data 和发出的请求。各部分的说明见后面几节。

ts
import { test, expect } from '@open-wxa/wxa-runner-playwright';

test('提交订单后跳转到结果页', async ({ miniProgram }) => {
  // 打开页面前声明,App.onLaunch 里就生效
  await miniProgram.mock('login', { code: 'test_code' });
  await miniProgram.route('POST **/api/orders', {
    status: 201,
    body: { id: 'o-1' }
  });
  await miniProgram.open('pages/cart/cart');

  // current 是当前页,跳转后自动指向新页面
  await miniProgram.current.locator('#submit').click();

  // 跳转需要时间,这两个断言会自动重试,不用手动等待
  await expect(miniProgram).toHavePath('pages/result/result');
  await expect(miniProgram).toHavePageData({ orderId: 'o-1' });

  const req = await miniProgram.waitForRequest(
    'POST **/api/orders'
  );
  expect(req.body).toEqual({ skuId: 1, count: 2 });
});

打开页面 ​

写法作用
await miniProgram.open()打开首页
await miniProgram.open('pages/detail/detail?id=1')打开指定页面,? 后面是页面参数
  • 页面不在 app.json 里时直接报错,不会停在首页上继续执行
  • 同一个用例里再次 open() 会整页重新加载,之前的 mock、route 仍然生效

操作页面 ​

miniProgram.current 是当前页(页面栈顶),用法和 Playwright 的 page.frameLocator() 一样。跳转后它自动指向新页面,不用重新获取:

ts
const page = miniProgram.current;
await page.locator('#add').click();
await page.getByText('去结算').click();
await expect(page.locator('#count')).toHaveText('1');

断言 ​

除了 Playwright 自带的断言,还可以直接断言小程序的状态,不满足时自动重试,直到超时:

写法检查
expect(miniProgram).toHavePath('pages/result/result')当前页是这个页面
expect(miniProgram).toHavePageData({ status: 'ok' })当前页的 data 包含这些字段和值,没列出的字段不比较
expect(miniProgram).toHavePageData(data, { path })页面栈里指定页面的 data

别忘了 await。两者都可以加 .not,也可以传 { timeout } 改超时时间(毫秒)。

mock 原生 API、拦截请求 ​

在 open() 之前调用,App.onLaunch 里就生效;打开之后调用,从下一次调用开始生效:

ts
await miniProgram.mock('getLocation', {
  latitude: 31.23,
  longitude: 121.47
});
await miniProgram.route('GET **/api/feed', {
  body: { list: [] }
});
await miniProgram.open();
  • 写法和 setup 的 mock、network.routes 一样,会覆盖 setup 里的同名配置
  • unmock(api?)、unroute(pattern?) 撤销,不传参数时撤销全部,包括 setup 里声明的

也可以传函数,按调用参数或请求内容返回结果。函数在 Node 里执行,能用用例里的变量,也能在里面写断言:

ts
await miniProgram.route('POST **/api/orders', request => {
  expect(request.body).toEqual({ skuId: 1, count: 2 });
  return { status: 201, body: { id: 'o-1' } };
});
  • 函数抛错时这次调用失败,用例结束时也会失败,写在函数里的断言失败了不会被忽略
  • 函数的结果是异步返回的。基础库同步调用的 API 只能传对象:以 Sync 结尾的,以及 getSystemInfo、getMenuButtonBoundingClientRect (wx.getWindowInfo 等都经过 getSystemInfo)
  • 参数和返回值要能序列化。route 的函数拿到的 request.body 已经按 Content-Type 解析好(JSON、表单)

检查发出的请求 ​

ts
await miniProgram.clearRequests();
await miniProgram.current.locator('#save').click();

const req = await miniProgram.waitForRequest(
  'POST **/api/profile'
);
expect(req.body).toEqual({ name: '张三' });
  • waitForRequest(pattern):等到小程序发出匹配的请求,返回最近的一个。之前已经发出的也算,只关心某个操作之后的请求时,操作前先 clearRequests()
  • requests(pattern?):已经发出的全部请求,按发出顺序。被拦截的和真正发出的都有,只保留最近 1000 个
  • 规则的写法同 route

每个请求有:

字段说明
method大写,如 POST
url完整地址
header请求头
body按 Content-Type 解析好的请求体:JSON 是对象,表单是 { 字段: 值 }
data没解析的原始请求体
routedBy拦截它的规则;没被拦截、真正发出去的为 null

读取状态、直接跳转 ​

写法作用
pageData(path?)页面的 data。不传时读当前页
currentPage() / pageStack()当前页 / 整个页面栈,每页有 path、query
waitForPage(path)等到这个页面成为当前页
callPageMethod(name, ...args)调用当前页的方法,返回方法的返回值
navigateTo(url)、navigateBack()、redirectTo(url)、reLaunch(url)、switchTab(url)直接跳转,同 wx 上的同名 API
getStorage(key) / clearStorage()读取 / 清空小程序的存储

都是 async 的,要 await。每个成员在编辑器里悬停都能看到详细说明。

选择图片 ​

wx.chooseImage、wx.chooseMedia 会打开浏览器的文件选择框。用 Playwright 的 filechooser 事件直接给文件,不会弹出系统窗口。 和真机一样,相册和拍摄都可选时(默认)会先弹出「拍摄 / 从手机相册选择」菜单,要先点菜单项:

ts
test('上传头像', async ({ miniProgram, page }) => {
  await miniProgram.open('pages/profile/profile');
  await miniProgram.current.locator('#avatar').click();

  const [chooser] = await Promise.all([
    page.waitForEvent('filechooser'),
    page.getByRole('button', { name: '从手机相册选择' }).click()
  ]);
  await chooser.setFiles('fixtures/avatar.png');
});
  • 菜单、图片预览这些弹层在 page 上,不在 miniProgram.current 里。预览层用 page.getByRole('dialog', { name: '图片预览' }) 找到
  • chooser.setFiles([]) 等于用户关掉选择框,页面收到 cancel

检查运行时错误 ​

每个用例结束时,fixture 会检查小程序有没有出错,有就让用例失败:

  • 逻辑层、渲染层的运行时错误
  • 调用了 wxa-runner 还没有实现的 wx API。这些 API 会返回成功,以免打断业务代码,但测试里要 mock 掉

用 miniProgram.errors() 可以看到具体内容。有意触发错误的用例,用 test.use({ wxaCheckErrors: false }) 关掉检查。

配置项 ​

在 playwright.config.ts 的 use 里设置:

选项默认值作用
wxaProject'.'小程序目录,相对配置文件
wxaServerOptions{}开发服务器的其他参数,如 setup、baseLibVersion、realLogin。路径相对配置文件
wxaCheckErrorstrue用例结束时检查运行时错误
  • 每个 worker(Playwright 并行运行用例的进程)启动一个开发服务器,小程序只编译一次;每个用例用一个新的浏览器上下文,存储、页面栈互不影响
  • 测试时不监听文件变化、不热更新,运行环境和真机一致
  • wxaProject、wxaServerOptions 只能写在配置文件里,或用例文件顶层的 test.use 里,不能写在 describe 里

注意事项 ​

  • 跳转后的选择器:新旧页面有同一个选择器时,先 await expect(miniProgram).toHavePath(...) 等跳转完成,免得断言落在上一个页面上
  • text 组件的文字:基础库 3.3.x 及更早的版本里,textContent(Playwright 的 toHaveText 默认用它)会把 text 组件的文字读到两遍。 用 toHaveText(text, { useInnerText: true }) 或 innerText()

基于 MIT 许可发布。内置基础库是微信官方代码,版权归腾讯所有