低代码平台中的自动化测试

400 阅读18分钟

自动化测试是现代软件开发实践中一个关键性的组成部分,但是它的维护成本往往居高不下,在投入资源有限的项目中一般难以达到较高的自动化测试覆盖率。很自然的,会有人想用可视化的低代码平台来简化测试用例的编写和维护。但是,** 自动化测试维护的本质性难点并不是是否可视化的问题,而是测试用例的脆弱性问题** 。一般来说,我们编写的测试用例采用的都是外部视角,即提供输入,调用函数,然后检查返回结果。但是业务函数很少是所谓的纯函数,它们的执行都必然涉及到大量的副作用,例如,读写数据库、并发访问、生成随机数等。这造成使用同样的输入参数调用同一个测试用例得到的返回结果可能是不确定的,比如说,执行转账操作之后账户余额减少,再次执行同样的转账操作,则可能会失败。为了克服这种不确定性,我们被迫手工编写大量的数据初始化代码,并将结果检查写成某种模糊匹配的形式。因为这一过程非常冗长,我们很难实现的非常严密,特别是当存在脏数据、数据结构频繁发生变化时,基于外部视角实现的测试用例就显得特别的脆弱。本文将介绍在Nop平台中使用的NopAutoTest自动化测试框架,它是与Nop平台完全集成在一起,并协同设计的后端应用自动化测试框架。它充分利用了Nop平台中的各种模型信息,通过录制回放、数据驱动、模型转化等一系列手段,有效的缓解了测试用例的脆弱性问题。具体实现可以参考nop-auto-test和nop-match模块

nop-autotest

nop-match

一. 低代码平台的特殊性

低代码平台本质上的特殊性不是它所提供的可视化操作界面,而是它内在的模型化的逻辑结构。如果一个低代码平台是充分模型化的,那么所有的副作用都应该是可观测的

  数据 = 输入 + 输出 + 副作用

如果将观测到的副作用数据补充到输入输出数据集中,则我们会得到系统完全的信息集合,从而消除所有不可知的副作用,将测试用例恢复为具有完全确定性的纯函数,克服因为信息不完备所导致的脆弱性问题

在应用开发中,常见的副作用有如下几种

  1. 数据库读写

    基于数据库中不同的数据状态,应用系统的行为可能存在很大的不同。而且,除了接口返回的结果数据之外,业务操作对整个系统的影响可能更多的体现在对数据库中核心业务数据的修改。如果我们需要验证业务实现正确,一般情况下除了检查接口结果数据之外,测试人员还需要执行数据库验证脚本来确认数据库中的数据状态满足完整性要求。

  2. 随机数和时间

程序代码中不可避免的需要使用系统时钟来记录当前操作时间,需要随机生成卡号、订单号、主键ID等每次执行都不重复的变化数据。

  1. 异步处理

    当业务接口返回结果之后,可能一些异步任务仍然在执行,需要经过一段时间之后,最终的系统状态才能稳定下来。

  2. 缓存读写 缓存读写的情况和数据库读写类似。但是缓存一般在业务上属于是可选组件,在验证核心业务正确性时可以考虑禁用缓存或者主动将缓存清空等。

  3. 外部基础设施和环境

    系统正常运行时可能需要依赖外部的基础设施,比如注册中心、消息队列、第三方服务等,也可能对外部的网络环境配置有一定的要求。

在Nop平台中,所有的业务对象都通过依赖注入容器来管理,而所有具有副作用的操作都通过模型化的引擎或者服务接口提供。在这种情况下,它为自动化测试提供了如下技术支持:

  1. 对数据库读写的录制回放

    NopOrm数据访问引擎是类似Hibernate的完整的ORM引擎,它可以记录所有应用程序读取和修改的数据库记录。测试用例初次执行的时候会录制数据库读写数据,启用快照执行模式后,测试框架会利用录制的数据,创建数据库表并插入初始化数据记录,同时在测试用例运行完毕后会自动校验数据库修改与录制的修改内容保持一致。

    即使是通过update xx set yy=zz这种EQL语句所执行的批量更新,也可以由ORM引擎解析EQL语句,获取到修改前和修改后的数据记录。

  2. 对随机数和时间的变量标注

    对于随机数和时间等每次执行都发生变化的数据,可以将它们标记为AutoTestVariable,由测试框架负责跟踪这些变量的传播过程。特别的,根据ORM模型上定义的主外键关联关系,我们可以自动识别出所有引用变量数据的外键变量。在最终的数据验证过程中,验证条件成为变量匹配

      checkMatch("@var:NopAuthSession@sessionId", visitLog.sessionId)
    
  3. 等待异步处理完成 测试框架可以通过监控异步处理队列的执行状态,等待所有暂态处理过程全部完成,从而得到满足最终一致性的确定性结果。

     waitUnti(()-> taskService.isAllProcessed(), 1000);
    
  4. 集成docker环境
    测试框架可以通过testcontainers库来集成docker环境,将测试所需的一些基础设施放到docker环境中运行。

二. 数据驱动测试

NopAutoTest测试框架是一个数据驱动的测试框架,这意味着一般情况下我们不需要编写任何准备输入数据和校验输出结果的代码,只需要编写一个骨架函数,并提供一批测试数据文件即可。具体来看一个示例

nop-auth/nop-auth-service/src/test/io/nop/auth/service/TestLoginApi.java

nop-auth/nop-auth-service/cases/io/nop/auth/service/TestLoginApi

class TestLoginApi extends JunitAutoTestCase {
   // @EnableSnapshot
    @Test
    public void testLogin() {
        LoginApi loginApi = buildLoginApi();

         //ApiRequest<LoginRequest> request = request("request.json5", LoginRequest.class);
        ApiRequest<LoginRequest> request = input("request.json5", new TypeReference<ApiRequest<LoginRequest>>(){}.getType());

        ApiResponse<LoginResult> result = loginApi.login(request);

        output("response.json5", result);
    }
}

测试用例从JunitAutoTestCase类继承,然后使用input(fileName, javaType) 来读取外部的数据文件,并将数据转型为javaType指定的类型。具体数据格式根据文件名的后缀来确定,可以是json/json5/yaml等。

调用被测函数之后,通过output(fileName, result)将结果数据保存到外部数据文件中,而不是编写结果校验代码。

2.1 录制模式

testLogin在录制模式下执行时会生成如下数据文件

TestLoginApi
    /input
       /tables
          nop_auth_user.csv
          nop_auth_user_role.csv
       request.json5    
    /output
       /tables
          nop_auth_session.csv
      response.json5    

/input/tables目录下会记录读取过的所有数据库记录,每张表对应一个csv文件。

即使是没有读取到任何数据,也会生成对应的空文件。因为在验证模式下需要根据这里录制的表名来确定需要在测试数据库中创建哪些表。

如果打开response.json5文件,我们可以看到如下内容

{
  "data": {
    "accessToken": "@var:accessToken",
    "attrs": null,
    "expiresIn": 600,
    "refreshExpiresIn": 0,
    "refreshToken": "@var:refreshToken",
    "scope": null,
    "tokenType": "bearer",
    "userInfo": {
      "attrs": null,
      "locale": "zh-CN",
      "roles": [],
      "tenantId": null,
      "timeZone": null,
      "userName": "auto_test1",
      "userNick": "autoTestNick"
    }
  },
  "httpStatus": 0,
  "status": 0
}

可以注意到,accessToken和refreshToken已经被自动替换为了变量匹配表达式。这一过程完全不需要程序员手工介入。

至于录制得到的nop_auth_session.csv,它的内容如下

_chgType,SID,USER_ID,LOGIN_ADDR,LOGIN_DEVICE,LOGIN_APP,LOGIN_OS,LOGIN_TIME,LOGIN_TYPE,LOGOUT_TIME,LOGOUT_TYPE,LOGIN_STATUS,LAST_ACCESS_TIME,VERSION,CREATED_BY,CREATE_TIME,UPDATED_BY,UPDATE_TIME,REMARK
A,@var:NopAuthSession@sid,067e0f1a03cf4ae28f71b606de700716,,,,,@var:NopAuthSession@loginTime,1,,,,,0,autotest-ref,*,autotest-ref,*,

第一列_chgType表示数据变更类型,A-新增,U-修改,D-删除。随机生成的主键已经被替换为变量匹配表达式@var:NopAuthSession@sid 。同时,根据ORM模型所提供的信息,createTime字段和updateTime字段为簿记字段,它们不参与数据匹配校验,因此被替换为了*,表示匹配任意值。

2.2 验证模式

当testLogin函数成功执行之后,我们就可以打开@EnableSnapshot注解,将测试用例从录制模式转换为验证模式。 在验证模式下,测试用例在setUp阶段会执行如下操作:

  1. 调整jdbcUrl等配置,强制使用本地内存数据库(H2)
  2. 装载input/init_vars.json5文件,初始化变量环境(可选)
  3. 收集input/tables和output/tables目录下对应的表名,根据ORM模型生成对应建表语句并执行
  4. 执行input目录下的所有xxx.sql脚本文件,对新建的数据库进行自定义的初始化(可选)。
  5. 将input/tables目录下的数据插入到数据库中

测试用例执行过程中如果调用了output函数,则会基于MatchPattern机制来比较输出的json对象和录制的数据模式文件。具体比较规则参见下一节的介绍。 如果期待测试函数抛出异常,则可以使用error(fileName, runnable)函数来描述

@Test
public void testXXXThrowException(){
   error("response-error.json5",()-> xxx());
}

在teardown阶段,测试用例会自动执行如下操作:

  1. 比较output/tables中定义的数据变化与当前数据库中的状态,确定它们是否吻合。
  2. 执行sql_check.yaml文件中定义的校验SQL,并和期待的结果进行比较(可选)。

2.3 测试更新

如果后期修改了代码,测试用例的返回结果发生了变化,则我们可以临时设置saveOutput属性为true,更新output目录下的录制结果。

@EnableSnapshot(saveOutput=true)
@Test
public void testLogin(){
    ....
}

三. 基于前缀引导语法的对象模式匹配

在上一节中,用于匹配的数据模板文件中匹配条件只包含固定值和变量表达式@var:xx 两种,其中变量表达式采用了所谓的前缀引导语法(详细介绍可以参加我的文章DSL分层语法设计及前缀引导语法 ),这是一种可扩展的领域特定语法(DSL)设计。首先,我们注意到@var:前缀可以被扩展为更多情况,例如 @ge:3表示大于等于3。第二,这是一种开放式的设计。** 我们随时可以增加更多的语法支持,而且可以确保它们之间不会出现语法冲突**。第三,这是一种局域化的嵌入式语法设计,String->DSL 这一转换可以将任意字符串增强为可执行的表达式,例如在csv文件中表示字段匹配条件。我们来看一个更加复杂的匹配配置

{
  "a": "@ge:3",
  "b": {
    "@prefix": "and",
    "patterns": [
      "@startsWith:a",
      "@endsWith:d"
    ]
  },
  "c": {
    "@prefix": "or",
    "patterns": [
      {
        "a": 1
      },
      [
        "@var:x",
        "s"
      ]
    ]
  },
  "d": "@between:1,5"
}

这个示例中通过@prefix引入了具有复杂结构的and/or匹配条件。类似的,我们可以引入if,switch等条件分支。

{
    "@prefix":"if"
    "testExpr": "matchState.value.type == 'a'",
    "true": {...}
    "false": {...}
}
{
    ”@prefix":"switch",
    "chooseExpr": "matchState.value.type",
    "cases": {
       "a": {...},
       "b": {...}
    },
    "default": {...}
}

testExpr为XLang表达式,其中matchState对应于当前匹配上下文对象,可以通过value获取到当前正在匹配的数据节点。根据返回值的不同,会选择匹配true或者false分支。

这里”@prefix“对应于前缀引导语法的explode模式,它将DSL展开为Json格式的抽象语法树。如果因为数据结构限制,不允许直接嵌入json,例如在csv文件中使用时,我们仍然可以使用前缀引导语法的标准形式。

@if:{testExpr:'xx',true:{...},false:{...}}

只要把if对应的参数通过JSON编码转化为字符串,再拼接上@if:前缀就可以了。

前缀引导语法的语法设计方式非常灵活,并不要求不同前缀的语法格式完全统一。例如@between:1,5表示大于等于1并且小于等于5。前缀后面的数据格式只有前缀对一个的解析器负责识别,我们可以根据情况设计对应的简化语法。

如果只需要验证对象中的部分字段满足匹配条件,可以使用符号*来表示忽略其他字段

{
    "a":1,
    "*": "*"
}

四. 多步骤相关测试

如果要测试多个相关联的业务函数,我们需要在多个业务函数之间传递关联信息。例如登录系统之后得到accessToken,然后再用accessToken获取到用户详细信息,完成其他业务操作之后再传递accessToken作为参数,调用logout退出。

因为存在共享的AutoTestVars上下文环境,业务函数之间可以通过AutoTestVariable自动传递关联信息。例如

    @EnableSnapshot
    @Test
    public void testLoginLogout() {
        LoginApi loginApi = buildLoginApi();

        ApiRequest<LoginRequest> request = request("1_request.json5", LoginRequest.class);

        ApiResponse<LoginResult> result = loginApi.login(request);

        output("1_response.json5", result);

        ApiRequest<AccessTokenRequest> userRequest = request("2_userRequest.json5", AccessTokenRequest.class);

        ApiResponse<LoginUserInfo> userResponse = loginApi.getLoginUserInfo(userRequest);
        output("2_userResponse.json5", userResponse);

        ApiRequest<RefreshTokenRequest> refreshTokenRequest = request("3_refreshTokenRequest.json5", RefreshTokenRequest.class);
        ApiResponse<LoginResult> refreshTokenResponse = loginApi.refreshToken(refreshTokenRequest);
        output("3_refreshTokenResponse.json5", refreshTokenResponse);

        ApiRequest<LogoutRequest> logoutRequest = request("4_logoutRequest.json5", LogoutRequest.class);
        ApiResponse<Void> logoutResponse = loginApi.logout(logoutRequest);
        output("4_logoutResponse.json5", logoutResponse);
    }

其中2_userRequest.json5中的内容为

{
  data: {
    accessToken: "@var:accessToken"
  }
}

我们可以用@var:accessToken来引用前一个步骤返回的accessToken变量。

集成测试支持

如果是在集成测试场景下,我们无法通过底层引擎自动识别并注册AutoTestVariable,则可以在测试用例中手工注册

public void testXXX(){
     ....
    response = myMethod(request);
    setVar("v_myValue", response.myValue);
    // 后续的input文件中就可以通过@var:v_myValue来引用这里定义的变量
    request2 = input("request2.json", Request2.class);
    ...
}

在集成测试场景下,我们需要访问外部独立部署的测试数据库,而不再能够使用本地内存数据库。此时,我们可以配置localDb=false来禁用本地数据库

@Test
@EnableSnapshot(localDb=false)
public void integrationTest(){
    ...
}

EnableSnapshot具有多种开关控制,可以灵活选择启用哪些自动化测试支持

public @interface EnableSnapshot {

    /**
     * 如果启用了快照机制,则缺省会强制使用本地数据库,并且会使用录制的数据来初始化数据库。
     */
    boolean localDb() default true;

    /**
     * 是否自动执行input目录下的sql文件
     */
    boolean sqlInit() default true;

    /**
     * 是否自动将input/tables目录下的数据插入到数据库中
     */
    boolean tableInit() default true;

    /**
     * 是否将收集到的输出数据保存到结果目录下。当saveOutput=true时,checkOutput的设置将会被忽略
     */
    boolean saveOutput() default false;

    /**
     * 是否校验录制的输出数据与数据库中的当前数据相匹配
     */
    boolean checkOutput() default true;
}

五. 数据变体

数据驱动测试的一个非常大优势在于,它很容易实现对边缘场景的细化测试。

假设我们需要要测试一个用户账户欠费之后的系统行为。我们知道,根据用户欠费额的大小,欠费时间的长短,系统行为在某些阈值附近可能存在着很大的变化。而构造一个完整的用户消费和结算历史是非常复杂的一项工作,我们很难在数据库中构造出大量具有微妙差异的用户数据用于边缘场景测试。如果使用的是数据驱动的自动化测试框架,则我们可以将已有的测试数据复制一份,然后在上面直接做精细化调整就可以了。

NopAutoTest框架通过数据变体(Variant)的概念来支持这种细化测试。例如

    @ParameterizedTest
    @EnableVariants
    @EnableSnapshot
    public void testVariants(String variant) {
        input("request.json", ...);
        output("displayName.json5",testInfo.getDisplayName());
    }

在增加了@EnableVariants@ParameterizedTest注解之后,当我们调用input函数的时候,它读取的数据是/variants/{variant}/input目录下的数据与/input目录下的数据合并的结果。

/input
   /tables
      my_table.csv
   request.json
/output
   response.json
/variants
   /x
      /input
         /tables
            my_table.csv
         request.json
      /output
         response.json
   /y
      /input
     ....

首先,测试用户会在忽略variants配置的情况下执行,此时会录制数据到input/tables目录下。然后,在开启variant机制之后,按照每个variant会再次执行测试用例。

以testVariants的配置为例,它实际上会被执行3遍,第一遍variant=_default ,表示以原始的input/output目录数据来执行。第二遍执行variants/x目录下的数据,第三遍执行variants/y目录下的数据。

因为不同变体之间的数据往往相似度很高,我们没有必要完整复制原有的数据。NopAutoTest测试框架在这里采用了可逆计算理论的统一设计,可以利用Nop平台内置的delta差量合并机制来实现配置简化。例如在/variants/x/input/request.json文件中

{
    "x:extends":"../../input/request.json"
    "amount": 300
}

x:extends是可逆计算理论引入的标准差量扩展语法,它表示从原始的request.json继承,只是将其中的amount属性修改为300。

类似的,对于/input/tables/my_table.csv中的数据,我们可以只在其中增加主键列和需要被定制的列,然后其中的内容会和原始目录下的对应文件自动合并。例如

SID, AMOUNT
1001, 300

整个Nop平台都是基于可逆计算原理而从头开始设计并实现的,关于它的具体内容可以参见文末的参考文档。

数据驱动测试在某种程度上也体现了可逆计算的所谓可逆性要求,即我们已经通过DSL(json数据以及匹配模板)表达的信息,可以被反向析取出来,然后通过再加工转换为其他信息。例如,当数据结构或者接口发生变化的情况下,我们可以通过编写统一的数据迁移代码,将测试用例数据迁移到新的结构下,而无需重新录制测试用例。

六. 作为DSL载体的Markdown

可逆计算理论强调通过描述式的DSL来取代一般的命令式程序编码,从而在各个领域、各个层面降低业务逻辑所对应的代码量,通过体系化的方案落地低代码。

在测试数据表达和验证方面,除了使用json/yaml等形式之外,也可以考虑采用更加接近文档形式的Markdown格式。

在XLang语言的测试中,我们规定了一个标准化的markdown结构用于表达测试用例

# 测试用例标题
 具体说明文字,可以采用一般的markdown语法,测试用例解析时会自动忽略这些说明
‘’‘测试代码块的语言
测试代码
’‘’

 * 配置名: 配置值
 * 配置名: 配置

具体实例可以参见TestXpl的测试用例 TestXpl

七. 小结

Nop平台是基于可逆计算原理从零开始构建的新一代低代码平台。它采用的是DSL优先、模型优先、自动测试优先的正向设计方案,而不是根据已有的程序框架结合部分低代码改造得到,在很多方面可以克服目前业界公开的低代码方案所存在的困难。

NopAutoTest是Nop平台的一个有机的组成部分。它充分利用Nop平台内部已有的模型信息进行自动推导,并结合可逆计算特有的差量化结构定义语法,可以有效降低自动化测试用例的维护成本。

在Nop平台的技术体系中,低代码定位于开发阶段,即低代码会根据模型生成代码,与手工编写的代码实现紧密集成。而无代码定位于运行阶段,通过可视化界面与用户交互,实现部分逻辑的定制和调整。NopAutoTest是支持低代码开发的一个测试框架,使用它无需单独部署额外的服务,可以集成在一般开发的DevOps流程中,使用已有的maven test指令来执行测试。

另一方面,NopAutoTest虽然提供了JUnit测试框架的集成方案,它的核心代码实际上与任何单元测试框架都无关,因此有可能将它的功能集成到运行时引擎中。例如,在界面上我们可以提供一个调试开关,开启后表示录制测试用例,此时会自动跟踪所有后续发起的后台调用,并录制所访问的所有数据库数据以及对数据库所做的更改,然后打包为一个离线测试用例。

关于可逆计算理论的详细介绍,可以参见我此前的文章 可逆计算:下一代软件构造理论 从张量积看低代码平台的设计 低代码平台需要什么样的ORM引擎(1) 低代码平台需要什么样的ORM引擎(2)

基于可逆计算理论设计的低代码平台NopPlatform已开源: