浏览器测试

本指南涵盖针对 hypermedia 和 Inertia 应用的端到端浏览器测试。你将学习如何:

  • 在你的测试套件中配置浏览器测试插件
  • 通过 CLI 选项控制测试执行(浏览器、有头模式、跟踪记录、慢动作)
  • 编写带有断言的基础页面访问测试
  • 在测试之间重置数据库状态
  • 使用 Playwright 选择器填写并提交表单
  • 使用录制模式快速生成测试代码
  • 在访问受保护页面之前对用户进行身份验证

概述

浏览器测试从外部对应用进行验证,其导航方式与一个真实用户完全一致。与检查孤立代码片段的单元测试不同,浏览器测试演练了整个技术栈:路由、控制器、视图、数据库查询,以及客户端交互协同工作。

对于 hypermedia 和 Inertia 应用,浏览器测试应该构成你测试套件的大部分。这些应用本质上关乎用户与渲染页面的交互,而浏览器测试直接捕捉了这一现实。当浏览器测试通过时,你高度确信该功能对用户而言确实可用。当它失败时,你捕获到了用户本将遇到的 bug。

如果你习惯了以单元测试为主的「测试金字塔」,这种方式可能感觉不同。对于服务端渲染的应用,反转这个金字塔是合理的:浏览器测试每个测试提供的价值更高,因为它们验证的是完整的用户流程,而非实现细节。

设置

浏览器测试需要在你的 tests/bootstrap.ts 文件中配置三个插件。这些插件已随官方的 Hypermedia 和 Inertia starter kit 安装并配置好。

tests/bootstrap.ts
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

在特定的浏览器中运行测试。支持的值为 chromiumfirefoxwebkit

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 对象提供了断言方法。

tests/browser/posts/index.spec.ts
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')
  })
})

这个测试会失败,因为数据库中不存在任何文章。失败消息表明该断言在等待预期内容时超时了。

Output of failing test
 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() 钩子在每个测试之后清空表,然后创建你的测试所需的特定记录。

另请参阅: 数据库测试工具 ,了解更多方法,如迁移和种子数据。

tests/browser/posts/index.spec.ts
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: '[email protected]',
      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 提交。

tests/browser/session/create.spec.ts
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('[email protected]')
    await page.getByLabel('Password').fill('secret')

    /**
     * 点击提交按钮。getByRole 通过元素的
     * ARIA 角色查找元素,使测试对
     * 标记变更更具韧性。
     */
    await page.getByRole('button').click()
  })
})

录制模式

手动编写定位器需要你在浏览器和测试文件之间反复切换。录制模式会启动一个浏览器,你的交互会被自动转换为测试代码。

创建一个新的测试文件,并调用 record 方法而非 visit。当你运行测试时,会打开一个浏览器,你可以在其中与你的应用进行交互。完成后关闭浏览器,并将生成的代码粘贴到你的测试文件中。

tests/browser/posts/create.spec.ts
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 方法为该测试中所有后续的页面访问对用户进行身份验证。

Warning

为了使身份验证在测试期间生效,需要在你的 .env.test 文件中设置 SESSION_DRIVER=memory。memory 驱动允许测试进程管理会话,而无需文件或数据库的开销。

tests/browser/posts/create.spec.ts
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: '[email protected]',
      password: 'secret',
    })

    /**
     * 为该浏览器上下文中的用户进行身份验证。
     * 所有后续的页面访问都将是已验证状态。
     */
    await browserContext.loginAs(user)

    const page = await visit(route('posts.create'))
    await page.assertPath('/posts/create')
  })
})

浏览器上下文提供了在测试期间读写 cookie、会话和闪现消息的方法。当你的应用行为依赖存储的状态时,这些方法很有用。

根据 cookie 的存储方式,提供了三种方法。

tests/browser/preferences.spec.ts
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 值,以验证你的应用是否正确设置了它们。

tests/browser/preferences.spec.ts
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 来建立该状态的功能很有用。

tests/browser/onboarding.spec.ts
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 如何展示通知或验证错误。

tests/browser/notifications.spec.ts
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')
  })
})

读取会话与闪现消息

验证你的应用是否将预期的数据写入了会话。

tests/browser/cart.spec.ts
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()
  })
})

另请参阅