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

92 阅读9分钟

getting-started-bun-react.png

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

基础库:bun.sh testing-library/reactaxios-mock-adapter>

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

🤷‍♂️ 测试背景

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

image.png

—— dzone.com/articles/fa…

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

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

⏰ 什么时候开始做集成测试

当你的应用已经稳定(没有太大的功能或 UI 变动了),而且是重要应用,可以考虑开始写集成测试了。单元测试开发过程即可开始写。

当你作为一个新人接手已有项目的时候除了阅读代码和文档,不妨通过补测试来了解功能。

更重要的是完善的测试能够保护自己不会因为功能不熟悉导致不起眼的修改影响到重要的主流程!

👩‍🔬 测试对象

我们的应用是一个类似 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 - 对话操作
  • 编辑对话:断言服务端请求入参正常,页面原地编辑流式输出对话、完成后本地存储原地修改一条记录
  • ……

bun icon 测试手段

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

🤹 测试难点

  • SSE 如何 mock?
  • SSE 如何断言逐字输出效果?
  • DOM 节点太多如何更方便的断言?
  • CI 服务器和本地运行输出结果可能不一致,开发者 A 和 B 运行结果也有同样问题。

🏁 开始测试

1. 安装 bun

pnpm i -g bun

修改 package.json

  scripts: {
    "test": "bun test",
    "coverage": "bun test --coverage",
    "test:staged": "bun test --watch $(git status -s | awk '{print $2}')",
  }

解释下 test:staged

独门秘籍:如果你只想测试某次修改或新增的单测文件,执行 bun test:staged 即可,这在本地测试阶段非常有用。,详见我的 package.json 中的一些有趣的脚本

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 html 原始 HTML
 * @param ignoreAttrs 要忽略或替换的属性规则
 * @returns 格式化后的 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

Bun test 常见问题

📚 参考