接口自动化测试的一些思考

前言

之前社区一直讨论很火的接口测试框架实现,到底是高大上的傻瓜式接口平台好用,还是全脚本的编写的接口框架好这两个方案其实我都有考虑过,两个方案各有优缺点, 我个人理解就是接口平台优点可以降低学习成本,快速使用, 全脚本编写接口框架的优点为灵活性高,各种疑难杂症的用例都能解决,而且拓展性好

在思考这两条路上,我差点做了接口平台,主要基于两点,第一点就是我上家公司,在接口平台还没火起来还没这个概念的时候,我写的接口测试框架基本上是全代码实现,在后面的大面积应用过程中,我也感觉到了很多缺点,比如分层不清晰, 大量用例维护心累等问题.第二点是接口平台能在像领导汇报时候提升逼格,出去面试时候也能吹吹

最后的结果就是何不两种方案结合下,可以提供一个测试数据模板直接把用例编写好,然后代码端又提供很好的拓展,有了这个想法就开始了我的撸码生活

技术选型

主要考虑到一些接口需要支持的东西,然后从这方面开始选型

首先技术栈是 java, 那底层支持就好选了, springBoot + maven 搞起来

java 测试框架嘛 testng 搞起来, 支持多线程多纬度运行测试用例,支持重试,支持数据驱动等等等等

测试报告 allure, 和 testng 完美兼容,且有现成 jenkins 插件

测试数据 yaml 保存

http 请求 restassured 这个 http 请求库基本上为测试而生

选型基本敲定,从底层到测试报告到 ci/cd

版本迭代

1.最初版本 0.0.1

1.1 具体实现方案为编写一个接口 api, 该 api 定义 baseUrl, 且通过方法与注解来描述里面的接口,返回一个 restassured 的 response, 然后通过代理类解析该接口生成一个具体实现类注入 spring 容器中

1.2 测试用例继承 AbstractTestNGSpringContextTests, 用@SpringBootTest开启 spring 容器

1.3 测试用例可用@Autowired注入接口类,然后像调用普通方法一样调用

大体代码如下:

接口定义类:

@HttpServer(baseUrl = "https://testplatform.com.cn/testool-api")
@Filters({RestAssuredLogFilter.class, RequestLoggingFilter.class, ResponseLoggingFilter.class})
public interface UserApi {

    @Get(url = "/menu/list", descriptoin = "获取所有菜单")
    Response getMenus();

    @Get(url = "/menu/byPhone", descriptoin = "根据手机号码查询菜单")
    Response byPhone(@ParamsForm("phone") String phone);

    @Get(url = "/role/list", descriptoin = "分页查询角色")
    Response getRoleListPage(@Head("pageNo") String pageNo, @Head("pageSize") String pageSize, @ParamsForm("roleName") String roleName);

    @Post(url = "/role/add", descriptoin = "增加用户角色")
    Response addRole(@ParamsJson String json);
}

测试用例类:

@Features("ces")
@Stories("测试Http")
public class TestoolApiTest extends BaseApiTestCase {
    @Autowired
    private UserApi userApi;

    @Severity(SeverityLevel.CRITICAL)
    @Description("测试api测试")
    @Title("TestoolUserApi")
    @Test(groups = "test-groups-1", dataProvider = "loadDataParams")
    //失败重试两次
    @RetryCount(count = 2)
    @DataParams({"1,10,"})
    public void getRoleListPage(String pageNo, String pageSize, String roleName) {
        Response response = userApi.getRoleListPage(pageNo, pageSize, roleName);
        response.then()
                .statusCode(200)
                .body("code", equalTo(200))
                .body("success", equalTo(true));
    }
}

baseTestCase 主要做一些继承和参数化

//properties属性指定本地测试需要用到的配置文件
//本地运行时,需要设置properties值为具体的某个配置文件
@SpringBootTest(properties = {"spring.profiles.active=local"})
//通过maven命令运行时需要把该参数去掉
//@SpringBootTest
public abstract class BaseTestCase extends AbstractTestNGSpringContextTests {
    @DataProvider
    public static Object[][] loadDataParams(Method method){
        DataParams dataParams =  method.getAnnotation(DataParams.class);

        AssertUtils.notNull(dataParams, method.getName() + "方法添加了DataProvider数据驱动,没有添加@DataParams注解");

        String[] values = dataParams.value();

        String split = dataParams.splitBy();

        List<Object[]> result = Lists.newArrayList();

        for (int i = 0; i < values.length; i++) {
            String[] v1 = values[i].split(split, -1);
            result.add(v1);
        }

        return wildcardMatcher(Utils.listToArray(result));
    }
}

主要注解意义:

- @HttpServer: 接口类上注解,非必填,可设置整个接口类的baseUrl
- @Filters: 用于传递reat-assured过滤器注解,接收一个io.restassured.filter.Filter类数组,
 如果注释在类上,则整个类接口都会使用该注解里面的过滤器,如果注解在方法上,则作用域该方法
- @Get/@Post: 定义接口请求方法,作用于方法上,需要设置接口请求url,接口简介,如果url为带http/https则最终请求url为该url,
如果不带http/https,则最终请求url为baseUrl + 该url,该注解可设置请求类型:content-type与请求字符编码集charset
- @HeadMap 作用于请求参数上,设置请求头,参数类型:Map
- @Head 作用于请求参数上,设置请求头,需要设置value值,请求时候value为key,参数为value,可设置多组,如果
同时设置@HeadMap与@Head则会合并为一个map
- @paramsForm 作用于请求参数上,设置请求参数,为k-v类型,注解vaule为请求k,参数为v,可多个,合并处理
- @ParamsJson 作用于请求参数上,设置请求参数,json模式,请求方式为json
- @ParamsMap 作用于请求参数上,设置请求参数,参数类型:Map

然后通过 testng 的 xml 配置文件配置运行

2.迭代 1.0.0(直接跳到现在版本,中间迭代太多)

主要问题是写一个用例耗时太多,需要改太多东西, 然后就有了思考空间, 把不需要的细节隐藏

2.1 引入 yaml 描述接口与用例

name: login
type: json
description: 登录成功
url: /login
method: POST
headers:
  x-request-client-imei: "222222222222"
requests:
  {
    "phone": "${phone, 13000000001}",
    "code": "0000"
  }
setup:
  - method: createTimestamp
  - method: setUptest
    args: ${request}
teardown:
  - method: teardowm
    args: args1111, args222
  - method: teardowm1
    args: ${response}, ${timestamp}
onFailure:
  - method: onFailure
    args: args1111, args222
validate:
  eq: ["result": 0, "error_code": "0"]
  notNull: ["data.token"]
  plugin: 
    - method: "teardowm"
      args: args1111, ${orderId}
saveGlobal: ["orderId": "response.data.token"]
saveMethod: ["orderId": "response.data.token"]
saveClass: ["orderId": "response.data.token"]
saveThread: ["orderId": "response.data.token"]
parameters:
  - name: login-phone-unregistered
    description: 手机号未注册
    headers:
    requests:
      "phone": "13000000009"
      "code": "0000"
    validate:
    eq: ["result": 1]
  - name: login-phone-not-found
    description: 手机号不存在
    requests:
      "phone": "10000000000"
      "code": "0000"
    validate:
      eq: ["result": 1]
  - name: login-code-length-error
    description: 验证码长度错误
    requests:
      "phone": "10000000000"
      "code": "000"
    validate:
      eq: ["result": 1]
  - name: login-code-error
    description: 验证码错误
    requests:
      code: "0001"
    validate:
      eq: ["result": 1]

2.2 BaseHttpClient , Response 类的一些处理

- BaseHttpClient对象方法
     wait(),wait(TimeUnit unit, long interval) 用于设置请求接口前的等待时间
     saveAsk(),saveGlobal(), saveTest(),saveSuite() 用于往不同生命周期保存一个缓存,saveAsk为该请求生命周期
     doHttp(BaseModel model)接口调用,入参为model,SINGLE模式时候直接传入方法入参BaseModel即可
     doHttp(String modelName)接口调用,入参为modelName, MULTIPLE模式时候传入modelName即可
   - Response对象方法
     then(): 语法糖,无特殊意义,只用作链式调用标明
     statusCode(): 用于断言接口返回code
     validate(): 断言方法
     eq(): 硬编码断言相等
     eqByPath(): 硬编码断言相等,值取jsonpath,xpath
     validatePlugin(): 硬编码断言,用于调用方法
     saveGlobal(), saveTest(),saveSuite() : 结果保存不同维度方法
     onFailure(BaseFailHandle failHandle): validate()断言失败后会执行的方法,所以必须在validate()方法后调用,入参为BaseFailHandle接口,需要实现该接口并且重写handle(T t)方法
     onFailure(Class clazz): 同上
     extract(): 用于取值
     processor(BaseProcessorHandle processorHandle)
     processor(Class clazz):用于该调用该接口后一些自定义处理,如订单行程需要一分钟,入参为BaseProcessorHandle接口,需要实现该接口并且重写processor(T t)方法
     wait(): 接口执行完后等待时间
     done(): 用于处理结束,抛出validate()异常,如没吊用extract()方法取值的话该方法为链式调用结尾必须调用
     auto(): 自动解析yaml文件所有内容
     auto(int httpStatusCode): 自动解析yaml文件所有内容,手动设置httpStatusCode
     autoExcludeDone(): 自动解析yml文件, 但是不会自动调用done()方法结束,需要手动调用done结束,主要用于给该http请求添加更多的自定义处理

2.3 然后 testng 类 configuration 配置方法不支持参数化,对此进行的一些加强处理

@ApiBeforeMethod
   @ApiBeforeClass
   @ApiBeforeSuite
   @ApiAfterMethod
   @ApiAfterClass
   @ApiAfterSuite
   主要结合@DataModel, @DataFile, @DataParams 当配置方法入参使用

2.4 数据驱动核心注解@DataModel

/**
 *
 * <p>数据驱动 yaml模式
 * <p> 当Format= SINGLE, vaule = {"login", "login1"}
 *     表示该用例是单接口模式
 *     用例入参模型为: BaseModel
 *     入参值为yaml文档name对应的login与login1和这两个name下的所有parameters
 *     eg.
 *     @DataModel(value = {"login", "login1"}, format = DataModel.Format.SINGLE)
 *     public void login(BaseModel model) {
 *         apiClient.doHttp(model).auto();
 *     }
 *
 * <p> 当Format= MULTIPLE, vaule = {"login", "profile"}
 *     表示该用例是业务流模式,只会运行一次,业务对应入参可从
 *     用例入参模型为: MultipleModel
 *     value可省略
 *     eg.
 *     @DataModel(format = DataModel.Format.MULTIPLE)
 *     public void login1(MultipleModel model) {
 *         driverApiClient.doHttp(model.getModel("login")).auto();
 *         driverApiClient.doHttp(model.getModel("profile")).auto();
 *     }
 *
 */
@Retention(RetentionPolicy.RUNTIME)
@Target({java.lang.annotation.ElementType.METHOD})
public @interface DataModel {
    /** 取yaml的name */
    String[] value() default {};

    /** yaml文件名字,支持多个文件引入,默认路径为 resources下的data.yml */
    String[] path() default {"data.yml"};

    /** 模式 */
    DataModel.Format format() default Format.MULTIPLE;

    enum Format
    {
        /**单接口**/
        SINGLE,
        /**串行**/
        MULTIPLE
    }

2.5 spring 功能支持多环境集成与中间的一些配置解释

application-qa.yml,application-uat.yml 区分环境配置文件,最终会根据使用环境默认合并到application.yml

- application.yml

  - notification节点: 配置通知类型
  - retry节点: 配置用例失败重试

- application-qa.yml,application-uat.yml 

  - httpurl节点: 配置接口层接口类上面@HttpServer注解中baseurl代表值baseurl直接用${driverapi.url}调用即可

2.6 测试结果通知支持钉钉,邮件等,通过 application.yml notification 节点配置

2.7 参数化说明

yaml模板中支持参数化|jsonpath|xpath等写法,如${phone, 13000000001}, 该用法为该参数化值设置一个default值,如果缓存中无该值,那就取default值
jsonpath一般用于validate与saveGlobal中,用于取返回值校验与保存
setup支持${response}参数化,${response}会转换成BaseModel
teardown支持${response}参数化,${response}会转换成Response

2.8 因为是 springboot 架构, 可直接集成 JdbcTemplate,redisTemplate,mongoTemplate 等

2.9 两个生命周期监听器用于拓展

public interface HttpPostProcessor extends PostProcessor{

    /**
     * http请求之前处理器
     * @param context
     */
    void requestsBeforePostProcessor(HttpContext context);

    /**
     * http请求之后处理器
     * @param context
     */
    void responseAfterPostProcessor(HttpContext context);


    /**
     * http请求后 对response对象进行各种处理后的处理器
     * 在{@link Response done()}内调用
     * @param context
     */
    void responseDonePostProcessor(HttpContext context);
public interface TestNgLifeCyclePostProcessor extends PostProcessor{

    /**
     * 测试方法执行前执行
     * @param result
     */
    void onTestMethodStartBeforePostProcessor(ITestResult result);

    /**
     * 测试方法执行成功后执行
     * @param result
     */
    void onTestMethodSuccessAfterPostProcessor(ITestResult result);


    /**
     * 测试方法执行失败后执行
     * @param result
     */
    void onTestMethodFailureAfterPostProcessor(ITestResult result);

    /**
     * 跳过测试方法后执行
     * @param result
     */
    void onTestMethodSkippedAfterPostProcessor(ITestResult result);

    /**
     * 在实例化测试类之后且在调用任何配置方法之前调用
     * @param context
     */
    void onTestClassInstantiationAfterPostProcessor(ITestContext context);

    /**
     * 在运行所有测试并调用其所有配置方法之后调用
     * @param context
     */
    void onAllTestMethodFinishAfterPostProcessor(ITestContext context);

    /**
     * suite执行之前执行 对应test.xml suite标签
     * @param suite
     */
    void onSuiteStartBeforePostProcessor(ISuite suite);

    /**
     * suite执行后执行 对应test.xml suite标签
     * @param suite
     */
    void onSuiteFinishAfterPostProcessor(ISuite suite);

    /**
     * 配置方法运行前执行(配置方法: beforeTest,AfterTest等)
     * @param result
     */
    void onConfigurationStartBeforePostProcessor(ITestResult result);

    /**
     * 配置方法执行成功时执行
     * @param result
     */
    void onConfigurationSuccessAfterPostProcessor(ITestResult result);

    /**
     * 配置方法执行失败时执行
     * @param result
     */
    void onConfigurationFailureAfterPostProcessor(ITestResult result);

    /**
     * 配置方法跳过时执行
     * @param result
     */
    void onConfigurationSkipAfterPostProcessor(ITestResult result);

2.10 完美兼容 allure 注解

2.11 完美兼容 testng 用法, 只做 testng 增强

2.12 完美兼容 maven, 指定 testng 用例执行, 支持多种 testng 运行纬度: XML Files, Groups, Parallel,verbosity,'testnames' in test tag

2.13 提供 com.ly.core.actuator.TestNgRun 编码方式运行用例

2.14 提供一个 har 格式转换为 yaml 用例数据 (charles 导出.har 文件转换为 yaml 格式)

2.15 jenkins 支持

3. example

3.1 先编写 yaml (yaml 默认放在 resource 目录下,也可直接指定路径)

testCase:
-   name: login
    description: 登录
    type: json
    url: /v1/security/login
    method: POST
    setup:
      - method: createTimestamp
      - method: setUptest
        args: ${request}
    headers:
        timestamp: '1589441750400'
        os: android9
        content-type: application/json;charset=UTF-8
        ver: 2.2.0
    requests:
        code: '0000'
        phone: '13000000001'
    validate:
        notNull: [result]
        eq: [result: 0]
        len: [result: 1]
        hasKey: [data: token]
        hasValue: [data: 4]
    saveMethod: ["token": "data.token"]
    teardown:
      - method: teardowm
        args: args1111, args222
      - method: teardowm1
        args: ${response}, ${timestamp}
    parameters:
      - name: login-phone-unregistered
        description: 手机号未注册
        requests:
          "phone": "13000000009"
          "code": "0000"
        validate:
          eq: ["result": 1]
      - name: login-phone-not-found
        description: 手机号不存在
        requests:
          "phone": "10000000000"
          "code": "0000"
        validate:
          eq: ["result": 1]
      - name: login-code-length-error
        description: 验证码长度错误
        requests:
          "phone": "10000000000"
          "code": "000"
        validate:
          eq: ["result": 1]
      - name: login-code-error
        description: 验证码错误
        requests:
          code: "0001"
        validate:
          eq: ["result": 1]
      - name: login-phone-isNull
        description: phone字段不存在
        requests:
          "phone": null
          "code": "0000"
        validate:
          eq: ["result": 1]

-   name: index
    description: 首页
    type: form
    url: /v1/driver/index
    method: GET
    headers:
        authorization: ${token}
        timestamp: '1589441751257'
        os: android9
        ver: 2.2.0
    requests: {}
    validate:
        notNull:
        - result
        eq:
        -   result: 0

3.2 编写 apiClient, http.test.url 写在配置文件中

@HttpServer(baseUrl = "${http.test.url}")
@Filters({RestAssuredLogFilter.class})
public interface DefaultApiClient extends BaseHttpClient{
}

3.3 编写用例

@Story("登录模块接口")
public class ExampleApiTestCase extends BaseDefaultApiTestCase {

    @DataModel(value = {"login"},
            format = DataModel.Format.SINGLE,
            path = {"example.yml"})
    @ApiBeforeClass
    public void beforeClass(BaseModel model) {
        System.out.println("===========beforeClass============: " + model);
    }

    @DataModel(value = {"login"},
            format = DataModel.Format.SINGLE,
            path = {"example.yml"})
    @ApiAfterMethod
    public void afterMethod(BaseModel model) {
        System.out.println("=============afterMethod==========" + model);
    }



    @Severity(SeverityLevel.CRITICAL)
    @Description("登录")
    @Test(groups = "example")
    @DataModel(value = {"login"},
            format = DataModel.Format.SINGLE,
            path = {"example.yml"})
    public void login(BaseModel model) {
        apiClient.doHttp(model).auto();
    }

    @Severity(SeverityLevel.CRITICAL)
    @Description("获取司机信息")
    @Test(groups = "example")
    @DataModel(format = DataModel.Format.MULTIPLE, path = "example.yml")
    public void order(MultipleModel model) {
        apiClient.doHttp("login") //调用yaml中name为 login的接口
                .processorByExpr("token", RedisDelProcessorCallback.class) //调用完做一些处理
                .eqByPath("${result}", 0) //断言
                .saveMethod("token", "token")  //保存作用域为method的缓存
                .onFailure(CancelFailHandle.class)  //如果失败执行失败兜底处理
                .done();// 结束

        apiClient.doHttp("index").auto();
    }

3.4 testng.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd">
<!--tests级别:不同test tag下的用例可以在不同的线程执行,相同test tag下的用例只能在同一个线程中执行。-->
<!--classs级别:不同class tag下的用例可以在不同的线程执行,相同class tag下的用例只能在同一个线程中执行。-->
<!--methods级别:所有用例都可以在不同的线程去执行。-->
<!--thread-count: 并发线程数-->
<suite name="自动化">
    <test verbose="5" name="example" >
        <groups>
            <!--groups分组-->
            <define name="test">
                <include name="example" />
            </define>

            <!--运行的groups-->
            <run>
                <include name="test" />
            </run>
        </groups>
        <classes>
            <class name="com.example.ExampleApiTestCase" />
        </classes>
    </test>
</suite>

3.5 测试报告 (随便搞了个)

4.写在最后

如果小伙伴有兴趣的话我把业务代码清理下,开源, 源码大概 1w 多行吧,里面各种其他处理,也希望能收到各位宝贵的意见和建议

5. 开源地址

https://github.com/luoylove/api-test
by. 12.17


↙↙↙阅读原文可查看相关链接,并与作者交流