Vue 组件 API 覆盖率工具
前言
在组件开发中,我们经常面临一个问题:组件测试是否真正覆盖了组件的所有 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流水线的周期检查。
通过工具化的方式,我们把主观的人工检查转变为客观的自动化检测,把模糊的质量要求转变为精确的量化指标。
整体架构
整体架构分为三个核心模块:
- ComponentAnalyzer:分析组件定义,提取所有可用的 API
- UnitTestAnalyzer:分析测试代码,识别哪些 API 被测试覆盖
- Reporter:生成可视化的覆盖率报告(CLI、HTML、JSON)
技术选型
在开始介绍具体实现之前,先分享一下技术选型过程中的弯路和思考。
早期方案
最初设计这个覆盖率工具时,我的想法是通过 AST(抽象语法树)去分析组件代码,直接提取出 Props、Slots 和 Exposed Methods。这个方案看起来很直接,但在实践中遇到了巨大的挑战:
Vue 组件的写法复杂多变,静态分析难以覆盖所有场景:
-
多种 API 风格:组件既可以用 Composition API 的
setup写法,也可以用 Options API 写法 -
运行时配置:组件可能配置了
mixins、extends等,这些内容需要递归分析多个文件 -
动态计算的 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);
}
}
}
核心原理:
- 通过正则表达式匹配
expose({ ... })调用 - 通过 AST 分析
defineComponent的expose选项 - 支持多种写法:字符串字面量、标识符、枚举值等
测试覆盖分析
测试代码有多种写法,我们需要支持各种常见的测试模式。
模式 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;
}
核心原理:
- 检测 prop 类型是否为 union 类型
- 遍历所有 union 成员,提取字面量值
- Boolean 类型只展开
true(因为false通常是默认值) - 过滤掉
undefined和null
测试值提取
在测试代码中,我们需要提取实际传递的值:
// 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;
}
核心原理:
- 直接提取字面量值
- 对于变量和表达式,利用 TypeScript 的类型推断获取值
- 支持 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;
}
}
核心原理:
-
从 identifier 获取 symbol
-
递归解析 alias symbol(处理
export { Button as Btn }等情况) -
获取原始声明文件路径
-
处理中间层的转发导出
实际应用场景
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 覆盖率的精准检测。核心技术点包括:
-
类型系统利用:通过
$props、$slots等类型属性提取组件 API - 多模式识别:支持 JSX、模板字符串、mount 对象等多种测试写法
- 严格模式:细粒度追踪 union 类型的每个变体
- 路径解析:递归追踪 import/export,准确关联测试和组件
这个工具不仅提升了组件测试的质量,还为团队提供了可量化的测试指标,让"测试覆盖率"这个概念更加贴近前端组件开发的实际需求。
后记
在目前 AI 辅助开发、Markdown 文档泛滥的场景下,其实开发一个强约束的工具也是一个不错的方向。相比于只能提供建议的文档和规范,带有强制检查能力的工具能够真正保证代码质量的底线。就像这个 API 覆盖率工具,它不是告诉你“应该写测试”,而是确保“必须写哪些测试”。
最后,感谢 Cursor 和 Claude Code 帮我完成了这个覆盖率工具和这篇分享文档。在 AI 辅助开发的时代,借助这些强大的工具,我们能够快速将想法转化为可用的产品。当然 AI 也不是万能,在某些场景下 AI i写的单测并没有实际测试到组件的功能,所以 AI 写的单测还是要让 AI 去review的。