端到端测试(End to end)是现代应用中的一种常见的测试方式,它模仿用户在客户端的 UI 界面执行操作。与单元测试(Unit Test)不同,前者主要通过测试函数的输入、输出、抛出错误等数据,确保其能够可靠完成工作;而后者更加关注在应用客户端场景下,一个完整的操作链是否能够完成,界面的内容信息、布局样式是否符合预期。
Playwright 是专门为了满足端到端测试的需要而诞生的。
相比起经典的测试工具 Cypress
、Selenium
,Playwright
具有非常大的优势:
- API 设计更加合理,甚至提供了可视化生成测试代码的能力,使用难度较低;
- 支持多线程执行测试,具有更快的测试执行速度;
- 支持所有的现代浏览器,包括
Chromium
、WebKit
、Firefox
等;
本文不同于中文互联网上你能查到的大部分 Playwright
教程,只是介绍这款工具的能力以及基本使用方式。我将基于“实操”的视角,一步步地带领大家搭建一个基础的 Playwright
测试项目。不过,受限于客观条件,实际的测试场景无法 100% 完美还原,因此需要读者结合个人的理解操作,不能原封不动地复制代码。
本文的上半部分将致力于解决实际测试过程中遇到的以下问题:
- 快速安装
Playwright
,并解决内网安装的困难。 - 区分不同的测试环境。
- 解决测试模拟用户的登录鉴权问题。
快速上手
我们按照官方网站推荐的安装方式,在工作目录下执行 pnpm dlx create-playwright
来生成一个 e2e 测试项目。
演示中采用更具优势的 pnpm
作为包管理器(新一代包管理工具 pnpm 使用心得)。
执行初始化名命令后,我们分别选择了:【项目使用 TypeScript】、【测试目录】、【不增加 Github Actions 流水线】、【不安装浏览器】(大家可以按照自己实际的需求修改选项)。运行结果如下:
生成的目录结构如下:
?my-test
┣ ?node_modules
┣ ?tests
┃ ┗ ?example.spec.ts
┣ ?tests-examples
┃ ┗ ?demo-todo-app.spec.ts
┣ ?.gitignore
┣ ?package.json
┣ ?playwright.config.ts
┗ ?pnpm-lock.yaml
对于 TypeScript
项目,我们可以建立 tsconfig.json
来提供语言服务,并通过 pnpm i -D @types/node
安装 node
的类型声明,阻止 playwright.config.ts
报错。
// tsconfig.json
{
"compilerOptions": {
"rootDir": ".",
"baseUrl": ".",
"outDir": "dist",
"target": "es2018",
"module": "esnext",
"moduleResolution": "node",
"sourceMap": false,
"strict": true,
"noUnusedLocals": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"removeComments": false,
"isolatedModules": true,
"types": ["node"]
},
"include": ["playwright.config.ts", "tests"],
}
内网环境安装浏览器
在我们初始化选择【不安装浏览器】的情况下,可以通过命令 npx playwright install
来安装浏览器,**不安装浏览器是无法执行任何测试的。**如果安装过程中没有出现问题,可以直接前往下一步继续操作。
如果你的工作环境为封闭式的内网,执行这条命令往往会收到报错信息,与 Playwright
CDN 地址链接时,出现这种 SSL 校验错误或者请求超时错误。
这里提供以下方法来解决这种问题:
一、忽略 SSL 报错
通过设置环境变量 NODE_TLS_REJECT_UNAUTHORIZED=0
,可以使 Node.js
忽略 SSL 证书错误。(注意 linux 环境命令不同于 PowerShell)
二、通过代理下载
如果有通过代理下载的需求,需要设置环境变量 HTTPS_PROXY=http://myproxy
。(参考:通过代理下载)
三、提前下载镜像二进制文件,部署到本地
在 CI 构建机的环境下,我们不方便通过代理链接外网,则可以使用这种方式。通过在浏览器中输入 CDN 地址直连下载(windows 系统请将 linux 改为 win64):
- playwright-verizon.azureedge.net/builds/chro…
- playwright-verizon.azureedge.net/builds/ffmp…
- 其他浏览器地址…
这里只举例了最重要的 Chrome
以及视频录制工具 ffmpeg
的下载地址,其他浏览器的下载地址可以类推出来。
将下载的包传入目标机器,存入指定路径即可:(参考:浏览器二进制文件路径参考)
%USERPROFILE%\AppData\Local\ms-playwright
on Windows~/Library/Caches/ms-playwright
on MacOS~/.cache/ms-playwright
on Linux
以 Windows 下的 Chrome
的存放路径为例:%USERPROFILE%\AppData\Local\ms-playwright\chromium-1064\chrome-win
四、自己搭建文件镜像服务
可以通过对象存储、nginx 或者其他多种方式起一个静态文件服务,预先下载好安装包,并按照 /builds/${name}/${version}/${name}-${platform}.zip
的格式归档(参考:从制品仓下载)。
之后设置环境变量 PLAYWRIGHT_DOWNLOAD_HOST
可以修改下载地址。
注意Playwright 版本依赖特定浏览器版本,如果需要经常更新项目中的 Playwright
版本,镜像归档中的浏览器包也需要根据实际情况维护更新,可以在 Playwright
的代码仓中找到维护版本信息的 JSON 文件
运行测试
我们通过命令:npx playwright test
,就能够执行全部测试。更多运行测试的命令行方法请参考:测试命令行。
playwright.config.ts
是测试配置文件,我们可以学习一下初始生成的配置文件,看看它如何影响测试效果。需要对配置进行更深入的学习请参考:测试选项
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// 测试目录
testDir: './tests',
// 是否并发运行测试
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
// 测试失败用例重试次数
retries: process.env.CI ? 2 : 0,
// 测试时使用的进程数,进程数越多可以同时执行的测试任务就越多。不设置则尽可能多地开启进程。
workers: process.env.CI ? 1 : undefined,
// 指定测试结果如何输出
reporter: 'html',
// 测试 project 的公共配置,会与与下面 projects 字段中的每个对象的 use 对象合并。
use: {
// 测试时各种请求的基础路径
baseURL: 'http://127.0.0.1:3000',
// 生成测试追踪信息的规则,on-first-retry 意为第一次重试时生成。
trace: 'on-first-retry',
},
// 定义每个 project,示例中将不同的浏览器测试区分成了不同的项目
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
});
输出测试结果
我们对配置文件做出以下修改,使得我们的测试能够输出丰富的测试结果:
- 测试过程中在命令行中同步打印每条用例的执行结果。
- 将 html 格式的报告输出到指定目录。
- 在某些条件下能够生成追踪信息以及测试录像。
// playwright.config.ts
export default defineConfig({
+ // 指定测试产物(追踪信息、视频、截图)输出路径
+ outputDir: 'test-results',
//...
- reporter: 'html',
+ reporter: [
+ // 在命令行中同步打印每条用例的执行结果
+ ['list'],
+ // 输出 html 格式的报告,并将报告归档与指定路径
+ ['html', {
+ outputFolder: 'playwright-report',
+ }],
+ ],
// ...
use: {
//...
+ // 非 CI 环境下,第一次失败重试时生成追踪信息。非 CI 环境下,总是生成追踪信息
+ trace: process.env.CI ? 'on-first-retry' : 'on',
+ // 非 CI 环境下,第一次失败重试时生成视频。非 CI 环境下,总是生成视频
+ video: process.env.CI ? 'on-first-retry' : 'on',
},
});
再次执行测试,所有预期中的结果都已经出现。令人惊喜的是,追踪信息和测试视频也会在测试报告中同步(process.env.CI = false
),不得不让人感叹 Playwright
自带的原生 reporter 功能也是如此强大,几乎没有什么自定义的必要了。
测试产物时不应该入仓的,也不应该被 IDE 插件扫描,记得在 .gitignore
、.eslintignore
等文件中将测试产物目录排除。
# .gitignore
node_modules/
+/test-results/
+/playwright-report/
+/playwright/.cache/
除了 list
和 html
两种类型的输出以外,官方还提供了更多的输出方式,详情可参阅文档:
区分测试环境
实际的应用可能会有多套环境,比如生产环境、回归测试环境、本地开发环境等。而测试应用应该具有对于不同的环境执行测试任务的能力。
我们可以借鉴 Vite
的 环境变量 的解决方案,在测试工程目录中建立多个环境变量文件:
?my-test
┣ ...
┣ ?.env # 所有情况下都会加载
┣ ?.env.dev # 本地开发环境下加载
┣ ?.env.test # 测试环境下加载
┣ ?.env.test.local # 测试环境下加载,但是只在本地有效不会入仓,可以用于存放一些不该入仓的敏感配置。其他环境也可以有 .local 配置文件。
┗ ?.env.production # 生产环境下加载
环境变量文件中可以声明更多的环境变量。在下面的例子中,.env.production
在测试生产环境时会被加载,之后在测试脚本以及 playwright.config.ts
中就可以通过 process.env.WEBSITE_URL
获取到网站的 url 了。于是,在不同的环境变量文件中设置不同的 WEBSITE_URL
就达成了测试环境的区分。
# .env.production
# 测试网站 url
WEBSITE_URL = https://myapp.com
# .env.test
WEBSITE_URL = https://test.myapp.com
需要注意,带有 .local
的配置文件由于不应该入仓,应当在 .gitignore
中声明
# .gitignore
# ...
+.env.local
+.env.*.local
我们可以规定环境变量 TEST_MODE
用于指定测试的目标环境,根据 TEST_MODE
的不同,我们将加载不同的配置文件。例如在 Linux
下,命令 set TEST_MODE=production && npx playwright test
将会执行对生产环境的测试。
考虑到不同的操作系统设置环境变量的方式不同,我们要引入 cross-env 来设置环境变量:
pnpm i -D cross-env
之后再 package.json
中声明对不同环境执行测试的命令:
{
// ...
"scripts": {
"test:development": "cross-env TEST_MODE=development playwright test",
"test:test": "cross-env TEST_MODE=test playwright test",
"test:production": "cross-env TEST_MODE=production playwright test"
}
}
接下来,我们要使得测试执行时,环境变量能够切实地被读取。这里我们引入一个 npm 包 dotenv:
pnpm i -D dotenv
之后在 playwright.config.ts
中加入读取环境变量文件的代码。
// playwright.config.ts
import dotenv from 'dotenv';
// TEST_MODE 的值决定了加载哪个环境的文件
const modeExt = process.env.TEST_MODE || 'development';
// 先加载入仓的配置文件,再加载本地的配置文件
dotenv.config({ path: '.env' });
dotenv.config({ path: `.env.${modeExt}`, override: true });
dotenv.config({ path: '.env.local', override: true });
dotenv.config({ path: `.env.${modeExt}.local`, override: true });
export default defineConfig({
// ...
});
随后,我们修改 playwright.config.ts
中的 baseURL
的值为 WEBSITE_URL
,之后在测试脚本中就可以通过相对路径访问我们测试应用的不同页面了。
// playwright.config.ts
export default defineConfig({
// ...
use: {
//...
+ // 非 CI 环境下,第一次失败重试时生成追踪信息。非 CI 环境下,总是生成追踪信息
+ baseURL: process.env.WEBSITE_URL,
},
});
我们可以验证一下效果,将 test
环境中的 url 设置为码云,将 production
环境中的 url 设置为 Github
:
# .env.test
WEBSITE_URL = https://gitee.com/
# .env.production
WEBSITE_URL = https://github.com/
修改 example.spec.ts
测试脚本,访问一下对应网站的首页即可:
// example.spec.ts
import { test } from '@playwright/test';
test('测试环境', async ({ page }) => {
// 与 playwright.config.ts 一样,测试脚本中也可以访问环境变量
console.log(process.env.WEBSITE_URL);
await page.goto('/');
});
分别执行以下命令,查看测试结果。--ui
可以在可视化界面查看测试结果,包含对步骤的追踪信息。
pnpm run test:test --ui
pnpm run test:production --ui
测试登录鉴权
很多系统的功能都需要先完成用户登录后才能够使用。因此在端到端测试中,处理用户登录是一个经典的问题。
Playwright
为我们提供了解决方案:
首先在 playwright.config.ts
中,定义一个新的测试工程 setup
,专门用于登录鉴权的初始化。其他正式的测试工程都必须在其后执行。
规定 .auth
目录存放浏览器缓存,setup
工程将登录后的浏览器缓存往其中写入,正式测试工程执行前将从中读出缓存。
// 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
之后,如何实现 setup
工程需要结合实际情况,具体问题具体分析。总体思路即 setup
工程将已登录的浏览器缓存信息保存入 .auth
的文件中,后续的测试任务直接从中加载登录状态。下面给出了两种实现方式:
模拟登录操作
官方给出的 登录示例 就是最经典的通过模拟登录操作实现的。例子中前往了 Github
登录页面执行登录操作,成功后将页面的缓存数据保存下来。我们不妨来模拟一下这个过程:
首先将登录用的账户和密码都写入配置文件:
# .env
# 根据自己的情况修改
TEST_USERNAME = username
# .env.local
# 根据自己情况修改
# 明文密码不建议入库,因此最好写在 .local 的配置文件中
TEST_PASSWORD = password
补充一个步骤,由于环境变量文件中声明的文件越来越多,建议增加一个类型声明文件 tests/env.d.ts
,使得在编写脚本时能够有类型提示,体验更佳。
// tests/env.d.ts
declare namespace NodeJS {
interface ProcessEnv {
/** 测试模式 */
TEST_MODE: 'production' | 'development' | 'test';
/** 测试用户 */
TEST_USERNAME: string;
/** 测试用户密码 */
TEST_PASSWORD: string;
/** 测试网站域名 */
WEBSITE_URL: string;
}
}
接下来在测试目录中创建 tests/auth.setup.ts
文件:
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = '.auth/user.json';
setup('authenticate', async ({ page }) => {
// 输入账号、密码、点击登录
await page.goto('/login');
await page.getByLabel('Username or email address').fill(process.env.TEST_USERNAME || '');
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD || '');
await page.getByRole('button', { name: 'Sign in' }).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('https://github.com/');
// 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 });
});
修改 tests/example.spec.ts
测试用例,在用例中我们试着访问 Github
个人中心。如果正式测试成功读取到登录信息,我们是可以成功访问个人中心的。
// tests/example.spec.ts
import { test } from '@playwright/test';
test('访问 github 个人中心', async ({ page }) => {
await page.goto(`/${process.env.TEST_USERNAME}`);
});
运行 pnpm run test:production --ui
执行测试:
很可惜,失败了,因为 Github
对于新设备登录有着验证码确认的机制,这个是无法通过测试脚本实现的。由于客观条件我无法为大家完整展示出效果。但是这种方式在自己管理的项目中是完全可行的,可以在后台将测试用户设置为免验证,或者将测试执行机 ip 添加到信任白名单中。
API 登录鉴权
解下来介绍另一种完成鉴权的方法。
在我实际的项目中,所有的接口都是通过请求头中的一串 token
字符串完成鉴权并获取到用户信息。
# Request Header
# ...
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJnMzAwMjEyMTQiLCJjcmVhdGVkIjoxNjg0MTk1NDM5MzA5LCJJUCI6IjEwLjU4LjIwMy45NyIsImV4cCI6MTY4Njc4NzQzOX0.9hpQtPwo9OjET5CqjvHMs-w62Vq78Gc8b3-c2Jhk_Vc-OQ_4sR-wqNeiQGbNbKjpYtMCg6u2INQYG1QGOm7YHQ
这个 token 在 OAuth2.0 登录完成重定向后从 url 参数中截取得到,后续保存在 localStorage
中。
我们实现了一个接口 getToken
,可以使特定白名单中的用户得以绕过登录流程,直接获取到 token
。
所以 tests/auth.setup.ts
的行为变成调用 getToken
接口,用获取到的 token
构造浏览器缓存数据结构,之后将 json 字符串写入 .auth/user.json
。
在这种情况下可以按下面的方式实现 auth.setup.ts
。
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import { axios } from 'axios'
import { Agent } from 'node:https';
import { writeFile } from 'node:fs/promises';
const authFile = '.auth/user.json';
setup('authenticate', async ({ page }) => {
// 调用 api 接口获取 token
const data = await axios.get('https//myapi.com/getToken', {
httpsAgent: new Agent({
rejectUnauthorized: false,
}),
});
const token = data.data?.token || '';
if (!token) {
throw new Error('Get token failed!');
}
// 获取一个空的缓存数据对象
const storage = await page.context().storageState();
// 构造缓存数据对象,这里将 token 存入 localStorage
storage.origins.push({
origin: process.env.WEBSITE_URL || '',
localStorage: [
{ name: 'token', value: token },
],
});
// 将含有 token 的缓存数据写入文件
await writeFile(authFile, JSON.stringify(storage), 'utf-8');
});
生成的缓存文件如下:
// .auth/user.json
{
"cookies": [],
"origins": [
{
"origin": "https://myapp.com",
"localStorage": [
{
"name": "token",
"value": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJnMzAwMjEyMTQiLCJjcmVhdGVkIjoxNjg1MzUxODE3Njc1LCJJUCI6IjEwLjU4LjIwMy45NyIsImV4cCI6MTY4NTQzODIxN30.CTxFHUYEXdv2vFRICAxC0EIKlfkLfVOLxiavZnE00fnOl3XZJazxxh15f3vg0-ImYHH3wl5HbpFpKWBhXPS_pw"
}
]
}
]
}
如此,我们的测试工程就有了一个还算完整的骨架,接下来就可以往其中填充血肉——测试用例了。测试用例的编写将在下半部分讲解。