浏览器测试
本指南涵盖针对 hypermedia 和 Inertia 应用的端到端浏览器测试。你将学习如何:
- 在你的测试套件中配置浏览器测试插件
- 通过 CLI 选项控制测试执行(浏览器、有头模式、跟踪记录、慢动作)
- 编写带有断言的基础页面访问测试
- 在测试之间重置数据库状态
- 使用 Playwright 选择器填写并提交表单
- 使用录制模式快速生成测试代码
- 在访问受保护页面之前对用户进行身份验证
概述
浏览器测试从外部对应用进行验证,其导航方式与一个真实用户完全一致。与检查孤立代码片段的单元测试不同,浏览器测试演练了整个技术栈:路由、控制器、视图、数据库查询,以及客户端交互协同工作。
对于 hypermedia 和 Inertia 应用,浏览器测试应该构成你测试套件的大部分。这些应用本质上关乎用户与渲染页面的交互,而浏览器测试直接捕捉了这一现实。当浏览器测试通过时,你高度确信该功能对用户而言确实可用。当它失败时,你捕获到了用户本将遇到的 bug。
如果你习惯了以单元测试为主的「测试金字塔」,这种方式可能感觉不同。对于服务端渲染的应用,反转这个金字塔是合理的:浏览器测试每个测试提供的价值更高,因为它们验证的是完整的用户流程,而非实现细节。
设置
浏览器测试需要在你的 tests/bootstrap.ts 文件中配置三个插件。这些插件已随官方的 Hypermedia 和 Inertia starter kit 安装并配置好。
import { assert } from '@japa/assert'
import app from '@adonisjs/core/services/app'
import type { Config } from '@japa/runner/types'
import { pluginAdonisJS } from '@japa/plugin-adonisjs'
import testUtils from '@adonisjs/core/services/test_utils'
import { browserClient } from '@japa/browser-client'
import { authBrowserClient } from '@adonisjs/auth/plugins/browser_client'
import { sessionBrowserClient } from '@adonisjs/session/plugins/browser_client'
export const plugins: Config['plugins'] = [
assert(),
pluginAdonisJS(app),
/**
* 配置 Playwright,并在每个测试之前
* 创建一个新的浏览器上下文。
*/
browserClient({ runInSuites: ['browser'] }),
/**
* 允许通过浏览器上下文
* 读取和写入会话数据。
*/
sessionBrowserClient(app),
/**
* 启用 loginAs 方法,用于在测试期间
* 对用户进行身份验证。
*/
authBrowserClient(app),
]
export const runnerHooks: Required<Pick<Config, 'setup' | 'teardown'>> = {
setup: [],
teardown: [],
}
export const configureSuite: Config['configureSuite'] = (suite) => {
if (['browser', 'functional', 'e2e'].includes(suite.name)) {
return suite.setup(() => testUtils.httpServer().start())
}
}
CLI 选项
Playwright 的行为通过在运行测试时传入的命令行标志来控制。以下选项有助于调试和跨浏览器验证。
--browser
在特定的浏览器中运行测试。支持的值为 chromium、firefox 和 webkit。
node ace test --browser=firefox
--headed
在测试执行期间显示浏览器窗口。默认情况下,测试以无头(headless)模式运行。
node ace test --headed
--devtools
在浏览器启动时自动打开浏览器开发者工具。
node ace test --devtools
--slow
按指定的毫秒数减慢测试动作。有助于直观地跟随测试正在做什么。
node ace test --slow=500
--trace
记录跟踪信息用于调试。使用 onError 仅当测试失败时记录,或使用 onTest 记录每个测试。
node ace test --trace=onError
记录跟踪
跟踪会捕获测试执行的完整时间线,包括截图、网络请求和 DOM 快照。仅在测试失败时或针对每个测试生成跟踪。
# 仅在测试失败时记录跟踪
node ace test --trace=onError
# 为每个测试记录跟踪
node ace test --trace=onTest
跟踪存储在 browsers 目录中。使用 Playwright 的 trace viewer 进行回放。
npx playwright show-trace browsers/path-to-trace.zip
运行特定测试
运行所有浏览器测试,或针对特定的文件和文件夹。
# 运行所有浏览器测试
node ace test browser
# 运行特定文件夹中的测试
node ace test --files="posts/*"
基础页面访问
浏览器测试访问一个页面,并对其内容做出断言。visit 辅助方法打开一个 URL,返回的 page 对象提供了断言方法。
import { test } from '@japa/runner'
test.group('Posts index', () => {
test('display list of posts', async ({ visit, route }) => {
/**
* 使用其命名路由访问 posts 索引页。
* visit 辅助方法返回一个经过断言方法
* 扩展的 Playwright page 实例。
*/
const page = await visit(route('posts.index'))
/**
* 断言 body 包含特定文本。
* 这会等待文本出现,最长达 5 秒。
*/
await page.assertTextContains('body', 'My first post')
})
})
这个测试会失败,因为数据库中不存在任何文章。失败消息表明该断言在等待预期内容时超时了。
ℹ AssertionError: expected 'body' inner text to include 'My first post', timed out after 5000ms
⁃ (AssertionError [ERR_ASSERTION]: expected 'body' inner text to include 'My first post':undefined:undefined)
数据库状态
测试应该以已知的数据库状态开始。使用 testUtils.db().truncate() 钩子在每个测试之后清空表,然后创建你的测试所需的特定记录。
另请参阅: 数据库测试工具 ,了解更多方法,如迁移和种子数据。
import Post from '#models/post'
import User from '#models/user'
import testUtils from '@adonisjs/core/services/test_utils'
import { test } from '@japa/runner'
test.group('Posts index', (group) => {
/**
* 在每个测试之后截断数据库表。
* 这确保测试之间不会互相影响。
*/
group.each.setup(() => testUtils.db().truncate())
test('display list of posts', async ({ visit, route }) => {
/**
* 创建该测试所依赖的数据。
* 每个测试都显式地建立自己的状态。
*/
const user = await User.create({
email: 'john@example.com',
password: 'secret',
})
await Post.create({
title: 'My first post',
content: 'This is my first post',
userId: user.id,
})
const page = await visit(route('posts.index'))
await page.assertTextContains('body', 'My first post')
})
})
表单交互
表单使用 Playwright 的定位器(locator)方法来填写。通过标签文本选择输入框,使用 fill 输入值,然后使用 click 提交。
import testUtils from '@adonisjs/core/services/test_utils'
import { test } from '@japa/runner'
test.group('Session create', (group) => {
group.each.setup(() => testUtils.db().truncate())
test('display error when invalid credentials are used', async ({ visit, route }) => {
const page = await visit(route('session.create'))
/**
* 通过关联的标签文本定位输入框。
* 这镜像了用户识别表单字段的方式。
*/
await page.getByLabel('Email').fill('john@example.com')
await page.getByLabel('Password').fill('secret')
/**
* 点击提交按钮。getByRole 通过元素的
* ARIA 角色查找元素,使测试对
* 标记变更更具韧性。
*/
await page.getByRole('button').click()
})
})
录制模式
手动编写定位器需要你在浏览器和测试文件之间反复切换。录制模式会启动一个浏览器,你的交互会被自动转换为测试代码。
创建一个新的测试文件,并调用 record 方法而非 visit。当你运行测试时,会打开一个浏览器,你可以在其中与你的应用进行交互。完成后关闭浏览器,并将生成的代码粘贴到你的测试文件中。
import { test } from '@japa/runner'
test.group('Posts create', () => {
test('create a new post', async ({ record, route }) => {
/**
* 以录制模式打开浏览器。
* 录制期间测试超时会被禁用。
* 与页面进行交互,然后关闭浏览器
* 即可看到生成的测试代码。
*/
await record(route('posts.create'))
})
})
录制完成后,将 record 调用替换为 visit,并粘贴生成的定位器和操作。
对用户进行身份验证
受保护的页面需要已通过身份验证的用户。browserContext.loginAs 方法为该测试中所有后续的页面访问对用户进行身份验证。
为了使身份验证在测试期间生效,需要在你的 .env.test 文件中设置 SESSION_DRIVER=memory。memory 驱动允许测试进程管理会话,而无需文件或数据库的开销。
import User from '#models/user'
import { test } from '@japa/runner'
import testUtils from '@adonisjs/core/services/test_utils'
test.group('Posts create', (group) => {
group.each.setup(() => testUtils.db().truncate())
test('display error when missing post title or content', async ({
visit,
browserContext,
route,
}) => {
const user = await User.create({
email: 'john@example.com',
password: 'secret',
})
/**
* 为该浏览器上下文中的用户进行身份验证。
* 所有后续的页面访问都将是已验证状态。
*/
await browserContext.loginAs(user)
const page = await visit(route('posts.create'))
await page.assertPath('/posts/create')
})
})
Cookie 与会话
浏览器上下文提供了在测试期间读写 cookie、会话和闪现消息的方法。当你的应用行为依赖存储的状态时,这些方法很有用。
设置 cookie
根据 cookie 的存储方式,提供了三种方法。
import { test } from '@japa/runner'
test.group('User preferences', () => {
test('apply dark mode from cookie', async ({ visit, browserContext, route }) => {
/**
* 设置一个加密 cookie(AdonisJS 中默认的 cookie 行为)。
*/
await browserContext.setCookie('theme', 'dark')
/**
* 设置一个不加密的明文 cookie。
* 对于客户端 JavaScript 需要读取的 cookie 很有用。
*/
await browserContext.setPlainCookie('locale', 'en-us')
/**
* 显式地设置一个加密 cookie。
* 等同于 setCookie。
*/
await browserContext.setEncryptedCookie('preferences', { sidebar: 'collapsed' })
const page = await visit(route('dashboard'))
await page.assertVisible('.dark-mode')
})
})
读取 cookie
在页面交互之后检索 cookie 值,以验证你的应用是否正确设置了它们。
import { test } from '@japa/runner'
test.group('User preferences', () => {
test('save theme preference to cookie', async ({ visit, browserContext, route }) => {
const page = await visit(route('settings'))
await page.getByLabel('Theme').selectOption('dark')
await page.getByRole('button', { name: 'Save' }).click()
/**
* 在页面交互之后读取 cookie。
*/
const theme = await browserContext.getCookie('theme')
const locale = await browserContext.getPlainCookie('locale')
const prefs = await browserContext.getEncryptedCookie('preferences')
})
})
设置会话数据
在访问页面之前预填充会话数据。这对于测试依赖会话状态、但又不想通过 UI 来建立该状态的功能很有用。
import { test } from '@japa/runner'
test.group('Onboarding', () => {
test('resume onboarding from step 3', async ({ visit, browserContext, route }) => {
/**
* 在访问页面之前设置会话数据。
* 页面会读取此状态并相应恢复。
*/
await browserContext.setSession({
onboarding: { currentStep: 3, completedSteps: [1, 2] }
})
const page = await visit(route('onboarding'))
await page.assertTextContains('h1', 'Step 3')
})
})
设置闪现消息
闪现消息是仅在下一次请求期间持久化的会话数据。设置它们可以测试你的 UI 如何展示通知或验证错误。
import { test } from '@japa/runner'
test.group('Notifications', () => {
test('display success notification', async ({ visit, browserContext, route }) => {
await browserContext.setFlashMessages({
success: 'Your changes have been saved'
})
const page = await visit(route('dashboard'))
await page.assertTextContains('.notification', 'Your changes have been saved')
})
})
读取会话与闪现消息
验证你的应用是否将预期的数据写入了会话。
import { test } from '@japa/runner'
test.group('Shopping cart', () => {
test('add item to cart stored in session', async ({ visit, browserContext, route }) => {
const page = await visit(route('products.show', { id: 1 }))
await page.getByRole('button', { name: 'Add to cart' }).click()
const session = await browserContext.getSession()
const flashMessages = await browserContext.getFlashMessages()
})
})
另请参阅
- Japa browser client 获取完整的断言 API
- Playwright locators 获取高级的元素选择策略