普通视图

发现新文章,点击刷新页面。
昨天 — 2026年2月5日首页

Vue 组件 API 覆盖率工具

作者 yddddddddddur
2026年2月4日 20:19

前言

在组件开发中,我们经常面临一个问题:组件测试是否真正覆盖了组件的所有 API,传统的代码覆盖率工具只能告诉我们代码行数的覆盖情况,却无法准确反映组件的 Props、Events、Slots 和 Exposed Methods 是否被充分测试。

传统代码覆盖率的局限性

传统的代码覆盖率工具(如 Istanbul、nyc 等)虽然能够统计代码行的执行情况,但在组件测试场景下存在明显的不足。它们无法检查出以下这些问题:

  • 无法追踪对象的某个 key 是否被使用:这是根本性的限制。传统工具只能知道某行代码被执行了,但无法精确追踪对象的哪些属性被访问。例如,组件的 props 对象被传递了,但不知道具体哪些 prop 键被使用
  • 无法 测试 是否遗漏了 Slots 的 TS 类型定义:组件有测试 slots 的功能,但没有声明 slots 的类型
  • 无法找出是否存在冗余的 Props:定义了某些 props,但未被实际使用
  • 无法检查 Props 的所有枚举值是否都 测试 :例如 type: 'primary' | 'ghost' | 'dashed' 这种联合类型,可能只测试了 'primary',而遗漏了其他变体
  • 无法检查 Props 的所有类型是否都 测试 :例如 value 可能接受 Boolean | String | Number | Array,但测试中只传了字符串

这些问题在组件库开发中尤为突出。一个看似 90% 代码覆盖率的组件,实际上可能有大量未经测试的 API 边界情况。传统覆盖率工具基于代码执行行数统计,而组件 API 测试需要的是基于类型系统和对象属性的精确追踪

实践中的困境

在早期做公司内部组件库的时候,我们也开启过一轮对组件 API 覆盖率的人工检查。然而,由于组件 API 过多,检查过程极其困难,最终总会有许多漏写的单元测试。人工核对的方式不仅效率低下,而且容易遗漏,标准也难以统一。

为了解决这个问题,我在半年前用 AI 开发了 vc-api-coverage,一个专门为 Vue 3 TSX 组件设计的 API 覆盖率分析工具。本文将深入剖析这个工具的技术实现原理,分享如何利用 TypeScript 类型系统和 AST 分析来实现精准的 API 覆盖率检测。

核心设计思路

这个工具的核心理念是:通过静态分析组件定义和 测试 代码,建立组件 API 与测试用例之间的映射关系

设计理念

在大学学过的一门项目管理课程中,讲到了"设备点检",这是一种预防性设备维护管理制度。通过定期、定点、定标、定人、定法的方式对设备进行检查,以确保设备正常运行。

这个覆盖率工具的设计思路与"设备点检"有异曲同工之妙,主要对定标、定法、定期这3个环节进行了强化:

  • 定标:将原本模糊、可完成可不完成的测试标准,变成一个明确、量化、强制的标准(如:100% API 覆盖率)。
  • 定法:将对api覆盖率的手动检查,变成程序自动化的检查。
  • 定期:将原本一次性的检查,变成CI流水线的周期检查。

通过工具化的方式,我们把主观的人工检查转变为客观的自动化检测,把模糊的质量要求转变为精确的量化指标。

整体架构

整体架构分为三个核心模块:

  1. ComponentAnalyzer:分析组件定义,提取所有可用的 API
  2. UnitTestAnalyzer:分析测试代码,识别哪些 API 被测试覆盖
  3. Reporter:生成可视化的覆盖率报告(CLI、HTML、JSON)

image.png

技术选型

在开始介绍具体实现之前,先分享一下技术选型过程中的弯路和思考。

早期方案

最初设计这个覆盖率工具时,我的想法是通过 AST(抽象语法树)去分析组件代码,直接提取出 Props、Slots 和 Exposed Methods。这个方案看起来很直接,但在实践中遇到了巨大的挑战:

Vue 组件的写法复杂多变,静态分析难以覆盖所有场景:

  1. 多种 API 风格:组件既可以用 Composition API 的 setup 写法,也可以用 Options API 写法
  2. 运行时配置:组件可能配置了 mixinsextends 等,这些内容需要递归分析多个文件
  3. 动态计算的 Props:有些组件的 props 需要运行时才能确定,例如使用 lodash.pick 从另一个对象选取部分 props:
import { pick } from 'lodash';
const baseProps = { a: String, b: Number, c: Boolean };
const componentProps = pick(baseProps, ['a', 'b']); // 静态分析无法得知结果

4. 类型信息丢失:纯 AST 分析只能看到代码结构,很难准确推断出 union 类型、可选属性等类型信息

经过几次尝试,发现要覆盖所有 Vue 组件的写法,需要实现一个接近完整的 Vue 编译器,这显然不现实。

最终方案

后来换了一个思路:既然 Vue 3 组件本身就有完整的类型定义,为什么不直接利用 TypeScript 的类型系统呢?

这个方案的优势非常明显:

  • 统一的接口:无论组件怎么写(setup、options、mixins),最终都会生成统一的组件类型,TypeScript 编译器已经帮我们处理好了所有复杂情况
  • 完整的类型信息:可以直接获取 union 类型、可选属性、泛型参数等完整的类型信息
  • 简单快捷:通过 InstanceType<typeof Component>['$props'] 就能获取所有 props,无需关心组件内部实现
  • 零维护成本:随着 Vue 版本升级,只要类型定义更新了,工具就能自动适配

这就是为什么最终选择了"类型系统 + AST"的混合方案:

  • 类型系统提取 Props、Events、Slots(简单可靠)
  • AST 提取单元测试代码(类型系统无法覆盖的场景)

技术选型的启示:不要试图重新实现已有的轮子。TypeScript 编译器已经解决了类型推断的复杂问题,我们应该站在巨人的肩膀上。

技术实现详解

组件 API 提取

组件的 Props、Events 和 Slots 信息隐藏在 Vue 组件的类型定义中,见TS Playground示例。我们利用 ts-morph 库来访问 TypeScript 的类型系统:

1. Props/Events 提取

 // src/analyzer/ComponentAnalyzer.ts:30
analyzePropsAndEmits(instanceType: Type, exportedExpression: Expression) {
    // 通过 $props 属性获取组件的所有 props
    const dollarPropsSymbol = instanceType.getProperty('$props');
    if (!dollarPropsSymbol) returnconst dollarPropsType = dollarPropsSymbol.getTypeAtLocation(exportedExpression);
    dollarPropsType.getProperties().forEach(propSymbol => {
        const propName = propSymbol.getName();
        // 过滤内部属性
        if (!internalProps.includes(propName)) {
            this.props.add(propName);
        }
    });
}

核心原理:Vue 3 组件通过 InstanceType<typeof Component>['$props'] 暴露了所有 props 的类型信息。我们直接访问这个类型,遍历其所有属性,就能获得完整的 props 列表。

2. Slots 提取

 // src/analyzer/ComponentAnalyzer.ts:157
analyzeSlots(instanceType: Type, exportedExpression: Expression) {
    const dollarPropsSymbol = instanceType.getProperty('$slots');
    if (!dollarPropsSymbol) returnconst dollarPropsType = dollarPropsSymbol.getTypeAtLocation(exportedExpression);
    dollarPropsType.getProperties().forEach(propSymbol => {
        const propName = propSymbol.getName();
        this.slots.add(propName);
    });
}

核心原理:与 props 类似,通过 $slots 属性获取所有插槽的类型定义。

3. Exposed Methods 提取

Exposed methods无法从 TypeScript 类型系统中获取,我们采用了 AST 代码分析的方法:

 // src/analyzer/ComponentAnalyzer.ts:176
analyzeExposeContextCalls() {
    // 方法1: 检测 expose({ ... }) 调用
    const matches = this.code.match(/expose(\s*{([^}]+)}\s*)/g);

    if (matches && matches.length > 0) {
        for (const match of matches) {
            const propsStr = match.replace(/expose(\s*{/, '').replace(/}\s*)/, '');
            const propMatches = propsStr.match(/(\w+),?/g);

            if (propMatches) {
                for (const prop of propMatches) {
                    const cleanProp = prop.replace(/,/g, '').trim();
                    if (cleanProp) {
                        this.exposes.add(cleanProp);
                    }
                }
            }
        }
    }
}
 // src/analyzer/ComponentAnalyzer.ts:202
analyzeExposeArrayOption(exportedExpression: Expression) {
    // 方法2: 检测 defineComponent({ expose: ['method1', 'method2'] })
    const componentOptions = this.getComponentOptions(exportedExpression);
    if (!componentOptions) return;

    const exposeArray = this.getExposeArrayFromOptions(componentOptions);
    if (!exposeArray) return;

    const exposeItems = exposeArray.getElements();
    for (const item of exposeItems) {
        const itemName = this.getItemName(item);
        if (itemName) {
            this.exposes.add(itemName);
        }
    }
}

核心原理

  1. 通过正则表达式匹配 expose({ ... }) 调用
  2. 通过 AST 分析 defineComponentexpose 选项
  3. 支持多种写法:字符串字面量、标识符、枚举值等

测试覆盖分析

测试代码有多种写法,我们需要支持各种常见的测试模式。

模式 1:传统 mount 方法

 // 测试代码
mount(Button, {
  props: { variant: 'primary', disabled: true },
  slots: { default: 'Click me' }
});
 // src/analyzer/UnitTestAnalyzer.ts:186
processMountComponent(componentArgNode: Node, optionsNode?: ObjectLiteralExpression) {
    if (!optionsNode) returnconst componentName = componentArgNode.getText();
    const componentFile = this.resolveComponentPath(componentArgNode as Identifier);

    if (!this.result[componentFile]) {
        this.result[componentFile] = {};
    }

    // 提取 props、emits、slots
    this.extractProps(optionsNode, this.result[componentFile]);
    this.extractEmits(optionsNode, this.result[componentFile]);
    this.extractSlots(optionsNode, this.result[componentFile]);
}

模式 2:JSX 写法

 // 测试代码
render(<Button variant="primary" disabled onClick={handler}>
  Click me
</Button>);
 // src/analyzer/UnitTestAnalyzer.ts:678
private analyzeJSXElements(callExpression: CallExpression) {
    const jsxElements = this.findJsxInCallExpression(callExpression);

    for (const jsxElement of jsxElements) {
        const openingElement = Node.isJsxElement(jsxElement)
            ? jsxElement.getOpeningElement()
            : jsxElement;

        const tagName = openingElement.getTagNameNode().getText();
        const filePath = this.resolveComponentPath(openingElement.getTagNameNode());

        // 提取 JSX 属性作为 props
        this.extractJSXAttrs(openingElement, this.result[filePath]);

        // 提取 JSX 子元素作为 slots
        if (Node.isJsxElement(jsxElement)) {
            this.extractJSXSlots(jsxElement, this.result[filePath]);
        }
    }
}

模式 3:Template 字符串

 // 测试代码
mount({
  template: '<Button variant="primary" @click="handler">Click me</Button>',
  components: { Button }
});
 // src/analyzer/UnitTestAnalyzer.ts:269
private extractPropsFromTemplate(template: string, componentTagName: string, componentTestUnit: TestUnit) {
    // 使用正则表达式解析模板中的属性
    const tagRegex = new RegExp(`<${componentTagName}(\s+[^>]*?)?>`, 'ig');
    let match;
    const propsFound: string[] = [];

    while ((match = tagRegex.exec(template)) !== null) {
        const attrsString = match[1];
        if (!attrsString) continue;

        // 解析属性名
        const attrRegex = /([@:a-zA-Z0-9_-]+)(?:=(?:"[^"]*"|'[^']*'|[^\s>]*))?/g;
        let attrMatch;
        while ((attrMatch = attrRegex.exec(attrsString)) !== null) {
            let propName = attrMatch[1];

            // 处理 v-bind:, :, v-model: 等前缀
            if (propName.startsWith(':')) {
                propName = propName.substring(1);
            } else if (propName.startsWith('v-bind:')) {
                propName = propName.substring(7);
            }

            propsFound.push(propName);
        }
    }

    componentTestUnit.props = [...new Set([...(componentTestUnit.props || []), ...propsFound])];
}

Exposed Methods 检测

对于暴露的方法,我们采用了一个简单但有效的策略:方法名匹配

 // src/analyzer/UnitTestAnalyzer.ts:1381
private analyzeExposedMethods(testCall: CallExpression) {
    const calledMethods = new Set<string>();

    // 查找所有属性访问表达式 (xxx.methodName)
    const propertyAccesses = testCall.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression);

    for (const access of propertyAccesses) {
        const methodName = access.getName();

        // 检查是否为暴露的方法
        if (this.isExposedMethod(methodName)) {
            calledMethods.add(methodName);
        }
    }

    // 将这些方法添加到组件的覆盖记录中
    for (const componentFile in this.result) {
        if (!this.result[componentFile].exposes) {
            this.result[componentFile].exposes = [];
        }
        for (const method of calledMethods) {
            if (!this.result[componentFile].exposes.includes(method)) {
                this.result[componentFile].exposes.push(method);
            }
        }
    }
}

核心原理:扫描测试代码中的所有属性访问表达式(如 wrapper.vm.focus()),提取方法名,然后过滤掉 Vue 内置方法和测试工具方法。

Strict Mode

在严格模式下,我们不仅检测 prop 是否被测试,还会检测每个 union 类型的变体是否都被测试。

 // src/analyzer/ComponentAnalyzer.ts:42
if (this.strictMode) {
    const propType = propSymbol.getTypeAtLocation(exportedExpression);
    const nonNullableType = propType.getNonNullableType();
    const variants = this.extractVariantsFromType(nonNullableType);

    if (variants.length > 0) {
        this.propsWithVariants.push({
            name: propName,
            variants
        });
    }
}

Union 类型展开

 // src/analyzer/ComponentAnalyzer.ts:62
private extractVariantsFromType(type: Type): PropVariant[] {
    const variants: PropVariant[] = [];

    if (type.isUnion()) {
        const unionTypes = type.getUnionTypes();

        for (const unionType of unionTypes) {
            // 跳过 undefined 和 null
            if (unionType.isUndefined() || unionType.isNull()) {
                continue;
            }

            const variant = this.getVariantFromType(unionType);
            if (variant) {
                // 跳过 false(boolean 类型只展开 true)
                if (!(variant.type === 'literal' && variant.value === false)) {
                    variants.push(variant);
                }
            }
        }
    }

    return variants;
}

核心原理

  1. 检测 prop 类型是否为 union 类型
  2. 遍历所有 union 成员,提取字面量值
  3. Boolean 类型只展开 true(因为 false 通常是默认值)
  4. 过滤掉 undefinednull

测试值提取

在测试代码中,我们需要提取实际传递的值:

 // src/analyzer/UnitTestAnalyzer.ts:942
private extractPropValue(attr: Node): PropValue | null {
    if (!Node.isJsxAttribute(attr)) return null;

    const propName = attr.getNameNode().getText();
    const initializer = attr.getInitializer();

    if (!initializer) {
        // 布尔属性 <Button disabled />
        return { propName, value: true, type: 'literal' };
    }

    // 字符串字面量
    if (Node.isStringLiteral(initializer)) {
        return { propName, value: initializer.getLiteralValue(), type: 'literal' };
    }

    // JSX 表达式
    if (Node.isJsxExpression(initializer)) {
        const expression = initializer.getExpression();
        if (!expression) return null;

        // 数字、布尔等字面量
        if (Node.isNumericLiteral(expression)) {
            return { propName, value: Number(expression.getLiteralValue()), type: 'literal' };
        }

        // 处理变量:通过类型推断获取实际值
        const exprType = expression.getType();
        if (exprType.isLiteral()) {
            const literalValue = exprType.getLiteralValue();
            if (literalValue !== undefined) {
                return { propName, value: literalValue, type: 'literal' };
            }
        }
    }

    return null;
}

核心原理

  1. 直接提取字面量值
  2. 对于变量和表达式,利用 TypeScript 的类型推断获取值
  3. 支持 ref 值追踪、循环变量展开等复杂场景

组件路径解析

为了准确关联测试代码和组件定义,我们需要解析 import 语句,找到组件的真实路径:

 // src/analyzer/UnitTestAnalyzer.ts:89
private resolveComponentPath(identifier: Identifier, importSymbol?: Symbol) {
    try {
        let originalSymbol: Symbol | undefined = importSymbol;
        if (identifier) {
            const typeChecker = this.project.getTypeChecker();
            originalSymbol = typeChecker.getSymbolAtLocation(identifier);
        }
        if (!originalSymbol) return null;

        // 解析别名
        while (originalSymbol?.getAliasedSymbol()) {
            originalSymbol = originalSymbol.getAliasedSymbol();
        }

        if (!originalSymbol) return null;
        const declarations = originalSymbol.getDeclarations();
        const declarationNode = declarations[0];
        if (!declarationNode) return null;

        const declarationSourceFile = declarationNode.getSourceFile();
        const originalPath = declarationSourceFile.getFilePath();

        if (!isComponentFile(originalPath)) {
            // 继续解析转发导出
            return this.resolveTsPath(declarationNode);
        }

        return originalPath;
    } catch (error) {
        return null;
    }
}

核心原理

  1. 从 identifier 获取 symbol

  2. 递归解析 alias symbol(处理 export { Button as Btn } 等情况)

  3. 获取原始声明文件路径

  4. 处理中间层的转发导出

实际应用场景

1. CI/CD 集成

通过 onFinished 回调强制 100% 覆盖:

export default defineConfig({
  test: {
    reporters: [['vc-api-coverage', {
      onFinished: (data) => {
        for (const item of data) {
          if (item.total > item.covered) {
            throw new Error(`${item.name} API Coverage is not 100%`)
          }
        }
      }
    }]]
  }
})

2. 组件库开发

对于组件库,确保每个组件的所有 API 都有测试覆盖:

reporters: [['vc-api-coverage', {
  include: ['**/src/components/**/*.{tsx,vue}'],
  format: ['cli', 'html'],
  openBrowser: true
}]]

3. 严格模式下的全面测试

对关键组件使用严格模式,确保每个 prop 变体都被测试:

reporters: [['vc-api-coverage', {
  include: ['**/src/components/Button/**/*.tsx'],
  strict: true  // 开启严格模式
}]]

总结

vc-api-coverage 通过巧妙地结合 TypeScript 类型系统和 AST 分析,实现了对 Vue 组件 API 覆盖率的精准检测。核心技术点包括:

  1. 类型系统利用:通过 $props$slots 等类型属性提取组件 API
  2. 多模式识别:支持 JSX、模板字符串、mount 对象等多种测试写法
  3. 严格模式:细粒度追踪 union 类型的每个变体
  4. 路径解析:递归追踪 import/export,准确关联测试和组件

这个工具不仅提升了组件测试的质量,还为团队提供了可量化的测试指标,让"测试覆盖率"这个概念更加贴近前端组件开发的实际需求。

后记

在目前 AI 辅助开发、Markdown 文档泛滥的场景下,其实开发一个强约束的工具也是一个不错的方向。相比于只能提供建议的文档和规范,带有强制检查能力的工具能够真正保证代码质量的底线。就像这个 API 覆盖率工具,它不是告诉你“应该写测试”,而是确保“必须写哪些测试”。

最后,感谢 Cursor 和 Claude Code 帮我完成了这个覆盖率工具和这篇分享文档。在 AI 辅助开发的时代,借助这些强大的工具,我们能够快速将想法转化为可用的产品。当然 AI 也不是万能,在某些场景下 AI i写的单测并没有实际测试到组件的功能,所以 AI 写的单测还是要让 AI 去review的。

参考资源

❌
❌