Mock工具wiremock-py
2018-03-26

wiremock-py

wiremock-py 是基于WireMock实现的, 使用Python批量生成不同 测试场景 下不同HTTP API的 mock 数据, 然后作为mock server快速全面地对 API 进行测试。

背景

在数澜地产应用的前端测试中, 前端一般依赖于后端的数据, 前端通过后端在网关上发布的 HTTP API 获取数据. 要对前端进行充分的测试, 理想的做法是, 等待后端部署完成, 并且在数据层直接输入不同类型的数据源, 然后前端直接调用后端发布在网关上的 API 进行测试。

640.webp.jpg

然而现实的情况是, 前端和后端的开发进度不完全一致, 如果前端先开发完成了, 必须要等后端对应的 API 开发完成后才能开始测试, 而且数据层的数据也不容易构造.

为了解决这个问题, 网关平台做了简单的 mock 功能, 每个 API 可以填写一个 mock数据, 然后前端调用 API 时直接使用这个 mock数据:

640.webp (1).jpg

这种方式下, 网关充当了mock server:

640.webp (2).jpg

但由于大家都使用同一个网关, 一个 API 只能保存一份 mock 数据, 所以有以下一些缺点:

  • 不同的测试场景需要不同的 mock 数据来测试, 此时需要删掉上个测试场景的 mock 数据, 再创建新场景的 mock 数据才能进行测试

  • 不能根据测试场景来按照一定的规则动态生成 API 对应的 mock 数据

  • 不能多人同时使用测试同一个 API时, 只能都使用同一份 mock 数据, 不能各用各的

wiremock-py 可以解决上述这些问题: wiremock-py 通过传入不同的测试场景参数来生成不同的 mock 数据, 同时不同测试场景下使用的 mock 数据可以保存起来; 生成 mock 数据时, wiremock-py 支持使用Pythonjs代码来动态生成 mock 数据(也支持直接使用 json 数据, 如果 mock 数据中的数据量很大, 人工手写 mock 时的数据量会很大, 使用代码生成则比较容易); 不同的测试人员使用各自自己的 mock server, 不会影响到其他测试人员的测试。

640.webp (3).jpg

测试人员需要做的是: 确定哪些 API 需要进行 mock 以及不同测试场景下对应的 mock 规则是什么。

依赖环境

  • Java 1.8.0_144

  • Node v8.6.0

  • Python 3.4.3

演示

快速开始

贸数v1.1.0版本 测试环境为例演示使用 wiremock-py 对楼层客流分布店铺客流分布两张图分布在3种场景下的测试方法

先确定本地浏览器能过正常访问 http://mall-data.com:9012

准备

克隆代码

git clone http://git.dtwave-inc.com:30000/baomi.wbm/wiremock-py.git

安装依赖

cd wiremock-py

pip install -r requirements.txt

npm install mockjs

生成目录

python mock.py -g "demo"

➜  wiremock-py git:(master) ✗ python mock.py -g "demo"
DEBUG:root:mockdir=, scene=, target=, proxy_port=5506, generate=demo, wiremock=False, rewrite=False
DEBUG:root:正在生成目录 /Users/wangbaomi/autotest/wiremock-py/demo
DEBUG:root:创建目录成功: demo
DEBUG:root:创建目录成功: demo/js
DEBUG:root:创建目录成功: demo/json
DEBUG:root:创建目录成功: demo/python
DEBUG:root:创建目录成功: demo/wiremock
DEBUG:root:创建文件成功: demo/mappings.json
DEBUG:root:生成目录完成: /Users/wangbaomi/autotest/wiremock-py/demo

编写 mock 规则

填写 mappings.json、json、python、js 数据

mappings.json 中填写内容:

[
    {
        "response": {
            "default": {
                "proxyBaseUrl": "target"
            }
        },
        "mapping_name": "request url not start with /api",
        "request": {
            "method": "ANY",
            "urlPattern": "/(?!api).*"
        }
    }, 
    {
        "mapping_name": "楼层客流分布",
        "request": {
            "urlPattern": "/api/v1/mall_data/customer_flow/every_floor\\?(.*)",
            "method": "POST"
        },
        "response": {
            "default": {
                "proxyBaseUrl": "target"
            },
            "测试场景1": {
                "bodyFileName": {
                    "json": "楼层客流分布.json"
                }
            },
            "测试场景2": {
                "bodyFileName": {
                    "python": "楼层客流分布.py",
                    "python_args": "测试场景2"
                }
            },
            "测试场景3": {
                "bodyFileName": {
                    "js": "楼层客流分布.js"
                }
            }
        }
    },
    {
        "mapping_name": "店铺客流分布",
        "request": {
            "urlPattern": "/api/v1/mall_data/customer_flow/every_shop\\?(.*)",
            "method": "POST"
        },
        "response": {
            "default": {
                "proxyBaseUrl": "target"
            },
            "测试场景1": {
                "bodyFileName": {
                    "js": "店铺客流分布.js"
                }
            },
            "测试场景2": {
                "bodyFileName": {
                    "json": "店铺客流分布.json"
                }
            },
            "测试场景3": {
                "bodyFileName": {
                    "python": "店铺客流分布.py",
                    "python_args": "测试场景3"
                }
            }
        }
    }
]

js 文件夹中新建店铺客流分布.js文件, 内容为:

var r = {
  "success": true,
  "code": null,
  "message": null,
  "content": {
    "meta": {},
    "multi": {
      "group": [
        {
          "id": "rank",
          "name": "排名",
          "value": [
            1,
            2,
            3,
            4
          ]
        }
      ],
      "result": [
        {
          "id": "the_shop",
          "name": "店铺",
          "value": [
            "店铺1",
            "店铺2",
            "店铺3",
            "第4个店铺"
          ]
        },
        {
          "id": "customer_count",
          "name": "人数",
          "value": [
            10,
            100,
            1000,
            3242
          ]
        }
      ]
    },
    "single": []
  }
};

console.log(JSON.stringify(r));

js 文件夹中新建楼层客流分布.js文件, 内容为:

var r = {
  "success": true,
  "code": null,
  "message": null,
  "content": {
    "meta": {},
    "multi": {
      "group": [
        {
          "id": "the_floor",
          "name": "楼层",
          "value": [
            "-1楼",
            "1楼",
            "2楼",
            "3楼",
          ]
        }
      ],
      "result": [
        {
          "id": "customer_count",
          "name": "人数",
          "value": [
            100,
            1000,
            5000,
            567
          ]
        }
      ]
    },
    "single": []
  }
};

console.log(JSON.stringify(r));

json 文件夹中新建店铺客流分布.json, 内容为:

{
  "success": true,
  "code": null,
  "message": null,
  "content": {
    "meta": {},
    "multi": {
      "group": [
        {
          "id": "rank",
          "name": "排名",
          "value": [
            1,
            2,
            3
          ]
        }
      ],
      "result": [
        {
          "id": "the_shop",
          "name": "店铺",
          "value": [
            "店铺1",
            "店铺2",
            "店铺3"
          ]
        },
        {
          "id": "customer_count",
          "name": "人数",
          "value": [
            10,
            100,
            1000
          ]
        }
      ]
    },
    "single": []
  }
}

json 文件夹中新建楼层客流分布.json, 内容为:

{
  "success": true,
  "code": null,
  "message": null,
  "content": {
    "meta": {},
    "multi": {
      "group": [
        {
          "id": "the_floor",
          "name": "楼层",
          "value": [
            "1楼",
            "2楼",
            "3楼"
          ]
        }
      ],
      "result": [
        {
          "id": "customer_count",
          "name": "人数",
          "value": [
            100,
            1000,
            5000
          ]
        }
      ]
    },
    "single": []
  }
}

python 文件夹中新建店铺客流分布.py, 内容为:

#coding: utf-8

resp = {
  "success": True,
  "code": None,
  "message": None,
  "content": {
    "meta": {},
    "multi": {
      "group": [
        {
          "id": "rank",
          "name": "排名",
          "value": [

          ]
        }
      ],
      "result": [
        {
          "id": "the_shop",
          "name": "店铺",
          "value": [

          ]
        },
        {
          "id": "customer_count",
          "name": "人数",
          "value": [

          ]
        }
      ]
    },
    "single": []
  }
}

def main(scene):

    if scene == "测试场景1":
        resp["content"]["multi"]["group"][0]["value"] = [1,2,3,4,5]
        resp["content"]["multi"]["result"][0] = {
          "id": "the_shop",
          "name": "店铺",
          "value": [
            "店铺1", "店铺2", "店铺3", "店铺4", "店铺5"
          ]
        }
        resp["content"]["multi"]["result"][1] = {
          "id": "customer_count",
          "name": "人数",
          "value": [
            "100", "150", "800", "999", "1889"
          ]
        }
        return resp
    elif scene == "测试场景2" or scene == "测试场景3":
        resp["content"]["multi"]["group"][0]["value"] = [1,2,3,4,5,6,7,8,9]
        resp["content"]["multi"]["result"][0] = {
          "id": "the_shop",
          "name": "店铺",
          "value": [
            "店铺1", "店铺2", "店铺3", "店铺4", "店铺5", "店铺_6", "店铺⑦", "店铺Ⅷ"
          ]
        }
        resp["content"]["multi"]["result"][1] = {
          "id": "customer_count",
          "name": "人数",
          "value": [
            "100", "150", "800", "999", "1889", "4455", "788", "3458"
          ]
        }
        return resp
    else:
        return {"error": "No such scene: " + str(scene)}

python 文件夹中新建楼层客流分布.py, 内容为:

#coding: utf-8

resp = {
  "success": True,
  "code": None,
  "message": None,
  "content": {
    "meta": {},
    "multi": {
      "group": [
        {
          "id": "the_floor",
          "name": "楼层",
          "value": [
            "1楼",
            "2楼",
            "3楼"
          ]
        }
      ],
      "result": [
        {
          "id": "customer_count",
          "name": "人数",
          "value": [
            100,
            1000,
            5000
          ]
        }
      ]
    },
    "single": []
  }
}

def main(scene):

    if scene == "测试场景1":
        resp["content"]["multi"]["group"][0]["value"] = ["一楼", "二楼", "三楼", "四楼"]
        resp["content"]["multi"]["result"][0] = {
          "id": "the_shop",
          "name": "店铺",
          "value": [
            1,2,3,4,5
          ]
        }
        return resp
    elif scene == "测试场景2":
        resp["content"]["multi"]["group"][0]["value"] = ["一楼", "二楼", "三楼", "四楼", "5lou", "6lou"]
        resp["content"]["multi"]["result"][0] = {
          "id": "the_shop",
          "name": "店铺",
          "value": [
            1,2,3,4,5,100,500
          ]
        }
        return resp
    else:
        return {"error": "No such scene: " + str(scene)}

测试不同场景下图表的显示情况

测试场景1

python mock.py --mockdir "demo" --scene "测试场景1" --target "http://mall-data.com:9012" --proxy_port 5506

浏览器访问 http://localhost:5506 来看看楼层客流分布店铺客流分布这两张图中的数据

640.webp.jpg

测试场景2

python mock.py --mockdir "demo" --scene "测试场景2" --target "http://mall-data.com:9012" --proxy_port 5506

浏览器访问 http://localhost:5506 来看看楼层客流分布店铺客流分布这两张图中的数据的变化

640.webp (1).jpg

测试场景3

python mock.py --mockdir "demo" --scene "测试场景3" --target "http://mall-data.com:9012" --proxy_port 5506

浏览器访问 http://localhost:5506 来看看楼层客流分布店铺客流分布这两张图中的数据的变化

640.webp (2).jpg

功能介绍

目录结构

wiremock-py
    |
    |__example                              // 示例目录
    |
    |__Jar  
    |   |
    |   |__wiremock-standalone-2.8.0.jar    // wiremock 文件
    |
    |__mockjs
    |   |
    |   |__mock.js                          // 用于支持 json 数据中的使用 mock.js 规则
    |
    |__mock.py                              // 生成目录结构已经运行 mock server
    |
    |__readme.md
    |
    |__.gitignore
    |
    |__requirements.txt

功能

生成 mock 数据目录结构

python mock.py -g 目录名称

生成的目录结构是这样:

mockdir
    |__mappings.json              // 保存当前测试环境下所有request和response的映射关系, 即 mock 规则
    |__python                     // 保存python代码, python chart_x.py --场景1 这样的调用方式可以返回一个json字符串
    |  |_chart1.py                // 这个json字符串将作为response的body体保存在 wiremock/某个场景目录/__files 目录下
    |  |_chart2.py                
    |__js                         // 保存js代码, 作用类似于python代码, 最终生成json数据
    |  |_chart3.js                // 最终的json数据也是作为response的body体保存在 wiremock/某个场景目录/__files 目录下
    |  |_chart4.js
    |__json                       // 保存json数据, json数据支持mock.js规则, 
    |  |_chart5.json              // 最终的json数据也是作为response的body体保存在 wiremock/某个场景目录/__files 目录下
    |  |_chart6.json
    |__wiremock                   // 最终wiremock读取的目录
    |  |__scene1                  // 场景目录
    |  |  |__ mappings            // request和response的映射关系
    |  |  |  |_mapping1.json
    |  |  |  |_mapping2.json
    |  |  |__ __files             // response中的body体内容
    |  |  |  |_chart1.json
    |  |  |  |_chart2.json

按照 mock 规则生成数据并运行 mock server (如果规则已生成数据, 不覆盖)

python mock.py --mockdir "demo" --scene "测试场景1" --target "http://mall-data.com:9012" --proxy_port 5506

--mockdir 为目录名称

--scene 为测试场景名称, mock.py 根据 scene 参数, 在 mappings.json 中选择不同的场景来返回不同的 response 数据, 比如 scene 指定为 “场景2”时, 会去 mappings.json中找所有规则中 response 在 “场景2” 下的应该返回的数据. 当指定的scene不存在时, 会使用”default”场景

--target 为目标 url, 如果 API 在某个测试场景下不需要 mock 就会请求这个 target 地址

--proxy_port mock server 端口号

按照 mock 规则生成数据并运行 mock server (如果规则已生成数据, 覆盖)

python mock.py --mockdir "demo" --scene "测试场景1" --target "http://mall-data.com:9012" --proxy_port 5506 --rewrite

不需要生成数据, 只使用已有数据运行 mock server

python mock.py -w 目录名称

原理

mappings.json 中描述了每个 API 在每个 测试场景 下生成 mock 数据的 规则, 规则可以描述为 json 形式, 或者 python 和 js 代码的形式

640.webp (4).jpg

文件内容格式为:

[
    {
        "mapping_name": "非图表请求",
        "request": {
            "urlPattern":"/(?!api).*",
            "method": "ANY"
        },
        "response": {
            "default": {
                "proxyBaseUrl": "target"
            },
            "场景2": {
                "status": 200
            }
        } 
    }
]

mapping_name

mapping_name为映射关系名称, 只是个名称而已

request

request 为要拦截的request请求格式, 当匹配到特定匹配条件的request后, 返回指定的 response.

匹配 request 的 url: 使用urlPattern参数, 支持正则表达式

匹配 request 方法: 使用method参数, 比如 GET, POST, PUT, DELETE, HEAD, TRACE, OPTIONS, 要匹配所有的请求方法可使用ANY

匹配 request 中的 JSON 数据, 使用equalToJson来完全匹配 json 数据:

"bodyPatterns" : [ {
  "equalToJson" : { "total_results": 4 }
} ]

使用matchesJsonPath来匹配 json 路径:

"bodyPatterns" : [ {
  "matchesJsonPath" : "$.name"
} ]
"bodyPatterns" : [ {
  "matchesJsonPath" : "$.things[?(@.name == 'RequiredThing')]"
} ]
"bodyPatterns" : [ {
  "matchesJsonPath" : "$.things[?(@.name =~ /Required.*/i)]"
} ]
"bodyPatterns" : [ {
  "matchesJsonPath" : "$[?(@.things.size() == 2)]"
} ]
"bodyPatterns" : [ {
  "matchesJsonPath" : {
     "expression": "$.outer",
     "equalToJson": "{ \"inner\": 42 }"
  }
} ]

request匹配格式可以参考wiremock的Request Matching部分

注意细节

如果 mappings.json 里所有的mapping规则都无法匹配某个request, 这个request对应的response将会是404, 而不是走target的真实请求.

比如 一个 request 为 http://localhost:5506/abcde, target 为 https://mall-data.com, 当 mappings.json 里能够匹配到这个 request 时, 就返回匹配规则中的 response (可以是返回某个特定的信息, 也可能是返回 target 真实的返回数据, 视 response 信息而定), 但如果 mappings.json 中没有匹配到这个 request, 那这个 request 的 response 值就只能是404.

response

response为要返回的response数据, response里需要指定 测试场景 的名称, 比如 默认场景 下的response是啥, 场景2 下的response是啥.

response 的格式, 可以参考wiremock的stubbing文档 和 simulating-faults文档

response 中支持使用json文件

如果想直接返回一个json文件里的json数据, 可以指定 "json" 为目标文件(目标json文件必须放在 json 目录中)

    {
        "mapping_name": "客流变化趋势",
        "request": {
            "urlPattern": "/api/v1/mall_data/overview/customer_day\\?(.*)",
            "method": "POST"
        },
        "response": {
            "默认场景": {
                "bodyFileName": {
                    "json": "客流变化趋势.json"
                } 
            }
        }
    }

json文件内容可参考:

{
    "success": true,
    "code": null,
    "message": null,
    "content": {
        "meta": {},
        "multi": {},
        "tab": {
            "currentValue": 99999,
            "chainValue": -0.22916559652349008
        }
    }
}

json数据会自动转换成按照mock.js规则转换后的json数据

比如json数据:

"result": [
    {
        "value": [
            "@integer(1,10000)",
            "@integer(1,10000)"
        ],
        "id": "customer_count",
        "name": "\u4eba\u6570"
    }
]

会按照mock.js规则转换成:

"result": [
    {
        "value": [
            7798,
            1768
        ],
        "name": "\u4eba\u6570",
        "id": "customer_count"
    }
]
response 中支持使用python代码生成数据

如果想使用python代码根据不同场景生成不同的数据作为 response 的话, 可以指定 "python"为指定的 目标python文件(目标python文件必须放在 python 目录中), "python_args" 为运行指定 python 代码时传的参数

    {
        "mapping_name": "老客数",
        "request": {
            "urlPattern": "/api/v1/mall_data/overview/customer_old\\?(.*)",
            "method": "POST"
        },
        "response": {
            "默认场景": {
                "status": 200,
                "bodyFileName": {
                    "python": "老客数.py",
                    "python_args": "默认场景"
                }
            }
        }
    }

python代码中, 必须包含一个 main(scene) 函数, 内容可参考:

def main(scene):
    return {"kk": scene}
response 中支持使用js代码生成数据

如果想使用js代码根据不同场景生成不同的数据作为 response 的话, 可以指定 "js" 为指定的 目标js文件(目标js文件必须放在 js 目录中), "js_args" 为运行指定 js 代码时传的参数

    {
        "mapping_name": "老客数",
        "request": {
            "urlPattern": "/api/v1/mall_data/overview/customer_old\\?(.*)",
            "method": "POST"
        },
        "response": {
            "默认场景": {
                "status": 200,
                "bodyFileName": {
                    "js": "老客数.js",
                    "js_args": "默认场景"
                }
            }
        }
    }

python 代码中会使用Naked运行 js 脚本, 运行方式就是 muterun_js("js_file args"), 然后从 js 脚本的标准输出来拿到返回数据, 所以js文件需要能够从命令行中读取参数, 并且能过将返回结果打印到标准输出中, 可参考:

if (process.argv[2] == "测试场景1"){
    console.log({"k1":"v1"})
}
response 中指定返回状态码

使用status参数返回指定的状态码, 如果不指定, 则返回200

response 中指定返回头部内容

使用headers参数返回指定的头部内容, 如果不指定, 代码会默认指定一个头部内容

response 中返回 base64 数据

使用base64Body参数返回指定的数据

wiremock

wiremock 的高级功能可参考 wiremock文档

mock.py 按照 mappings.json 中的规则, 会在 wiremock 目录中生成 __files 和 mappings两个目录, 然后拉起 wiremock, wiremock 会按照这两个目录中的数据来做 mock server.

640.webp (3).jpg


参考资料

wiremock: http://wiremock.org/

640.webp (5).jpg

作者简介:咪咪,5年+软件测试经验,参与过深信服VDI产品的自动化测试和测试开发,以及华为公有云的自动化测试。