用 bun 给 AI 应用做自动化集成测试

10 阅读15分钟

getting-started-bun-react.png

关键词:bun test、React 组件测试、集成测试、单元测试、snapshot test、回归测试、SSE 测试

基础库:@testing-library/reactaxios-mock-adapter

本文的集成测试是指测试某个页面内多个组件之间的通信和交互,通过断言渲染(DOM 节点)请求入参state(state 持久化在 localStorage 故只需断言存储即可)是否符合预期来保障代码的正确性。

🤷‍♂️ 测试背景

错误和故障出现得更快,错误被更早检测到,这样能更容易重现和更快地修复。更少的错误和缺陷将投入生产,从而产生更高质量的软件。

image.png

—— dzone.com/articles/fa…

这张图展示的是软件工程中著名的“缺陷修复成本曲线”(Defect Cost Curve),它描述了在软件开发的不同阶段发现并修复缺陷的成本变化趋势。图中显示,随着软件开发过程的推进,从需求收集到设计、开发、单元测试、功能/系统测试、用户验收,直至产品上线,发现并修复缺陷的成本会逐渐增加。

它强调了早期发现和修复缺陷的重要性,因为越早发现和修复缺陷,成本就越低。可以帮助团队理解及时测试和质量保证的价值。

测试对象

我们的应用是一个类似 DeepSeek R1 的具备深度思考能力的 AI 对话 PC 端 React 应用。

首页进入后展示历史多轮对话(存储在 localStorage),每一轮对话有复制和编辑等行动点,答案区域分几块:

  1. 深度思考(多种状态)
  2. 思考内容
  3. 回答正文
  4. 本次对话的 token 用量和性能数据等
  5. 行动点:复制、重试等按钮。

底部是问题输入框,点击按钮可发送。

image.png

简单原型图,实际功能更复杂

测试目标

为了保障应用的稳定性,防止某次迭代影响应用主要功能,计划对主流程进行自动化测试。

  • 当做主流程的回归测试。
    • 如果基础测试挂了,说明某次修改影响到了主流程。
    • 如果线上遇到严重的或者影响主流程的 bug,需补充测试用例。
  • 主流程覆盖率 60+:即主要页面的函数以及行覆盖率 60%+。
  • 接入 CI:每次部署前自动执行回归测试

测试范围和计划

  • AI 对话页面
  • AI 历史页面
  • ……

覆盖主流程、不增加维护成本的情况下尽量覆盖更多细节。以对话页面为例:

  • 首屏历史对话渲染正常(对话状态正常:正常完成、用户中断、报错)
    • 对应单元测试:renders Chat App - 成功从 localStorage 渲染首屏历史对话
  • 输入对话:断言服务端请求入参正常、页面流式输出一条新的对话中记录、完成后本地存储新增一条记录
    • 对应单元测试:renders Chat App - 对话操作
  • 编辑对话:断言服务端请求入参正常,页面原地编辑流式输出对话、完成后本地存储原地修改一条记录
  • ……

测试手段

页面级别的多个组件的集成测试。使用 RTL 结合 bun(速度 >> vitest)。

测试难点

  • SSE 如何 mock?
  • SSE 如何断言逐字输出效果?
  • DOM 节点太多如何更方便的断言?
  • CI 服务器和本地运行输出结果可能不一致。

🏁 开始测试

1. 安装 bun

pnpm i -g bun

2. 设置环境

因为需要运行 react 组件测试、需要 mock document、window、location 故需要配置 bunfig.toml

我的配置文件

修改自 bun.sh/guides/test…

bunfig.toml:

[test]
preload = ["./tests/happydom.ts", "./tests/testing-library.ts"]
coverageSkipTestFiles = true
coverageThreshold = { functions = 0.56, lines = 0.69 } # 覆盖率阈值
❯ tree tests
tests
├── formatHTML.ts
├── happydom.ts
├── matchers.d.ts
└── testing-library.ts

tests/happydom.ts:

import { GlobalRegistrator } from '@happy-dom/global-registrator'

GlobalRegistrator.register()

tests/testing-library.ts:

import { afterEach, expect } from 'bun:test'
import { cleanup } from '@testing-library/react'
import * as matchers from '@testing-library/jest-dom/matchers'

// @ts-expect-error
expect.extend(matchers)

// Optional: cleans up `render` after each test
afterEach(() => {
  cleanup()
})

tests/matchers.d.ts:

import type { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers'

// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { Matchers, AsymmetricMatchers } from 'bun:test'

declare module 'bun:test' {
  type Matchers<T> = TestingLibraryMatchers<typeof expect.stringContaining, T>
  // eslint-disable-next-line @typescript-eslint/no-empty-interface
  interface AsymmetricMatchers extends TestingLibraryMatchers {}
}

tests/formatHTML.ts 该文件作用后续会讲到,暂时你可以认为不需要。

3. 写单元测试

我们将遵循 Arrange-Act-Assert(AAA)模式来书写单元测试。

3.1 导入组件 #Arrange

假设我们要给 /openchat 页面做测试,对应的组件为 openTalk

import Talk from './pages/openTalk'

const PATH_TO_TEST = '/openchat'

function OpenApp() {
  return (
    <MemoryRouter initialEntries={[PATH_TO_TEST + '?modelId=1553']} initialIndex={0}>
      <Routes>
        <Route path={PATH_TO_TEST} element={<Talk />} />
      </Routes>
    </MemoryRouter>
  )
}

代码解读:

因为组件用到了 useSearchParams 需要将其包裹到 Router 内,否则会报错。react-router-dom 推荐测试使用 MemoryRouter,而且也只能使用,否则默认渲染的是 / 根路径(尝试过使用其他手段无法进入目标页面,比如 location.href/pathname,useNavigation 等)。

  • 通过 MemoryRouter 包裹组件。
    • 配置 initialEntries 直入测试页面,并且通过 ?xxx 带入 search params。
    • initialIndex 默认 0 可省略。
  • PATH_TO_TEST 后续会用到,常量保存下。

3.2 mock #Arrange

  1. 首页历史对话存储在 localStorage,故需 mock。
test('renders Chat App - 成功从 localStorage 渲染首屏历史对话', async () => {
  // 因为代码内使用了 location.pathname 而非 useLocation 故这里使用 window.location.href
  window.location.href = `http://localhost:3002${PATH_TO_TEST}?modelId=1553`
  
  window.localStorage.setItem('open_chat_key', 'xx-e68c-4b2f-948e-xx')
  window.localStorage.setItem('OPEN_CHAT_DATA', JSON.stringify(mockedHistory))
})

代码解读:

  • 我们简单通过 localStorage.setItem 设置 mock 数据。
  • 细心的读者会发现这里有个小问题,存在重复代码:window.location.hrefinitialEntries 都设置了 path。这是因为代码内使用了 location.pathname 而非 useLocation 故这里需要再手动设置 window.location.href所以推荐大家使用 useLocation 而非 window.location,这才是最佳实践
  1. mock 请求。通过 axios-mock-adapter mock 首次渲染的必要接口(因为项目使用 axios 发请求,故需引入 adapter,提供了非常容易且灵活的方式来 mock 任何接口)。
import MockAdapter from 'axios-mock-adapter'

test('renders Chat App - 成功从 localStorage 渲染首屏历史对话', async () => {
  const mock = new MockAdapter(axiosInstance)

  mock.onGet('/api/foo/v1/models').reply((config) => {
    expect(config.headers?.authorization).toBe('Bearer xx-e68c-4b2f-948e-xx')

    return [
      200,
      models(),
      {
        'content-type': 'application/json',
      },
    ]
  })

代码参考:coderscratchpad.com/mocking-axi… 虽然 bun test 也导出了 vi 可以用来 mock 模块,但若直接 mock axios 模块则需要写很多路由逻辑,才能准确 mock 某一个接口。不使用 adapter,只能泛泛的 mock get / post 请求,如果不想安装 adapter 详见 How to Mock Axios with Vitest 和对应 github 源码

代码解释:

  • mock.onGet('/api/foo/v1/models') 针对性 mock get /api/foo/v1/models
  • 断言请求入参加了 authorization 头部(我们是通过拦截器加入的头部,预期所有接口都必须有该头部)
  • reply 返回值是元祖:[StatusCode, ResponseBody, ResponseHeaders]

3.3 渲染 #Act

await act(() => {
  return render(<OpenApp></OpenApp>)
})

我们的应用渲染存在异步状态变化,需要从请求获取数据然后更新 state 故需要加 act,更多详见 secrets of the act(...) api

若不加React 将报错,报错原因是测试结束后仍然有异步更新存在。(最佳实践是先不加,使用异步 query 获取元素,如果仍然报错再加)。

3.4 断言 #Assert

我们将断言三部分:首次渲染 DOM 符合预期、输入问题点击发送后接口入参符合预期、再次渲染 DOM 符合预期:页面新增对话符合 mock 答案、本地存储新增一轮对话。

首次渲染 DOM 符合预期

假设我们已经 mock 了 5 轮历史对话到本地存储:

// 存入 5 轮历史对话
window.localStorage.setItem('OPEN_CHAT_DATA', JSON.stringify(mockedHistory))

1. 预期页面将展示 5 轮 历史对话:

image.png

// 1. 断言有 5 轮 历史对话
let chatElements = await screen.findAllByLabelText('chat-box')
expect(chatElements.length).toBe(5)

代码解释:

findAllByLabelText:因为我们标注了 aria-label,故可以通过 LabelText系列 API 找到这些 DOM 节点。其次因为是异步渲染的需要 await + findXxx 异步系列 API。阅读更多断言技巧

测试让代码变得更好! 这里有个有趣的小点,刚开始代码没有加 aria-label,导致无法通过 RTL API 找到这些节点(当然从能力上来说用 document.querySelectAll 也行,但不建议)加了之后语义增强了对读屏软件更友好了 aria friendly。所以两个结论:

  1. 尽量使用 RTL 提供的 API
  2. 测试不仅保证逻辑也让应用更好用 make use of semantic queries to test your page in the most accessible way

2. 预期每轮对话都有对应状态的深度思考

接下来断言相应存在 5 轮深度思考,且状态分别为:三轮完成两轮用户主动停止。

// 1.1 断言正常展示了 5 轮历史对话的深度思考时间
screen.getByText('深度思考完成(用时 0 秒)')
screen.getByText('深度思考完成(用时 11 秒)')
expect(screen.getAllByText('已停止思考').length).toBe(2) // 两轮用户停止
screen.getByText('深度思考完成(用时 18 秒)')

其实下面的断言方式更好,因为能准确断言数量甚至精确的位置排列:

const deepThinkBtns = screen.getAllByRole('button', { name: /思考/ })

// 断言数量
expect(deepThinkBtns.length).toBe(5)

// 断言先后顺序
expect(deepThinkBtns.map((btn) => btn.textContent)).toEqual([
    '深度思考完成(用时 0 秒)',
    '深度思考完成(用时 11 秒)',
    '已停止思考',
    '深度思考完成(用时 18 秒)',
    '已停止思考',
])

性能问题:getAllByRole 耗时 2.64s,修改成 getAllByText 耗时下降到 0.76ms

- const deepThinkBtns = screen.getAllByRole('button', { name: /思考/ })
+ const deepThinkBtns = screen.getAllByText(/思考/)

3. 给首页所有节点做断言

写到这里大家发现,我们的 AI 应用页面元素和操作点很多,如果要一一断言着实麻烦,有没有更方便快速的办法?答案是 snapshot test

snapshot 翻译即“快照”,就是给测试对象在某个时刻“拍一张照”。这个“对象”可以是字符串(HTML、普通字符串)、数组或对象,任何 JS 值都行。当要断言复杂 UI 组件、大对象的时候特别有用。想一想如果要断言一个复杂组件的 HTML 结构,若要逐个写 screen.getByText ... 是不是想一下都头皮发麻 😵。

假设我们的应用结构如下,想给其做快照测试。

image.png

代码如下:

import { toStableHTML } from '../tests/formatHTML'

// 直接断言整个渲染后的 HTML 省去逐个断言 DOM 元素
const main = screen.getByRole('application')
expect(main.innerText).toMatchSnapshot('innerText')

const formatted = await toStableHTML(main.innerHTML)
expect(formatted).toMatchSnapshot('formatted innerHTML')

代码解释

  1. 我们首先对 innerText 然后是 innerHTML 做快照测试。这是为什么? 一般来说 innerHTML 已经覆盖了 innerText,只需做 HTML 快照即可,那为何要先 innerText,这是因为“快速试错退出原则 | Fail Fast”,innerText 长度更短,结构更简单,一旦失败可快速定位,无需继续往下执行,减少排查和测试执行成本。

  2. 那为什么选择 innerText 而非 textContent 因为前者会考虑布局 CSS 的影响,返回浏览器渲染的样子,即用户看到的样子。我们对比下二者对同一个节点的返回值就会一目了然:

render 差别:

image.png

一个是 3 行,一个是 6 行,innerText 更接近用户看到的,测试优选 👍

性能优化:

目标是一个长度为 54239 的 HTML,其 innerText 长度 3488,textContent 长度 3471

// @testing-library/react v13.4.0
// 第一次
[0.26ms] textContent
[2.38s] innerText costs
// 第二次
[0.21ms] textContent costs
[2.01s] innerText costs
// 升级 v16.3.0 后
// 第一次
[0.19ms] textContent costs
[2.13s] innerText costs
// 第二次
[0.25ms] textContent costs
[2.03s] innerText costs

这里有个反转,考虑到二者毫秒级和秒级的巨大差距!,而且升级到最新版本也没有优化(Slow getByRole leads to test timeouts #1213waitFor + getByRole causing severe delays #820),暂时使用性能更好的 textContent 🚀。

总结:如果性能不是瓶颈优先 innerText,否则 textContent

  1. 为什么断言前要用 toStableHTML 处理? 有两点原因,直接用 HTML 返回的是“压缩饼干”一行代码几乎无法 diff,故需要 format,其次因为 img 在本地 Windows 下执行单测渲染的 src 将会是本地磁盘地址比如 <img src="D:\\workspace\\我的用户名\\src\\assets\\user-profile.png />,如果是 CI 流水线 Linux 下将会是 <img src="/app/src/assets/user-profile.png />,会导致本地运行通过的单测 CI 失败,其次如果每个人的本地工程目录不一样,在你这运行成功的 case 在他人电脑就会执行失败。

toStableHTML 是为了稳定测试,通过 format + filter 手段。

如果感兴趣可以展开看看 toStableHTML 的实现,原理是先用 parse5 处理掉 src,然后通过 prettier 格式化。你也许也会用得上。

toStableHTML 实现
import prettier from 'prettier'
import parse5 from 'parse5'
import path from 'path'

/**
 * - true: 过滤掉该属性
 * - false: 保留该属性
 * - string: 替换该属性值
 */
type IFilter = (node, attr) => true | false | string

/**
 * 使用 parse5 过滤 HTML 属性,再用 Prettier 格式化
 * @param {string} html 原始 HTML
 * @param {Array<string|RegExp>} ignoreAttrs 要忽略的属性规则
 * @returns {string} 格式化后的 HTML
 */
function formatAndFilter(
  html: string,
  ignoreAttrs: (IFilter | string | RegExp)[] = [],
): Promise<string> {
  return format(filter(html, ignoreAttrs))
}

export async function toStableHTML(html: string): Promise<string> {
  const formatted = await formatAndFilter(html.trim(), [
    (node, attr) => {
      const isSrcDiskPath =
        node.tagName === 'img' &&
        attr.name === 'src' &&
        (/^[a-zA-Z]:/.test(attr.value) || attr.value.startsWith('/app/'))

      if (isSrcDiskPath) {
        // D:\\workspace\\foo\\src\\assets\\user-2.png
        // to user-2.png
        // /app/src/assets/submitIcon.png to submitIcon.png
        return '...DISK_PATH/' + path.basename(attr.value)
      }

      return false
    },
  ])

  return formatted.trim()
}

async function format(html: string): Promise<string> {
  const formatted = await prettier.format(html, {
    parser: 'html',
    htmlWhitespaceSensitivity: 'ignore',
  })

  return formatted
}

// const prettier = require('prettier')

function filter(html: string, ignoreAttrs: (IFilter | string | RegExp)[] = []): string {
  // 1. 用 parse5 解析 HTML
  const document = parse5.parseFragment(html)

  // 2. 遍历 AST,移除要忽略的属性
  const removeIgnoredAttrs = (node) => {
    if (node.attrs) {
      node.attrs = node.attrs.filter((attr) => {
        const attrKey = `${node.tagName}.${attr.name}`

        return !ignoreAttrs.some((rule) => {
          if (typeof rule === 'string') {
            return rule.includes('.')
              ? attrKey === rule // 匹配 "tag.attr"
              : attr.name === rule // 匹配 "attr"
          }

          if (typeof rule === 'function') {
            const action = rule(node, attr) // 自定义匹配

            if (typeof action === 'boolean') return action

            attr.value = action // 自定义替换

            return false
          }

          return rule.test(attrKey) // 正则匹配
        })
      })
    }

    if (node.childNodes) {
      node.childNodes.forEach(removeIgnoredAttrs)
    }
  }

  removeIgnoredAttrs(document)

  // 3. 将 AST 重新序列化为 HTML
  const filteredHTML = parse5.serialize(document)

  return filteredHTML
}

toStableHTML 处理前:

<img style="width: 3rem; height: 3rem" class="cursor-pointer p-1" src="D:\\workspace\\foo\\src\\assets\\bot-profile.png" alt="alive ai chat bot" />

toStableHTML 处理后:

<img
    style="width: 3rem; height: 3rem"
    class="cursor-pointer p-1"
    src="...DISK_PATH/bot-profile.png"
    alt="alive ai chat bot"
/>

至此我们已经完整测试完一个 AI 应用的整个渲染过程,接下来将测试对话过程,将更加精彩刺激!

3.5 第二部分:测试对话过程

新建一个单测文件 openApp.chat.test.tsx

1. mock 对话 SSE 接口

mock 内部可以断言入参,入参包含两部分:请求头、请求体。

mock.onPost('/api/foo/v1/chat/completions').reply((config) => {
    expect(config.headers?.authorization).toBe('Bearer xx-e68c-4b2f-948e-xx')
    expect(JSON.parse(config.data)).toMatchSnapshot('completions 接口入参')
})

注意请求体在 config.data 中且会被序列化故需要反序列化成 json 方便对比。这里我们再次使用了 snapshot,因为请求体有 116 行,如果你的请求体不大可直接用 toEqual。

这里教大家一个测试小技巧,先写 toMatchInlineSnapshot() 单测运行后会自动填充,然后在此基础上修改成对象和 toEqual 方便后续修改,详见大对象断言 #单测 🧪 ~ 掘金

mock SSE 接口。重头戏来了。通过观察业务代码发现使用的是 onDownloadProgress 来流式接收数据。故我们可以对症下药。

首先将返回值切成 chunks:一个 chunk 是一段携带数据的字符串 data: { ... } chunk 之间用 \r\n\r\n 隔开。 然后利用 setInterval 每隔一段时间往后增加一段,最后如果发送完毕则结束 promise

技巧:axios-mock-adapter v1.7.0 之后支持返回 promise。

\n\n 还是 \r\n\r\nMDN 示例采用 \n\n,但 HTML 规范定义了多种,\r\n\r\n 也符合规范。故要看服务端的实现。

Lines must be separated by either a U+000D CARRIAGE RETURN U+000A LINE FEED (CRLF) character pair, a single U+000A LINE FEED (LF) character, or a single U+000D CARRIAGE RETURN (CR) character. end-of-line = ( cr lf / cr / lf )

image.png

完整代码

// openApp.chat.test.tsx

test('renders Chat App - 对话操作', async () => {
  // 2. 断言输入新问题
  mock.onPost('/api/foo/v1/chat/completions').reply((config) => {
    const chunks = genStreamingResponse() // ["data: {}", "data: {}"]
    expect(config.headers?.authorization).toBe('Bearer xx-e68c-4b2f-948e-xx')

    // console.log('config.data:', config.data)

    // 断言问答接口入参
    expect(JSON.parse(config.data)).toMatchSnapshot('completions 接口入参')

    console.time('completions 接口响应')

    return new Promise((resolve) => {
      let currentChunk = 0
      let responseText = ''

      const interval = setInterval(() => {
        if (currentChunk < chunks.length) {
          responseText += chunks[currentChunk]

          config.onDownloadProgress!({
            loaded: responseText.length,
            total: undefined,
            event: {
              target: {
                responseText: responseText,
              },
            },
            bytes: 0,
          })
          currentChunk++
        } else {
          clearInterval(interval)
          console.timeEnd('completions 接口响应')

          resolve([
            200,
            responseText,
            {
              'Content-Type': 'text/event-stream; charset=utf-8',
              'Cache-Control': 'no-cache',
              Connection: 'keep-alive',
            },
          ])
        }
      }, 0)
    })
  })
})

function genStreamingResponse(): string[] {
  return `data: {"id":"ad51c176-e1b3-4412-bab5-aea908fc3c35","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}],"model":"deepseek_r1_7b","object":"chat.completion.chunk"}

: ping - 2025-05-08 05:46:34.607265+00:00

data: {"id":"ad51c176-e1b3-4412-bab5-aea908fc3c35","choices":[{"index":0,"delta":{"content":"<think>"},"finish_reason":null}],"model":"deepseek_r1_7b","object":"chat.completion.chunk"}
data: {"id":"ad51c176-e1b3-4412-bab5-aea908fc3c35","choices":[{"index":0,"delta":{"content":"."},"finish_reason":null}],"model":"deepseek_r1_7b","object":"chat.completion.chunk"}

data: {"id":"ad51c176-e1b3-4412-bab5-aea908fc3c35","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"model":"deepseek_r1_7b","object":"chat.completion.chunk","usage":${JSON.stringify(genUsage())},"performance":${JSON.stringify(genPerformance())}}

data: [DONE]

`
    .split('\n\n')
    .map((chunk) => `${chunk}\r\n\r\n`)
}
2. 模拟用户输入问题
// 2.1 输入问题
const question = 'who are you?'
const input = screen.getByRole('textbox')

fireEvent.change(input, { target: { value: question } })

// 2.2 点击发送
const sendButton = screen.getByLabelText('send')
fireEvent.click(sendButton)
3. 断言输入后开启深度思考,状态扭转符合预期

页面新增一条回复。

await screen.findByText('深度思考中...', {}, { timeout: 1e3 })
chatElements = screen.getAllByLabelText('chat-box')
expect(chatElements.length).toBe(5 + 1)

编码小技巧:写成 5 + 1 比写 6 代码可读性更好。

“思考中”变成“思考完成”

import { within } from '@testing-library/react'

const utils = within(chatElements.at(-1)!)

await utils.findByText('深度思考中...')
await utils.findByText('停止生成')
await utils.findByText(/已停止生成/, {}, { timeout: 4e3 })

思考过程耗时超过 findByText 的默认超时时间 1s,故需要设置 timeout。

其次这里用到了比较少见的 within,这也是一个技巧,通过将 query 范围限定在最后一个元素这样我们的 query 才是精准的 🎯,最重要是能利用 RTL 提供的 API,当然也能小小提升性能。

4. 断言回答完毕,页面渲染了预期答案
chatElements = screen.getAllByLabelText('chat-box')
expect(chatElements.at(-1)!.innerText.split('\n')).toEqual([
    `who are you?`,
    `深度思考完成(用时 2 秒)`,
    `Alright, the user just asked, "who are you?" I need to respond in a way that's clear and friendly. Since I'm an AI assistant created by DeepSeek, I should mention that. I should explain that I'm designed to help with answering questions, providing information, and assisting with various tasks. It's important to convey that I don't have personal experiences or emotions, but I'm here to help as much as I can. I should keep the tone warm and welcoming to encourage the user to ask more questions or engage further.`,
    '',
    `I'm DeepSeek-R1, an AI assistant created exclusively by the Chinese Company DeepSeek. I'm at your service and would be delighted to assist you with any inquiries or tasks you may have.`,

    `已停止生成`,
    `[TTFT 3.24s  | 总耗时 17.19s  | 推理速度 (除去首 Token) 11.03 tokens/s]`,
    `重新生成复制`,
  ])

  const html = await toStableHTML(chatElements.at(-1)!.innerHTML)
  expect(html.split('\n')).toEqual(expectResponseHTML())

这里我们改成 innerText 因为性能非瓶颈。其次这里对 HTML 没有使用 snapshot,因为第一长度并不长,其次 toEqual 的 diff 比 snapshot 可读性好很多,所以使用 toEqual。使用 split 也是为了方便 diff。

5. 断言回答完毕,本地存储的历史记录新增一条

断言历史记录新增一条

  const newHistories = JSON.parse(window.localStorage.getItem('OPEN_CHAT_DATA')!)
  const msgs = newHistories.chat[0].data

  expect(msgs.length).toBe(5 + 1)

断言思考时间

  const lastMsg = msgs.at(-1)
  const thinkTime = lastMsg.thinkingStopTime - lastMsg.thinkingStartTime
  
  console.log('thinkTime:', thinkTime) // 1925
  expect((thinkTime / 1000).toFixed(0)).toBe('2') // 2s

断言新增内容

  const lastMsg = msgs.at(-1)

  expect(lastMsg).toEqual({
    question_id: expect.any(Number),
    question,
    imagesInQuestion: [],
    answer_id: expect.any(Number),
    answer:
      "<think>\nAlright, the user just asked, \"who are you?\" I need to respond in a way that's clear and friendly. Since I'm an AI assistant created by DeepSeek, I should mention that. I should explain that I'm designed to help with answering questions, providing information, and assisting with various tasks. It's important to convey that I don't have personal experiences or emotions, but I'm here to help as much as I can. I should keep the tone warm and welcoming to encourage the user to ask more questions or engage further.\n</think>\n\nI'm DeepSeek-R1, an AI assistant created exclusively by the Chinese Company DeepSeek. I'm at your service and would be delighted to assist you with any inquiries or tasks you may have.",
    error: '',
    created_time: expect.stringMatching(/^\d{13}$/),
    status: 2,
    firstWordResponded: true,
    hasThinkTag: true,
    // 1746683201551
    thinkingStartTime: expect.any(Number),
    // 1746683211676
    thinkingStopTime: expect.any(Number),
    usage: genUsage(),
    performance: genPerformance(),
  })

这里使用了一些模糊断言,因为有些字段是用时间做 id(比如 1747383115240),故为了保障每次单测都能过,对这些字段仅断言是数字类型 expect.any(Number),对字符串虽然也可以类似(expect.any(String))但是我们还是多断言了一步,必须是 13 位数字字符串 expect.stringMatching(/^\d{13}$/)。虽然如此,但是针对重要字段比如 thinkingStartTimethinkingStopTime,因为页面会显示思考时间,我们还做了相对更精准的断言,详见上方的“断言思考时间”。

更多模糊断言,详见模糊断言 #单测 🧪 ~ 掘金

至此正文部分结束。当然我们还缺了一部分,对异常流程的断言,比如断言非法 API Key 输入框置灰,并且页面提示 message.warn“请更换可用的 API Key”,这些都是可以用单元测试完成的。为了限制本文长度这一部分就不在此展开了。

📅 未来计划

  • 接入 CI 流水线。每次 MR 自动执行,保证“脏代码”不会被合入稳定分支。
  • 验证价值:收集通过自动化测试发现的 bug。

😎 感想

整个使用下来了,bun 直接能运行 tsx,不需要复杂配置,且测试执行速度快!意味着能快速反馈循环确实能提升幸福感,但是 bun test 也有自己的局限。 一些在其他测试框架中常见的高级功能,如代码覆盖率报告,可能在 Bun 测试工具中尚未完全实现 Implement a html coverage reporter for bun test #4099。还有打印一个 element 会出现无限打印停不下来的情况。

❓ FAQ

1. RTL 可以做哪些性能优化?

  1. getAllByRole 改成 getAllByText,性能从 2.6s 优化到 <1ms。

  2. 我们对文字断言也做了性能优化,从 1s+ 优化到 <1ms 内,这是因为我们的 HTML 很大性能是瓶颈,如果你的 HTML 不是很大,优先 innerText 而非 textContent,原因上文有讲到。

  3. 还可以继续对 parse HTML 优化,使用性能更好的 node-html-parser,估计可以从几百毫秒优化到几十毫秒,但是收益相对不大暂时不优化。

  4. 利用 within 将 query 范围限定在某一个元素内,让 query 最精准,能提升性能,最重要是能利用 RTL 提供的 API。

2. 为何我的 mock adapter 不生效

假设你的业务代码如下,封装了一个 axios 实例用来做返回值的拦截处理:

// src/utils/http.ts
import axios from 'axios'

class Request {
  // axios 实例
  instance: AxiosInstance

  constructor(config: AxiosRequestConfig) {
    this.instance = axios.create(config)

    // 全局响应拦截器保证最后执行
    this.instance.interceptors.response.use(
    ...
  }
  
  request<T, P>(config: AxiosRequestConfig): Promise<T | P> {
    return this.instance.request(...)
  }
}

const config: AxiosRequestConfig = {
  // 默认地址
  // baseURL: '/neuron',
  baseURL: '',
  // 设置超时时间
  timeout: 30000,
  // 跨域时候允许携带凭证
  withCredentials: true,
}

const instance = new Request(config)

而你的 mock 是这么写的:

// foo.test.ts
const axios = require('axios');
const MockAdapter = require('axios-mock-adapter');

const mock = new MockAdapter(axios);

那么 mock 是不会生效的,因为业务代码使用的是 axios 实例,而你 mock 的是整个 axios 模块。 怎么解决?

第一步导出实例

// src/utils/http.ts

// 请勿删除 export 否则单元测试无法 mock axios
export const instance = new Request(config)

第二步

// foo.test.ts
import { instance } from './utils/http'

const mock = new MockAdapter(instance.instance);

3. error: useLocation() may be used only in the context of a <Router> component.

将组件包裹在 Router 中即可,单测推荐使用 MemoryRouter

4. 什么是 snapshot test 和其变种 inline snapshot 以及什么时候用?

snapshot 翻译即“快照”,就是给测试对象在某个时刻“拍一张照”。这个“对象”可以是字符串(HTML、普通字符串)、数组或对象,其实任何 JS value 都行。当要断言复杂 UI 组件、大对象的时候特别有用。想一想如果要断言一个复杂组件生成到 HTML 结构,若要逐个写 screen.getByText ... 是不是想一下都头皮发麻。

故当输出结果很长手动写特别麻烦的时候可以借助 snapshot,测试框架会自动在 __snapshot__文件夹下生成对应测试过文件的 *.snap 文件。如果是 inline snapshot 则不会生成文件而是在调用处自动生成。那 inline 有什么用呢?首先 inline 一般用于不是很长的断言,其次 inline 后我们可以很方便的将其改成 toEqual 等断言方式,为什么改?修改后维护和 diff 更方便。

我们来看看一个 snapshot test 的例子:

假设有 openApp.test.tsx

test('renders Chat App - 成功从 localStorage 渲染首屏历史对话', async () => {
  const formatted = await toStableHTML(screen.getByRole('application').innerHTML)
  expect(formatted).toMatchSnapshot('formatted innerHTML')
 ...

执行 bun test openApp.test.tsx 后自动在 __snapshots__ 文件夹下生成 snapshot 文件 openApp.test.tsx.snap

❯ tree src/__snapshots__ 
__snapshots__
└── openApp.test.tsx.snap

内容

// Bun Snapshot v1, https://goo.gl/fbAQLP

exports[`renders Chat App - 成功从 localStorage 渲染首屏历史对话: formatted innerHTML 1`] = `
"<div class="headerDiv">
  <div class="headerLeft">
    <img src="...DISK_PATH/loginTitleIcon.png" />
    ...

解读:每一个 snapshot 断言的 title 由三部分组成,

  1. renders Chat App - 成功从 localStorage 渲染首屏历史对话: test title
  2. formatted innerHTML toMatchSnapshot 的入参
  3. 1 自动生成,防止同名冲突

更多阅读:bun.sh/docs/test/s…

5. snapshot 失败了但是看不出问题

长文本 snapshot 对比很不明显,这也是 bun 需要改善的地方。

两种方法

  1. 改成数组:split('\n')
  2. 增加 --update-snapshots 比如 bun test src/openApp.test.tsx --update-snapshots。更新后 git diff 即可,详见 bun.sh/docs/test/s…

6. Warning: An update to Provider inside a test was not wrapped in act(...).

When writing UI tests, tasks like rendering, user events, or data fetching can be considered as “units” of interaction with a user interface. React provides a helper called act() that makes sure all updates related to these “units” have been processed and applied to the DOM before you make any assertions.

在编写 UI 测试时,诸如渲染、用户事件或数据获取等操作都可以视为与用户界面的“交互单元”。React 提供了一个名为 act() 的辅助工具,确保在进行任何断言之前,所有与这些“交互单元”相关的更新都已处理并应用到 DOM。

act() 的名称来源于 Arrange-Act-Assert(AAA)模式

react.dev/reference/r…

断言之前先 await act 让状态变化先应用到 DOM 上,这样才能方便做 DOM 的断言。

import { render, screen } from '@testing-library/react'

- render(<OpenApp></OpenApp>)
+ await act(() => {
+   return render(<OpenApp></OpenApp>)
+ })

7. bun test toMatchSnapshot 报错 “panic(main thread): integer overflow”

如果针对整个容器断言,bun 将崩溃。

const { container } = render(<OpenApp></OpenApp>)

expect(container).toMatchSnapshot()

解法:改成 HTML 或 textContent。

详细报错和排查过程:

❯ bun test src/openApp.test.tsx
bun test v1.1.29 (6d43b366)

src\openApp.test.tsx: Warning: KaTeX doesn't work in quirks mode. Make sure your website has a suitable doctype.

============================================================ Bun v1.1.29 (6d43b366) Windows x64 (baseline) Windows v.win10_fe CPU: sse42 avx avx2 Args: "E:\pnpm\global\5\node_modules\bun\bin\bun.exe" "test" "src/openApp.test.tsx" Features: jsc bunfig dotenv transpiler_cache(30) tsconfig_paths(2) tsconfig(24) Builtins: "node:assert" "node:buffer" "node:child_process" "node:constants" "node:crypto" "node:events" "node:fs" "node:http" "node:https" "node:net" "node:os" "node:path" "node:perf_hooks" "node:stream" "node:stream/web" "node:string_decoder" "node:tty" "node:url" "node:util" "node:util/types" "node:vm" "node:zlib" Elapsed: 3523ms | User: 3015ms | Sys: 1484ms RSS: 0.55GB | Peak: 0.55GB | Commit: 0.70GB | Faults: 170929

panic(main thread): integer overflow oh no: Bun has crashed. This indicates a bug in Bun, not your code.

To send a redacted crash report to Bun's team, please file a GitHub issue using the link below:

bun.report/1.1.29/et16…

bun --version 1.1.29,尝试升级到 1.2 仍然崩溃,已经提交 issue github.com/oven-sh/bun…

升级过程

首先查看 bun 是被谁安装的:

which bun 
/e/pnpm/bun
❯ pnpm i -g bun

 EPERM  EPERM: operation not permitted, open 'e:\pnpm\bun'
 
 ❯ pnpm self-update
 EBUSY  EBUSY: resource busy or locked, open 'E:\pnpm\pnpm'

但是报错无权限。改成用 administrator 角色打开 terminal 虽然可以安装成功,但是

❯ pnpm i -g bun
 WARN  4 deprecated subdependencies found: fstream@1.0.12, glob@7.2.3, inflight@1.0.6, rimraf@2.7.1
Already up to date
Progress: resolved 93, reused 84, downloaded 0, added 0, done
Done in 1.9s

执行 bun -v 报错:

 ❯ bun -v
/c/Program Files/nodejs/bun: line 12: C:\Program Files\nodejs/node_modules/bun/bin/bun.exe: No such file or directory

这是因为 bun 之前被 npm 安装过,需要 uninstall。如果不记得当时安装时候的 node.js 版本,可以 nvm 切换不同的 node.js 然后执行 uninstall 直到

❯ npm uninstall -g bun

removed 3 packages, and audited 2 packages in 226ms

found 0 vulnerabilities

如果还不行,进入目录删除 /c/Program Files/nodejs/bun 相关的任何 bun 文件(bun bun.exe bun.bat bun.ps1……)

再次执行 bun -v 若仍然不行。

切换安装方式

curl -fsSL https://bun.sh/install | bash

虽然我的操作系统是 Windows 10,但是因为安装了 git-bash。故仍然使用 Linux 安装方式(实在不想用 Windows 的包管理器)。

curl -fsSL https://bun.sh/install | bash

为什么可以这么做?可以看下安装脚本 bun.sh/install:

#!/usr/bin/env bash
set -euo pipefail

platform=$(uname -ms)

if [[ ${OS:-} = Windows_NT ]]; then
  if [[ $platform != MINGW64* ]]; then
    powershell -c "irm bun.sh/install.ps1|iex" # Windows 安装过程
    exit $?
  fi
fi

... # Linux 和 macOS 安装过程
uname -ms
MINGW64_NT-10.0-19045 x86_64

我们看看自己的 OS 和 platform 是什么。

echo $OS
Windows_NT

❯ uname -ms
MINGW64_NT-10.0-19045 x86_64

安装脚本有两重判断当 OS 等于 Windows_NT 但是 platformMINGW64 才会进入 Windows 安装过程。我的电脑虽然是 Windows 但是安装了 git-bash(mingw)故不满足会走到 Linux 类系统安装过程。

新问题执行 curl -fsSL https://bun.sh/install | bash 会超时,因为默认通过 github 下载安装包 github.com/oven-sh/bun…

❯ curl -fsSL https://bun.sh/install | bash

curl: (28) Failed to connect to github.com port 443 after 21044 ms: Couldn't connect to server

error: Failed to download bun from "https://github.com/oven-sh/bun/releases/latest/download/bun-windows-x64.zip"

那这个包地址是如何拼接而来的呢?如果我们能将 github 换成其镜像不就可以快速下载了吗?

替换 GITHUB 环境变量

首先 windows-x64 是根据 platform 而来的:

case $platform in
'Darwin x86_64')
    target=darwin-x64
    ;;
'Darwin arm64')
    target=darwin-aarch64
    ;;
'Linux aarch64' | 'Linux arm64')
    target=linux-aarch64
    ;;
'MINGW64'*)
    target=windows-x64 # MINGW64 会下载 windows-x64
    ;;
'Linux x86_64' | *)
    target=linux-x64
    ;;
esac

继续看:

GITHUB=${GITHUB-"https://github.com"}

github_repo="$GITHUB/oven-sh/bun"

...

if [[ $# = 0 ]]; then
    bun_uri=$github_repo/releases/latest/download/bun-$target.zip
else
    bun_uri=$github_repo/releases/download/$1/bun-$target.zip

可以发现是通过 bun_uri=$github_repo/releases/latest/download/bun-$target.zip 拼接而来。

GITHUB=${GITHUB-"https://github.com"} 这句话的意思是如果环境变量存在 GITHUB 则使用,否则兜底 https://github.com!bun 真是太贴心了,帮我们预留了“后门”,据此我们可以通过环境修改 GITHUB。

❯ GITHUB=https://xxgithubyy.zz curl -fsSL https://bun.sh/install | bash
########################################################################################################################## 100.0%
bun was installed successfully to ~/.bun/bin/bun
Run 'bun --help' to get started

xxgithubyy.zz 只是示例,大家换成自己的源

如果无法设置或者 bun 写死了怎么办?直接下载 bun.sh/installinstall.sh 然后修改代码,最后执行bash install.sh 即可。

8. 生成出来的 snapshot 一行代码,不利于阅读和 diff

比如 expect(screen.getByRole('application').innerHTML).toMatchSnapshot('innerHTML').

解法:使用本文的 toStableHTML

📚 参考