主题
三方登录与组件测试(三)
本页对应 三方登录与组件测试,共 6 段视频,约 1 小时 31 分。
学习目标
- 能够使用 Mock.js 规则为接口数据随机生成 ID、中文标题、中文内容、图片、时间及指定范围数字,并保证字段与接口文档一致。
- 能够通过浏览器请求本地 Mock 接口,验证返回 10 条随机数据且字段类型、取值范围符合预期。
- 能够安装并配置 Vitest,编写针对工具函数的测试用例,使用
test、expect、toBe断言输入与预期输出是否一致。 - 能够运行测试命令并根据通过/失败结果定位逻辑错误,说明单元测试与 TypeScript 静态类型检查在预防 Bug 上的区别。
- 能够使用组件测试工具挂载 Vue 组件,断言初始渲染内容,并验证
props变化能否驱动组件视图更新。 - 能够模拟用户点击等交互,断言组件是否触发自定义事件、事件参数是否正确,并验证事件值回传后组件状态与视图是否同步更新。
知识点
先让 mock 接口的 data 有内容
为什么需要:上一节已经把 mock 接口的基本结构搭起来了,但 response.data 还是空的。页面请求虽然能走 mock,却拿不到消息列表,业务开发仍然无法继续。
怎么想:模拟接口要尽量像真实后台,返回的数据字段必须和接口文档一致;先模拟 10 条,够列表开发调试即可。
怎么做:回到 mock/index.ts,把原来空的 data 改成数组,用 for 循环往数组里 push 10 条数据。每条数据先按接口文档准备字段:id、title、content、avatar、createTime、status、type。
易错点:字段名、字段类型、枚举范围都要和接口文档对齐;status 只能是 0 或 1,type 只能是 1、2、3;不要只改了 data 却忘了在原 mock 接口的 response 中返回它。
先用写死数据跑通链路
为什么需要:在引入随机规则前,先确认“请求能到 mock、data 能返回、页面能打印”这条链路是通的,否则后面生成随机数据时很难判断问题出在哪。
怎么想:先不追求真实,只写固定值,例如 title: '这是标题'、content: '这是内容'、avatar: 'http://'、status: 0、type: 1,createTime 先给一个时间值。
怎么做:在 for 循环里 data.push({ ... }),长度写 10。保存后刷新浏览器,打开日志,应该能看到 10 条数据。转写稿里的调试过程就是先刷新页面看日志,确认已经出现 10 条,但发现“都是写死的数据,看起来不真实”。
易错点:avatar 要给完整链接形式;createTime 要保持时间类型或接口约定格式;如果刷新后还是线上真实接口数据,要检查请求地址是否用了本地开发环境域名——mock 只在本地开发服务器里生效。
用 mockjs 生成随机真实数据
为什么需要:写死数据只能证明链路通,不能覆盖列表展示、图片展示、状态判断等场景。用 mockjs 生成随机数据,才能更接近真实后台返回。
怎么想:到 mockjs 官方文档找生成规则,先用 @ctitle 试标题,确认能生成随机中文标题,再按同样方式改其他字段。转写稿中先只改 title,刷新后看到标题变成随机中文,证明规则可用,然后继续生成 id、content、avatar、createTime、status、type。
怎么做:在 push 时使用 Mock.mock,可以传规则字符串。按转写稿整理的核心代码如:
ts
import Mock from 'mockjs'
const data: any[] = []
for (let i = 0; i < 10; i++) {
data.push({
id: Mock.mock('@id'),
title: Mock.mock('@ctitle(5,10)'),
content: Mock.mock('@ctitle(10,20)'),
avatar: Mock.mock("@image('100x100')"),
createTime: Mock.mock('@datetime'),
status: Mock.mock('@integer(0,1)'),
type: Mock.mock('@integer(1,3)')
})
}
// 再把 data 放到原 mock 接口的 response.data 中返回规则含义:@id 生成随机唯一标识;@ctitle(5,10) 生成 5 到 10 个中文字符的标题;content 用 @ctitle(10,20) 生成更长中文内容;@image('100x100') 生成 100×100 图片地址;@datetime 生成随机时间;@integer(0,1) 生成 0 或 1;@integer(1,3) 生成 1 到 3。
易错点:是 @ctitle 不是 @c title;图片尺寸写法是 @image('100x100');@integer 范围要按字段枚举写;Mock.mock 可以传字符串,也可以传对象规则,不要和 Random 方法混着写错。
刷新验证与切换到线上接口
为什么需要:生成完不能只看代码,要确认页面日志里的每条数据都符合接口文档。
怎么做:刷新浏览器,展开日志逐条检查:id 是否随机、title 和 content 是否中文、avatar 是否图片地址、createTime 是否时间、status 是否只在 0/1、type 是否只在 1/2/3。转写稿中还复制了 avatar 地址到浏览器访问,确认能看到一张 100×100 的随机图片。
易错点:如果某个字段仍然写死,检查是否只改了 title 忘了改其他字段;如果图片打不开,检查 @image 规则和尺寸写法。将来真实接口开发好后,在调用接口的地方去掉本地开发环境域名,就会正常访问线上接口;mock 相关配置和生成逻辑不要在没有确认前随意删除。
为什么需要生成更真实的 mock 数据
上一节已经把模拟接口打通了,但 data 还是空数组。为什么需要继续生成数据?因为页面开发不能只验证“接口能通”,还要验证列表渲染、长标题、长内容、头像、时间、状态和类型等不同情况。怎么写死几条数据也能看效果,但十条一模一样的数据不真实,无法支撑业务调试。所以思路是:模拟接口返回的字段必须和接口文档保持一致,先生成 10 条,再用 Mock.js 的规则把字段变成随机但受约束的数据。易错点是字段名、字段类型和取值范围不能自己乱改,否则页面按真实接口开发后会对不上。
先写死 10 条数据,确认链路
为什么先写死?因为要先确认“循环生成 10 条 → response 返回 → 页面能拿到”这条链路没问题。怎么想?在 mock/index.ts 里准备一个 data 数组,用 for 循环往里面 push 10 次,每个对象包含接口文档里的字段:id、title、content、avatar、createTime、status、type。怎么做?可以先写假数据,例如标题写“这是标题”、内容写“这是内容”、头像写一个 http:// 开头的链接、status 写 0、type 写 1。刷新浏览器后,在控制台日志里展开就能看到 10 条数据。易错点是:循环长度要写 10;push 的对象字段要和接口文档一致;status 只能是 0 或 1,type 只能是 1、2、3,不能先写超出范围的值。
用 Mock.js 生成随机字段
为什么不用写死数据?因为写死的数据看起来不真实,也无法覆盖不同状态。怎么想?到 Mock.js 官方文档里找生成规则,先搜索 @ctitle 验证中文标题,再逐个替换字段。怎么做?在 push 时调用 Mock.mock(),传入规则字符串。标题用 @ctitle(5,10),表示 5 到 10 个中文字;内容用 @ctitle(10,20),字数比标题多一些;id 用 @id;avatar 用 @image(100x100) 生成 100×100 图片;createTime 用 @datetime;status 用 @integer(0,1);type 用 @integer(1,3)。可以先只改 title,刷新看到随机中文标题后再补其他字段,这样调试更稳。易错点是:规则要写成字符串,例如 Mock.mock('@ctitle(5,10)');@image 的宽高用小写 x;@integer 不要拼成 interger;字段名 avatar、createTime、status、type 不要写错。
ts
import Mock from 'mockjs'
const data = []
for (let i = 0; i < 10; i++) {
data.push({
id: Mock.mock('@id'),
title: Mock.mock('@ctitle(5,10)'),
content: Mock.mock('@ctitle(10,20)'),
avatar: Mock.mock('@image(100x100)'),
createTime: Mock.mock('@datetime'),
status: Mock.mock('@integer(0,1)'),
type: Mock.mock('@integer(1,3)')
})
}
export default [
{
// url、method、timeout 沿用上一节已经配置好的模拟接口
response: {
// code、message 等基础结构沿用上一节
data
}
}
]刷新验证与图片访问
为什么还要刷新验证?因为随机规则是否生效,只看代码看不出来。怎么想?刷新页面后看控制台日志:id 应是随机标识,title 和 content 应是随机中文,avatar 应是图片地址,createTime 应是时间,status 只能落在 0/1,type 只能落在 1/2/3。怎么做?多展开几条数据检查范围,再复制 avatar 地址到浏览器访问,能看到一张 100×100 的随机图片。易错点是:如果请求还是走线上真实接口,通常是因为模拟接口只在本地开发环境生效,请求时要使用本地开发域名才会走 mock;将来接口开发好后,在调用接口处去掉本地开发域名,就会正常访问线上接口。
易错点与切换线上接口
为什么最后强调切换?因为 mock 只是开发阶段的支撑,不能带到线上。怎么想?模拟接口的字段和后台保持一致,页面业务就可以先按这份数据开发;接口完成后只改请求域名。怎么做?调用接口处保留路径,去掉本地开发域名即可。易错点是:不要把写死数据当成最终数据;不要忘记 vite-plugin-mock 和 Mock.js 的配置;不要把 mock/index.ts 里的接口地址、请求方式、超时和 response 基础结构改乱;生成规则不确定时回官方文档查,不要凭感觉写。这样 10 条随机消息数据就能稳定支撑消息列表开发。
为什么已经有 TypeScript 还要写单元测试
很多同学会问:前面引入 TypeScript 时不是说它能“预防无意引入的 bug”吗,为什么还要再引入单元测试?
这两者的检查层次不一样。TypeScript 做的是静态类型检查,它只保证你传进去的类型对不对,不会判断你的逻辑执行得是否正确。单元测试做的是逻辑正确性验证:给函数一个输入,看它能不能产出预期的输出。逻辑写错了,类型再对也拦不住,而单元测试能拦住这一类 bug。
所以思路是:类型检查管“形状”,单元测试管“行为”,二者互补,不是重复。
什么时候引入测试最合适
结论是:越早越好。
原因在于复杂度会随时间增长。一个函数越封装越复杂,依赖越来越多;等到项目后期再补测试,你要考虑的点非常多,写起来会非常麻烦。所以在项目初期就引入,成本最低。
那为什么本课把测试放到最后讲?因为实际开发中一般项目很少引入测试,这里放在最后只是做扩展,让你知道测试该怎么写、能带来什么收益,而不是要求你每个项目都必须上测试。
测试的三种类型与本节范围
测试通常分三类:单元测试、组件测试、端到端测试。本节只讲单元测试。
单元测试的对象是函数、类或组合式函数。判断标准很简单:给函数一个输入,看它能不能达到你预想的输出;达到了,就代表这个函数通过测试。
要完成“输入 → 预期输出”的判断,需要一个测试框架。这里选 Vitest(V 取 Vite 之意),它是基于 Vite 的测试框架,用法和大家熟悉的 Jest 很像,只是它构建在 Vite 之上。而我们项目本来就用 Vite,所以选它最顺。
安装 Vitest 并配置 test 命令
怎么做:先安装,再在 package.json 里加一条脚本命令。
bash
pnpm install vitest -Djson
{
"scripts": {
"test": "vitest"
}
}执行这条命令后,Vitest 会去检查项目中所有符合命名规则的测试文件,并运行其中的测试用例。
易错点:安装要加 -D(开发依赖),因为它只在开发阶段使用,不进入生产构建。
测试文件放哪里、叫什么名字
怎么想:测试文件要和被测代码对应起来,方便定位。
怎么做:比如要测 utils 下的 filter.ts,就在 utils 下新建一个 test 目录,在里面新建 filter.test.ts。后缀必须是 .test.ts,Vitest 才会识别。文件建好后图标会变成一个“化验瓶”样式,说明编辑器已经把它当成测试文件了。
你也可以把所有测试用例统一放在最外层的 test 目录,两种方式都行;这里为了就近对应,直接放在 utils 下。
用 test 和 expect 写第一个用例
Vitest 导出两个核心函数:test 用来声明一个测试用例,expect 用来做断言(预测)。
ts
import { test, expect } from 'vitest'
import { AliasTime } from '@/enums'
import { getAliasTimeText } from '../filter'
test('getAliasTimeText 传入 month 应返回一个月内', () => {
// 输入:month
const text = getAliasTimeText(AliasTime.month)
// 预期输出:一个月内
expect(text).toBe('一个月内')
})推导过程:这个用例的预期值从哪来?回到 getAliasTimeText 函数内部看它的 timeOptions 映射——如果传入的是 month,对应的文案就是“一个月内”。所以预期值就是“一个月内”,不是随便写的。
怎么做:test 第一个参数是用例描述,第二个参数是回调,回调里完成“取值 → 断言”。
易错点:断言不要写成“我觉得应该是”,要去源码里确认映射关系再写预期值,否则测试本身就成了 bug。
用测试捕获无意引入的 bug
写完执行:
bash
pnpm test终端会打印日志,告诉你 filter.test.ts 有 1 个测试用例通过。
而且这条命令启动后会监听文件变化,你改代码它就自动重跑。这个特性正好用来演示测试的价值:
- 再加一个用例,取
week对应的文案,预期是“一周内”,保存后通过。 - 把预期故意改成“一周”,保存——测试立刻报错,提示你预期是“一周”,实际返回的是“一周内”,用例未通过。改回“一周内”,又通过。
- 模拟真实场景:同事在别的页面想显示“一周”,顺手把函数里的“一周内”改成了“一周”,保存后测试立刻变红。他会发现这里早有人写了测试,说明这么写是有原因的,从而发现问题。
这类问题 TypeScript 检查不到,因为类型没变,变的只是逻辑。
第二个函数同理:
ts
test('getConsultFlagText 传入 0 应返回没就诊过', () => {
const text = getConsultFlagText(0)
expect(text).toBe('没就诊过')
})0 代表没有就诊过,所以预期输出是“没就诊过”。这里调试时踩过一个坑:预期写成了“没有就诊过”,多了一个“过”字,测试直接失败——实际返回的是“没就诊过”。字符串断言是逐字比较的,差一个字都不行。
函数颗粒度决定测试难度
toBe 用于判断简单类型的值是否相等;如果断言对象是复杂类型(数组、对象),它比较的是引用地址,这时要用 toEqual 比较值(组件测试中会用到)。
更重要的是这条经验:上面两个函数都很简单,所以容易写预期。如果函数非常复杂、依赖很多,你连“预期输出是什么”都很难说清。所以想让项目能测试,函数就要拆得颗粒度更小。颗粒度越小,输出越可预测,测试越准确;将来真被引入 bug,测试也能第一时间定位到具体函数。
代码示例
1. mock 配置 + 生成数据
ts
// vite.config.ts
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { viteMockServe } from 'vite-plugin-mock'
export default defineConfig({
plugins: [
vue(),
viteMockServe({
mockPath: 'mock', // mock 代码所在目录
localEnabled: true // 本地开发环境开启 mock
})
],
test: {
environment: 'happy-dom' // 组件测试需要 DOM 环境
}
})ts
// mock/index.ts
import type { MockMethod } from 'vite-plugin-mock'
import Mock from 'mockjs'
const data: any[] = []
for (let i = 0; i < 10; i++) {
data.push({
id: Mock.mock('@id'), // 唯一标识
title: Mock.mock('@ctitle(5, 10)'), // 中文标题 5~10 字
content: Mock.mock('@ctitle(10, 20)'),// 中文内容 10~20 字
avatar: Mock.mock("@image('100x100')"), // 100 x 100 的随机图片
createTime: Mock.mock('@datetime'), // 随机时间
status: Mock.mock('@integer(0, 1)'), // 状态只能是 0 或 1
type: Mock.mock('@integer(1, 3)') // 1 系统 / 2 交易 / 3 聊天
})
}
export default [
{
url: '/api/message/list',
method: 'get',
timeout: 1000, // 模拟网络延迟
response: () => ({
code: 200,
message: 'success',
data
})
}
] as MockMethod[]要点:
viteMockServe必须配localEnabled: true(生产用prodEnabled),且请求地址要带上本地开发域名(如http://localhost:5173/api/message/list),否则命中的还是线上真实接口,这是最容易踩的坑。data先声明数组,再在for循环里push10 条,字段名必须和接口文档完全一致,否则页面拿到的字段是undefined。- Mock 规则记忆:
@id唯一 ID、@ctitle(5,10)中文标题、@image('100x100')图片(宽 x 高,中间是小写x,写在双引号字符串里要用单引号)、@datetime时间、@integer(min, max)限定范围数字。 status用@integer(0, 1)、type用@integer(1, 3),不限定范围就会造出接口文档里不存在的值。- 接口开发好之后,只需把调用处的 mock 域名去掉,就能无缝切回线上接口。
2. 单元测试(vitest)
ts
// utils/filter.test.ts
import { test, expect } from 'vitest'
import { getIslandsTimeText, getConsultFlagText, IslandsTime } from './filter'
test('getIslandsTimeText', () => {
const text = getIslandsTimeText(IslandsTime.month)
expect(text).toBe('一个月内')
const text2 = getIslandsTimeText(IslandsTime.week)
expect(text2).toBe('一周内')
})
test('getConsultFlagText', () => {
const text = getConsultFlagText(0)
expect(text).toBe('没就诊过')
})要点:
- 先
pnpm i vitest -D安装,再在package.json加"test": "vitest",执行后会自动扫描项目里所有*.test.ts文件。 - 测试文件命名必须是
xxx.test.ts,放在被测文件同级的test目录或项目根test目录都可以。 test(描述, 回调)声明一个用例,expect(实际值).toBe(预期值)做断言;简单类型toBe比值,数组/对象要用toEqual(toBe比的是引用地址)。- 命令启动后处于 watch 模式,改代码会不停重跑;故意把预期写成
'一周'(实际返回'一周内')用例就会失败——这正是它能拦住同事误改文案、而 TS 静态检查拦不住的地方。
3. 组件测试(@vue/test-utils + happy-dom)
ts
// components/test/components.test.ts
import { test, expect } from 'vitest'
import { render } from '@vue/test-utils'
import CPRadioButton from '../CPRadioButton.vue'
test('CPRadioButton', async () => {
// 1. 单独挂载组件,传入默认 props
const wrapper = render(CPRadioButton, {
props: {
options: [
{ label: '选项一', value: 1 },
{ label: '选项二', value: 2 }
],
modelValue: 1
}
})
// 2. 测试渲染
expect(wrapper.getByText('选项一')).toBeTruthy()
expect(wrapper.getByText('选项二')).toBeTruthy()
// 3. 测试 props 驱动激活(组件用 active 类名标记选中)
expect(wrapper.queryByText('选项一').classes()).toContain('active')
// 4. 测试用户点击 -> 自定义事件与传值
await wrapper.queryByText('选项二').trigger('click')
expect(wrapper.emitted('update:modelValue')![0]).toEqual([2])
// 5. 把事件抛出的值回传给组件,验证视图更新(即 v-model 闭环)
await wrapper.rerender({ modelValue: 2 })
expect(wrapper.queryByText('选项二').classes()).toContain('active')
})要点:
- 需要安装
@vue/test-utils和happy-dom;不配test.environment: 'happy-dom'会直接报document is not defined,同时在vite.config.ts顶部加/// <reference types="vitest/config" />才有test字段的类型提示。 render(组件, { props })返回的wrapper是组件容器,getByText找不到会抛错(用于断言"一定有"),queryByText找不到返回空(用于拿到元素再取classes())。emitted('update:modelValue')返回的是"每次触发对应一个参数数组"的数组,所以写[0]取第一次触发、再toEqual([2])比对参数;用toBe([2])会因引用不同而失败。rerender返回 Promise,必须await等组件更新完再断言,否则读到的是旧 DOM,用例会莫名失败。- 一个用例覆盖四件事:挂载、渲染、props 更新视图、交互触发自定义事件并回传值,第 4、5 步合起来就是
v-model的测试。
常见错误
改完 mock 文件,浏览器里数据还是旧的 现象:
index.ts里 push 了 10 条数据,刷新页面拿到的还是空数组或旧字段。 原因:mock 配置文件在 dev server 启动时被读取并注册成中间件,运行时修改不会热替换接口注册。 修法:改完 mock 目录下的文件后重启pnpm dev;把「改 mock → 重启 → 刷新」当成固定动作,别在浏览器里反复刷新找原因。请求打到了线上真实接口,mock 完全没生效 现象:Network 面板里请求的域名是线上域名,返回真实数据;控制台没有 mock 相关日志。 原因:mock 只挂在本地 dev server 上,用线上域名请求自然不会经过它;或
vite.config.ts里 mock 插件未启用 /prodEnabled与enable配置不当。 修法:请求地址换成当前本地开发域名;把 baseURL 抽成环境变量,切换环境只改一处;确认插件配置后再排查代码。组件测试一跑就报
document is not defined现象:mount那一行直接抛错,用例全部失败。 原因:测试默认跑在 Node 环境,没有 DOM,渲染组件时访问document就崩。 修法:安装happy-dom(或 jsdom),在vite.config.ts的test.environment里指定;同时用三斜线指令引入vitest/config的类型,让test字段有提示不报红。断言数组 / 对象永远不通过 现象:明明打印出来值一样,
expect(res).toBe([2])却红灯。 原因:toBe走的是引用比较,两个内容相同的数组地址不同,必然不等。 修法:基本类型用toBe,数组/对象用toEqual;只关心「包含某元素」用toContain,只关心部分字段用toMatchObject。触发点击后立刻断言,结果时好时坏 现象:
trigger('click')下一行读 DOM 状态或事件参数,偶尔拿到旧值。 原因:trigger、setProps返回 Promise,视图更新在微任务之后才完成。 修法:await wrapper.trigger('click')、await wrapper.setProps({...}),必要时补await nextTick();凡是「改数据 → 看 DOM」的链路都要等。queryByText找不到元素,后面接.classList直接报错 现象:元素不存在时抛Cannot read properties of null,看不出是用例断言失败还是代码写错。 原因:queryBy*查不到返回null,而getBy*查不到会直接抛错;用queryBy后没做空判断。 修法:正向用例用getByText让失败信息更明确;负向用例(断言「不存在」)才用queryBy,且只做toBeNull()判断,不再往下取属性。
练习
给消息列表 mock 增加分页能力:接收
page、pageSize两个 query 参数,返回{ list, total }。要求id全局唯一、createTime为随机时间、status只出现 0/1、type只出现 1/2/3。写完后用浏览器 Network 面板逐条核对字段与接口文档是否完全一致。把「修改 mock 文件 → 不重启 → 刷新页面 → 数据没变 → 重启后正常」这个排查过程完整走一遍,并记录每一步的观察点(控制台输出、Network 请求域名、返回体),整理成一份 5 行以内的排错清单。
为
getConsultFlagText写一组单元测试,至少覆盖 3 个输入:未就诊、已就诊、以及一个非法/边界输入。要求每个用例名能说清「输入什么、期望什么」,跑pnpm test时看红灯信息能否一眼定位问题。为单选按钮组件补两个用例:① 点击后断言触发的自定义事件名与传出的值;② 断言「点选项二后,选项二的按钮被激活而选项一没有」。第二题必须用
await处理更新,故意去掉await观察失败表现并记录差异。故意把源码中某个文案或分支逻辑改错(模拟同事误改),观察测试是否红灯、TS 是否报错,据此说明「类型检查」和「单元测试」各自能拦住什么问题。改完记得还原。
课件图示






原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 扩展-mock数据-02-生成数据 | 11:11 |
| 2 | 扩展-mock数据-02-生成数据 | 11:11 |
| 3 | 扩展-单元测试 | 13:52 |
| 4 | 扩展-单元测试 | 13:52 |
| 5 | 扩展-组件测试 | 20:39 |
| 6 | 扩展-组件测试 | 20:39 |
小结
- mock 的定位是「本地开发的临时后端」:字段必须与接口文档逐一对齐,否则业务代码写完上线还要返工,收益归零。
- mock 生效与否,先看请求域名再看配置——90% 的「mock 不生效」是请求没走本地 dev server,而不是插件写错。
- mock 数据要「随机但受约束」:随机保证真实感,范围约束(status 只能 0/1、type 只能 1/2/3)保证不会造出线上不可能存在的脏数据。
- 类型检查管「形状」,测试管「行为」:前者拦字段写错、参数类型不符,后者拦逻辑分支写反、文案改动、交互失效,二者不可互相替代。
- 测试越早引入成本越低:函数一旦膨胀、依赖变多,可测性就急剧下降;反过来,好测的代码往往就是拆分合理的代码。
- 组件测试的落点是四件事:能否挂载、初始渲染是否正确、props 是否驱动视图、交互是否触发事件并回传正确值——其中「事件 + 回传值 + 视图更新」串起来就是一次完整的双向绑定验证。
- 写断言的纪律:断言要能说出「我期望什么」,失败信息要能直接定位问题;选择
toBe/toEqual/toContain时想清楚比的是引用、结构还是成员。 - 测试文件要进版本库并接入 CI,让它成为「改错就会响的警报」,否则只是本地跑过一次的演示脚本。