为了 Vue 组件测试,你需要为每个事件绑定的方法加上括号吗?

本文由华为云体验技术团队松塔同学分享

先说结论,当然不是!Vue 组件测试,尤其是组件触发事件的测试,有成熟的示例。我们同样要关注测试的原则,例如将组件当成黑盒,不关心其内部实现,而只关心与其交互。本文是借由一次 Vue 组件测试,引发对 Vue 源码和 Spy 函数的延伸探讨。

假设你写了一个 Vue 组件,它大概长这样:

js 复制代码
<MyComponent
  :disabled="!valid"
  :data="someTestData"
  @confirm="handleConfirm"
/>

它定义了datadisabled作为 props,前者作为组件的数据输入,后者用来定义组件的功能开关。组件被点击时,会抛出confirm事件,不过当disabledtrue时,confirm事件不会被触发。

当你想为这个组件写一些单元测试时,可能会这样写:

js 复制代码
describe('MyComponent on the page', () => {
  // ...
  it('confirm event', async () => {
    const instance = wrapper.findComponent({ name: 'MyComponent' })
    const spy = vi
      .spyOn(wrapper.vm, 'handleConfirm')
      .mockImplementation(() => null)
    await instance.trigger('click')
    expect(spy).not.toHaveBeenCalled()
    // ... change valid
    await instance.trigger('click')
    expect(spy).toHaveBeenCalledTimes(1)
  })
})

valid初始化时为false,即MyComponent一开始不会抛出confirm事件,当valid被改变后,点击MyComponentconfirm事件才被抛出。

这段单元测试会在最后一句报错,显示spy实际被触发 0 次。实际上,spy永远不会被触发,即使valid初始化时为true也是如此。

然而,将模板里的方法调用调整一下,加上括号,单元测试就按照预期通过了:

js 复制代码
<MyComponent
  :disabled="!valid"
  :data="someTestData"
  @confirm="handleConfirm()"
/>

为什么加不加括号会引起单元测试的逻辑变化?

模板语法

首先我们需要看一看模板在编译时,处理@confirm="handleConfirm()"@confirm="handleConfirm"有什么不同。

@vue/compiler-sfccompileTemplate方法开始一路往下分析,会发现模板编译的核心方法是@vue/compiler-core这个包中的baseCompile方法。这个方法主要干三件事:

js 复制代码
export function baseCompile(
  template: string | RootNode,
  options: CompilerOptions = {}
): CodegenResult {
  // ...
    
  // 1. 生成基础ast
  const ast = isString(template) ? baseParse(template, options) : template
  
  // ...
  
  // 2. 对ast做转换
  transform(
    ast,
    extend({}, options, {
      prefixIdentifiers,
      nodeTransforms: [
        ...nodeTransforms,
        ...(options.nodeTransforms || []) // user transforms
      ],
      directiveTransforms: extend(
        {},
        directiveTransforms,
        options.directiveTransforms || {} // user transforms
      )
    })
  )
  // 3.生成渲染函数
  return generate(
    ast,
    extend({}, options, {
      prefixIdentifiers
    })
  )
}
  1. 调用baseParse方法解析 HTML,生成基础的 AST。由于 Vue 在 HTML 上增加了许多语法特性(v-if、v-for、v-bind 等等),需要做对应解析。
<div @click="handleConfirm()" /> 生成的 AST <div @click="handleConfirm" /> 生成的 AST

查看生成的 AST 结构后可以发现,加不加括号对结构并不会产生影响。二者都生成了 v-on 的 prop,exp中的 content 未对原始内容做出改动。

  1. 进一步对 AST 做解析和转换。这一步引入了nodeTransformsdirectiveTransforms对象,其实是在./transforms目录下的一系列函数:
js 复制代码
    export function getBaseTransformPreset(
      prefixIdentifiers?: boolean
    ): TransformPreset {
      return [
        [
          transformOnce,
          transformIf,
          transformMemo,
          transformFor,
          ...(__COMPAT__ ? [transformFilter] : []),
          ...(!__BROWSER__ && prefixIdentifiers
            ? [
                // order is important
                trackVForSlotScopes,
                transformExpression
              ]
            : __BROWSER__ && __DEV__
            ? [transformExpression]
            : []),
          transformSlotOutlet,
          transformElement,
          trackSlotScopes,
          transformText
        ],
        {
          on: transformOn,
          bind: transformBind,
          model: transformModel
        }
      ]
    }

光从名字就可以看出来,依旧是对 Vue 的语法特性做的一些工作,最终在 AST 的每个节点上增加codegenNode,这个属性将会被用在第三步生成渲染函数过程中。经过 transform 这一步后,生成的codegenNode如下:

<div @click="handleConfirm()" />codegenNode <div @click="handleConfirm" />codegenNode

二者 prop 中的 value 值有所差异,type 是 typescript 定义的 enum,编译后变成了数字,还原后前者的类型从SIMPLE_EXPRESSION变成了COMPOUND_EXPRESSION,后者仍保持之前的SIMPLE_EXPRESSION

造成二者差异的原因,需要深入transformOn这个对 v-on 语法转换的方法。它根据 AST 节点的exparg,生成codegenNodeprops下的属性。简化一下有关exp的逻辑,核心代码如下:

js 复制代码
    const isMemberExp = isMemberExpression(exp.content, context)
    const isInlineStatement = !(isMemberExp || fnExpRE.test(exp.content))
    const hasMultipleStatements = exp.content.includes(`;`)
    if (isInlineStatement || (shouldCache && isMemberExp)) {
      // wrap inline statement in a function expression
      exp = createCompoundExpression([
        `${
          isInlineStatement
            ? !__BROWSER__ && context.isTS
              ? `($event: any)`
              : `$event`
            : `${!__BROWSER__ && context.isTS ? `\n//@ts-ignore\n` : ``}(...args)`
        } => ${hasMultipleStatements ? `{` : `(`}`,
        exp,
        hasMultipleStatements ? `}` : `)`,
      ]);
    }

首先对exp做判断,是否是 member expression、是否是 inline statement,是否有多个 statement。然后出现了exp的改写,根据判断生成了 compound expression,实际就是转换成了函数表达。看来isMemberExpisInlineStatement这两个判断影响了最终codegenNode的生成。

Member Expression

这是个来源于 AST 定义的概念,JavaScript 中经常有对象属性的指向,例如:

css 复制代码
const a = { x: 0 }
const b = a.x

这里a.x就是 member expression,transformOn中调用isMemberExpression来做判断,实际就是调用 babel parser 的能力分析,简化来说:

js 复制代码
    try {
      let ret: Expression = parseExpression(path, {
        plugins: context.expressionPlugins,
      });
      if (ret.type === 'TSAsExpression' || ret.type === 'TSTypeAssertion') {
        ret = ret.expression;
      }
      return (
        ret.type === 'MemberExpression' ||
        ret.type === 'OptionalMemberExpression' ||
        ret.type === 'Identifier'
      );
    } catch (e) {
      return false;
    }

这里 MemberExpression、OptionalMemberExpression、Identifier 都被认定成了 member expression。OptionalMemberExpression 即带有 optional chaining (?.) 的表达式。Identifier 也被包括的原因是,在模板中一般会省略主对象,如 this、或者 setup 中返回的对象。

<div @click="handleConfirm" />handleConfirm就是 Identifier,它指向的就是我们在 script 中定义的函数。

isInlineStatement的判断中还出现了一个条件fnExpRE.test(exp.content),这是函数表达式的正则判断:

虽然直接在模板里声明函数很罕见,但是 Vue 并没有限制这种做法。

exp如果既不是 member expression,也不是函数表达式,transformOn就把它当作 inline statement。实际上这是我们在日常使用时比较常见的作法,例如只是简单对变量赋值,那就无需在<script>中声明函数,而是简写为:

js 复制代码
    <MyComponent
      :disabled="!valid"
      :data="someTestData"
      @confirm="hasConfirmed = $event"
    />

而让这段代码生效的原因,就在于transformOn编译时将exp包裹了一层函数声明。它调用createCompoundExpression,将$event 作为函数入参,从而使函数内能获取到:

ini 复制代码
($event) => (hasConfirmed = $event)
  1. 由上一步生成的codegenNode,转换成最终的渲染函数。重点看一下带括号的表达式生成的渲染函数:
js 复制代码
        const _Vue = Vue

        return function render(_ctx, _cache) {
          with (_ctx) {
            const { openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue

            return (_openBlock(), _createElementBlock("div", {
              onClick: $event => (handleConfirm())
            }, null, 8 /* PROPS */, ["onClick"]))
          }
        }
csharp 复制代码
with statement 是在模板中可以省略 this 的原因。

对比

将以上分析做一个总结,我们可以将编译后结果简化一下,那么带括号的函数表达:

js 复制代码
    const ctx = { handleConfirm: () => null }
    const prop = { onClick: ($event) => { ctx.handleConfirm() } }

不带括号的函数表达:

js 复制代码
    const ctx = { handleConfirm: () => null }
    const prop = { onClick: ctx.handleConfirm }

Mock Function

我们已经搞清楚在编译阶段,带不带括号的函数表达有什么区别。接下来就要研究这个区别对于 Mock 行为产生了什么影响。Vitest 内部利用 tinyspy 来实现 mock 功能,本文并不会深入 tinyspy 的具体实现,因为 JavaScript spy 库大同小异,而背后的 JavaScript 语言特性才是本文真正想分享的。spy 函数的基本功能就是提供对目标函数的监视,例如执行次数,出入参等。一个函数在声明后,JavaScript 无法让我们二次修改它的内容,因此通常来说 spy 库会将原本函数的引用指向新的实现。一个简单的 spy 函数可以是这样:

js 复制代码
    function spyOn(obj, method) {
      let spy = {
        args: [],
      };

      let original = obj[method];
      obj[method] = function () {
        let args = [].slice.apply(arguments);
        spy.count++;
        spy.args.push(args);
        return original.call(obj, args);
      };

      return Object.freeze(spy);
    }

它将object[method]指向了新的函数,首先更新函数执行的次数、记录每次执行的入参,然后用call执行原始函数。

对应到本文的例子中,当我们声明const spy = vi.spyOn(wrapper.vm, 'handleConfirm')后,wrapper.vm.handleConfirm就被指向了 spy 生成的新函数,这个改动针对的是 Vue 实例对象,而我们由模板编译生成的渲染函数仍保持不变。因此const prop = { onClick: ctx.handleConfirm }onClick仍指向原始函数的引用,无论 handleConfirm 之后怎么改变,其在渲染函数生成后就从始至终不变了。而const prop = { onClick: ($event) => { ctx.handleConfirm() } }ctx.handleConfirm()会在点击回调触发后解析,此时就会指向spyOn定义的新函数了。

总结

当搞清楚模板语法生成事件回调的逻辑后,我们就会发现这其实是一个经典的对象引用指向的问题。受限于 JavaScript 语言特性,mock 行为实际上创建了一个新的函数,而上下文若仍保持着对原函数的引用,那 mock 行为不会按照预期运行也就可以理解了。

当你想要测试组件是否正确地 emit,也许应该尝试@vue/test-utils中的emitted()方法。或者将视角拉得更高,从最终页面呈现的内容来判断。

关于 OpenTiny

OpenTiny 是一套企业级 Web 前端开发解决方案,提供跨端、跨框架、跨版本的 TinyVue 组件库,包含基于 Angular+TypeScript 的 TinyNG 组件库,拥有灵活扩展的低代码引擎 TinyEngine,具备主题配置系统TinyTheme / 中后台模板TinyPro/ TinyCLI命令行等丰富的效率提升工具,可帮助开发者高效开发 Web 应用。


欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~更多视频内容也可关注B站、抖音、小红书、视频号

OpenTiny 也在持续招募贡献者,欢迎一起共建

OpenTiny 官网opentiny.design/

OpenTiny 代码仓库github.com/opentiny/

TinyVue 源码github.com/opentiny/ti...

TinyEngine 源码github.com/opentiny/ti...

欢迎进入代码仓库 Star🌟TinyEngineTinyVueTinyNGTinyCLI~

如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

相关推荐
魏大帅。几秒前
Axios 的 responseType 属性详解及 Blob 与 ArrayBuffer 解析
前端·javascript·ajax
花花鱼7 分钟前
vue3 基于element-plus进行的一个可拖动改变导航与内容区域大小的简单方法
前端·javascript·elementui
k093310 分钟前
sourceTree回滚版本到某次提交
开发语言·前端·javascript
EricWang135831 分钟前
[OS] 项目三-2-proc.c: exit(int status)
服务器·c语言·前端
September_ning32 分钟前
React.lazy() 懒加载
前端·react.js·前端框架
web行路人41 分钟前
React中类组件和函数组件的理解和区别
前端·javascript·react.js·前端框架
超雄代码狂1 小时前
ajax关于axios库的运用小案例
前端·javascript·ajax
长弓三石1 小时前
鸿蒙网络编程系列44-仓颉版HttpRequest上传文件示例
前端·网络·华为·harmonyos·鸿蒙
小马哥编程1 小时前
【前端基础】CSS基础
前端·css
嚣张农民2 小时前
推荐3个实用的760°全景框架
前端·vue.js·程序员