22 低代码平台如何帮助应用做测试?副作用的检测
你好,我是陈旭。
上一讲我们用下面这图定义了被测功能。
我们抓住了触发功能这个关键环节,由于GUI中的各个功能的触发,往往是通过事件来驱动,于是,我们通过跟踪事件,顺藤摸瓜找出被测功能,并采用事件链的形式来标记一个被测功能。在多个事件链中,将相同事件叠在一起,多个事件链就可以组成了一棵事件树。
在这个推理过程中,我为了可以流畅地讲解这个过程,我故意遗漏了一个重要的技术实现难点,也就是上一讲的思考题:如何分析事件处理逻辑。这一讲我们就来解决这个问题,并更进一步,给出如何检测副作用的方法。
如何分析事件处理逻辑
为了找到事件链的下一级,我们势必要去分析处理上一级事件的逻辑,这样才能从处理逻辑中找出它是否发送了下一级事件,以及事件名是啥,这样才能一级一级找下去。在传统纯代码实现的App中,事件处理逻辑就是一个代码块。而在低代码实现的App中,处理事件的方式比纯代码要更加复杂,不仅有纯代码的处理方式,还有各种一键绑定、自动更新等非代码处理。我们分开讨论。
要准确地分析一整块代码(包含函数、类定义等任何语法),正则表达式是搞不定的,更不要说最基础的字符串搜索。唯有祭出 AST 大法了。
AST,全称为抽象语法树(Abstract Syntax Tree),是在计算机科学中广泛使用的一种数据结构。它用于表示程序代码的语法结构,以便更方便地进行分析和处理。AST由节点组成,每个节点代表程序代码中的一个语法结构。节点之间通过父子关系相连,形成树的结构。树的根节点代表整个程序的起点,而叶节点代表最小的语法单元。
这样说比较抽象,可以看一下下面这个图,它可以有效帮助你理解这段文字。
这图左右两边采用了两种不同的方式来描述同一段递归生成斐波那契数列的代码。左边的是你熟悉的代码的样子,对人类友好但对编译器不友好。右侧是一棵树,从树根SourceFile开始,每一级有数量不等的节点,没错,这就是左侧代码对应的抽象语法树。这棵树表达的逻辑和左侧接近自然语言的代码是完全一样的,但显然这不是给人类准备的,它是给编译器准备的。编译器能快速准确地处理右侧的代码,但你我却不行。
你仔细观察,代码中的任何关键字、常量、符号,都可在右侧的AST树上找到对应的节点。所以,通过遍历AST,就可以非常快速、准确地找出是否有哪个节点调用了发送自定义事件的API(这个API当然是已知的),如图倒数第4个节点的CallExpression节点,就是一个调用API节点,并且通过读取这个节点的子级节点,就可以轻松获得发送自定义事件的事件等信息。
还记得上一讲我根据事件的创建者将事件分成了两组吗?第一组是系统创建的,第二组是自定义事件,是由应用开发人员创建的。这样分组后,我们会发现,第一组事件往往是一个事件链的起点,而第二组自定义事件则只出现在事件链的中间位置,当时我说这样个特点可以帮助我们大幅降低事件链的发现复杂度,现在可以解释清楚这个说法背后的原因了。那就是,我们在遍历 AST 树的时候,只需要寻找调用了发送自定义事件的API节点即可,这样会使得判断逻辑大幅简化。
现在,事件处理逻辑中最复杂的一类——自定义代码块的分析已经被我们用AST克服了,还剩下各种一键绑定、自动更新等非代码处理方式了,比如下面这个配置界面就是一个典型。
请注意更新时机这个配置项,就是一个自定义事件,事件名是 this.jigsawButton261.click。在这个界面确定保存时,显然低代码平台不会将生成的代码保存下来,而是会将这个界面对应的结构化信息(JSON对象)保存下来。类似这样的结构:
export class InteractiveConfig {
...
}
export class ExecutiveData {
...
config: InteractiveConfig
...
}
这类事件处理逻辑,分析起来可比代码块要简单许多了,输入的数据天然就是结构化的,而且结构是事先已知的。但是事情还没完。要知道,在一个具有一定可用性的低代码平台上,一个组件的配置项是非常多的,越成熟的低代码平台,可用的配置项就会越多,配置项过千是一点都不稀奇。这些配置项分布在一棵深度达到 10~20 层的JSON树上。
面对一个如此复杂的结构化数据,我们如何知晓哪个属性是用于存储事件处理逻辑的,结构是啥,哪个属性是代码块?这不是一个轻易可以给出答案的问题。最容易想到的是用if else来把各种情况一一枚举出来,但这不是长久之计,因为低代码平台持续迭代,保存配置的数据结构也在不停变化,哪天修改了结构忘了更新if else的逻辑,bug就来了。
那么有没有更好的办法可以解决这个问题?
当然是有的,我推荐你使用装饰器来解决这个问题。JS原生还未支持装饰器,目前还在TC39的Stage 2中,我这里用的是TypeScript提供的装饰器解决方案。装饰器是TypeScript的一项重要特性,它提供了一种修改类、方法、属性或参数的能力,它的应用非常广泛,我们这里就用它来为一个类成员属性添加特定的标记,通过这个标记,我们就可以很容易回答前面的问题:有没有涉及事件逻辑以及结构是啥。
比如我们可以给前面界面的配置数据ExecutiveData,添加这样的装饰器:
export class ExecutiveData {
...
@Interactive('eventEmitter', InteractiveConfig)
config: InteractiveConfig;
}
注意,这里我们给 Config 属性戴了一个 @Interactive 的帽子,Interactive就是一个装饰器,它的作用就是悄悄咪咪地给Config属性加上了两个额外的信息,装饰器会把这些信息记录在ExecutiveData的原型链上备用。
装饰执行后的ExecutiveData近似等于下面这样的代码,注意下,这里多出了一个__configMetainfo属性。
export class ExecutiveData {
...
config: InteractiveConfig;
__configMetainfo = {type: 'eventEmitter', ctor: InteractiveConfig}
}
在遍历到这个属性时,我们会尝试从一个对象的原型链上读取这些信息,如果有,这个属性则包含事件处理逻辑,并且它的结构是 InteractiveConfig。补充一下,对于代码块的装饰器是这样用的:
这个方法从头到尾没有一个if else,而且添加的额外标记与被标记的属性是紧挨着的,这样无论日后如何迭代,大概率是不会出错了。
副作用与断言
我们依然回到我们的终极目标上:自动生成自动化测试用例代码,而断言是自动化测试的核心目的(没有之一),没有断言的测试用例是毫无意义的。那么,如何生成断言呢?
为了解答这个问题,我们来回顾一下被测功能的定义,尝试从中找出蛛丝马迹。
我们发现,副作用是被测功能的基本特征之一,而且副作用是否符合我们的预期,是检查一个被测功能是否满足需求的关键特征。所以,检查副作用是否符合预期的逻辑,就是测试用例中的断言!
好了,现在我们的目标非常明确,那就是要检查副作用,但是,副作用到底是啥?它又在哪呢?
我们前文所提及的“副作用”都是采用的比喻说法,此前这个修辞有利于你的学习,但现在我们要实打实去寻找副作用这个实体,那就不能再比喻了,所以现在得从看得到、摸得着的角度来说说到底啥是副作用了。
不啰嗦,直接给结果:副作用就是一个组件在功能触发前后的状态变化。所以,寻找副作用的过程,实际上就是要寻找被测功能所波及到的组件实例的过程。
我们先来讨论副作用是如何产生的,总结起来,有这些途径:
- 组件的数据属性变化,如这行代码
table.data = new TableData(...); //
表格的 data 属性变更导致表格更新,这将触发副作用。 - 调用组件的API更新,效果和第1种相似,如这行代码
table.refresh()
。 - 属性绑定的自定义事件触发,如这行代码
eventBus.emit('this.jigsawButton261.click')
。它发出了 this.jigsawButton261.click 这个自定义事件,我这里延续采用了上一节讨论装饰器的那个例子,你可以回顾一下那个例子,否则你可能无法真正理解。
只要挨个找出这些可能触发副作用的代码或配置,并一一确认是否包含3种代码,如果有,则进一步分析,获得对应组件的ID,这样就可以找到所有波及到的组件实例了。不需要我提示,你应该已经想到,分析代码或配置的过程,和刚刚我们分析事件处理逻辑的过程如出一辙,因此,我们可以采用基本相同的方法来来处理。
首先是要标记哪些属性可能需要分析。这个时候,装饰器又可以派上用场了。我们可以定义一个新的装饰器来做这个事情,它的实现逻辑,甚至连参数都和前面的@Interactive这个装饰器一样,差别在于它需要在原型链上植入另一个标记,避免与@Interactive搞混。
完成了标记后,在遍历配置的过程中,我们就方便找到这些需要分析的属性了,并且从植入的信息里可以分清这个属性是代码块,那个属性是一个JSON对象并且结构是啥。如果是代码块,那依然需要使用AST来分析得到组件ID,如果是JSON对象,则可以直接读取对应属性值来得到组件ID。
接下来就是最后一步,生成断言代码了。
断言是一个二元运算,也就是有两个数据参与运算,一个是当前状态值,另一个则是预期值。状态的当前值当然是基于组件实例读取而来的,预期值是无法自动获取的,这个必须人工输入,这也是下一讲需要重点讲解的内容,现在我们不展开。
作为UI自动化测试的断言,一般来说,当前状态值一般就是当前页面的DOM结构,比如下面这样的代码,就是典型的读取DOM状态。
it('should assert the visibility of an element', () => {
cy.visit('https://example.com');
cy.get('h1').should('be.visible');
});
BTW,这是一段Cypress的代码,我们使用Cypress作为我们的自动化测试驱动框架,非常不错,我推荐给你。
我们要生成的断言的代码,当然可以和这个示例代码一样,但是,我们并没有这么做,而是采取了一个“讨巧”的做法,这样可以节约大量的处理DOM细节的工作,但可以达到基本相似的效果。
首先,我们做了一个假设:组件是不会出错的。从通常的逻辑来说,这个假设是不成立的,但是在低代码平台这样的庞大的项目中,从软件工程协同开发角度来说,这样的假设是有充分的基础的,那就是有专门的特性团队为组件集的质量负责,一旦组件出错了,他们必须负责修复组件的问题。
现在,既然组件不会出错,那么就可以得到一个关键推论:只要喂给组件的数据是对的,那么组件渲染出来的界面肯定是对的。基于这个推论,我们就可以无需关注组件产生的DOM细节了,而只要校验组件当前的数据符合预期就行了。比对两个JSON对象,比比对两棵DOM树要简单不知道多少倍!
我们生成的比对代码,并非严格比较2个JSON对象严格相等,而是采用组件当前数据是否包含了预期数据的方式,这是因为,预期数据是需要人工配置的,人总是喜欢偷懒。就如为一个表格配置预期数据,一般来说,只要表格包含了一行或者少数几行关键数据就行了,其他数据是可有可无的,此时开发人员只要填上核心那一个行数据即可。
下面是比对两个JSON对象是否包含另一个的关键代码,供你参考:
function assertJsonEquality(currentValue: any, expectedValue: any): boolean {
for (const key in expectedValue) {
if (typeof expectedValue[key] === 'object' && expectedValue[key] !== null) {
if (!currentValue.hasOwnProperty(key) || typeof currentValue[key] !== 'object' || currentValue[key] === null) {
return false;
}
if (!assertJsonEquality(currentValue[key], expectedValue[key])) {
return false;
}
} else {
if (!currentValue.hasOwnProperty(key) || currentValue[key] !== expectedValue[key]) {
return false;
}
}
}
return true;
}
小结
我们抓住了触发被测功能的事件这个关键点,通过对事件的跟踪,就可以找出了一个个被测功能了。而要对事件进行跟踪,则只能通过对事件的处理逻辑进行分析来实现。传统纯代码开发的App处理事件只有一种手段,那就是事件的回调函数,也就是一个代码块。低代码平台上处理事件的手段,除了有代码块之外,还有其他形式。因此,在低代码平台上,要对事件处理逻辑进行分析,实际上比纯代码模式要更复杂。
AST虽然复杂,耗时也更大,但这是对代码块进行分析和处理的唯一可靠手段,我们主要要在AST树上找到发送自定义事件的表达式,一旦发现了这样的节点,那就可以进一步提取出这个表达式中发送的事件名。剩下的还需要分析的是各种一键绑定、自动更新等非代码处理方式,在低代码平台上,这些事件处理方式天然就是结构化的,分析起来相对简单,但是麻烦的地方在于如何在数以千计的属性节点中寻找到哪些属性需要处理。我们采用的解决方案是装饰器,给需要分析的节点加上装饰器,后续在遍历配置信息过程中,只要发现一个属性被装饰过,就意味着这是一个处理事件的属性,需要加以分析。
完成了事件的分析之后,我们就开始了对副作用的讨论,所谓副作用实际上就是被测功能所波及到的组件,它在受波及前和波及后的状态差异,就是副作用。对副作用的跟踪,我们依然采用了与事件分析相同的方法,采用AST分析代码,采用装饰器标记需要分析的组件属性。寻找副作用的根据目的在于生成断言的代码。
至此,我们就完成了在低代码平台上自动生成测试用例代码的所有准备任务,下一讲就可以生成测试用例代码了。
思考题
在副作用检测的逻辑中,我们做了一个假设:只要喂给组件的数据是对的,那组件绘制的界面就是对的。这样做是为了避免读取DOM的细节来检测副作用,你能举例说说,读取DOM的细节如何繁琐吗?比如可以说说通过DOM的API如何读取表格各个单元格的值,还可以说说如何读取一个ol元素里的各个条目?
期待你的分享!我是陈旭,我们下一讲再见。
- ifelse 👍(0) 💬(0)
学习打卡
2023-07-30