用例组成结构
httprunner 最小的执行单元是 测试步骤(teststep)一个或多个测试步骤组成一个测试用例完整的测试用例,每个测试用例应该确保是可单独运行的。
在一个测试用例中除了包含 测试步骤(teststep)还有一个 配置(config)重点是存放:环境变量,测试的数据,设置base_url,请求中公共的配置...
在测试步骤中还可以通过引入 API 步骤类型和其他 TestCase 中定义的测试步骤进行完善补充用例的步骤
用例的编写格式
测试步骤(teststep)
编写格式
name: 步骤名称
request: # 请求配置
method: POST # 请求方法
url: /service/mock # 请求url
http2: false # http2协议
params: # 请求参数(拼接到url后)
user_name: $user_name # 通过$引用变量
password: $password
headers:
Content-Type: application/json # 请求头
cookies:
Token: xxx # 请求cookie
body: {"":""} # 请求体(最高优先级)
json: {"":""} #(第二优先级)使用时请求头自动设置Content-Type
data: {"":""} # 没有配置body和json时(第三优先级)
timeout: 60 # 超时时间(单位:秒)
allow_redirects: true # 允许重定向
verify: false # 客户端是否进行SSL校验(todo)
upload: # 上传文件
fileName: x/xx/xx
api: xx/xxx/xxx.yaml # 引入api类型步骤
testcase: xx/xxx/xxx.yaml # 引入其他testcase步骤
variables: # 局部变量设置
user_name: zhangsan
password: xxxxxxxx
setup_hooks: # 前置执行函数
-
"${SetupHook()}" # 通过${}引用函数
teardown_hooks: # 后置执行函数
-
"${TearDownHook()}"
extract: # 参数提取
task_id: body.task_id
validate: # 响应断言
- eq: ["status_code":200] # 响应码断言
export: # 变量导出(变量做为全局变量使用)
- task_id
对应结构体
type TStep struct {
Name string `json:"name" yaml:"name"` // required
Request *Request `json:"request,omitempty" yaml:"request,omitempty"`
API interface{} `json:"api,omitempty" yaml:"api,omitempty"` // *APIPath or *API
TestCase interface{} `json:"testcase,omitempty" yaml:"testcase,omitempty"` // *TestCasePath or *TestCase
Transaction *Transaction `json:"transaction,omitempty" yaml:"transaction,omitempty"`
Rendezvous *Rendezvous `json:"rendezvous,omitempty" yaml:"rendezvous,omitempty"`
ThinkTime *ThinkTime `json:"think_time,omitempty" yaml:"think_time,omitempty"`
WebSocket *WebSocketAction `json:"websocket,omitempty" yaml:"websocket,omitempty"`
Android *MobileStep `json:"android,omitempty" yaml:"android,omitempty"`
IOS *MobileStep `json:"ios,omitempty" yaml:"ios,omitempty"`
Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"`
SetupHooks []string `json:"setup_hooks,omitempty" yaml:"setup_hooks,omitempty"`
TeardownHooks []string `json:"teardown_hooks,omitempty" yaml:"teardown_hooks,omitempty"`
Extract map[string]string `json:"extract,omitempty" yaml:"extract,omitempty"`
Validators []interface{} `json:"validate,omitempty" yaml:"validate,omitempty"`
Export []string `json:"export,omitempty" yaml:"export,omitempty"`
}
type Request struct {
Method HTTPMethod `json:"method" yaml:"method"` // required
URL string `json:"url" yaml:"url"` // required
HTTP2 bool `json:"http2,omitempty" yaml:"http2,omitempty"`
Params map[string]interface{} `json:"params,omitempty" yaml:"params,omitempty"`
Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"`
Cookies map[string]string `json:"cookies,omitempty" yaml:"cookies,omitempty"`
Body interface{} `json:"body,omitempty" yaml:"body,omitempty"`
Json interface{} `json:"json,omitempty" yaml:"json,omitempty"`
Data interface{} `json:"data,omitempty" yaml:"data,omitempty"`
Timeout float64 `json:"timeout,omitempty" yaml:"timeout,omitempty"` // timeout in seconds
AllowRedirects bool `json:"allow_redirects,omitempty" yaml:"allow_redirects,omitempty"`
Verify bool `json:"verify,omitempty" yaml:"verify,omitempty"`
Upload map[string]interface{} `json:"upload,omitempty" yaml:"upload,omitempty"`
}
请求参数: body data json
func convertCompatRequestBody(request *Request) {
// 未设置body字段时,进入下面逻辑处理
if request != nil && request.Body == nil {
// 首先判断是否设置json字段,如果json字段有值则自动填充Content-Type
// 同时将json字段赋给body,json 字段置空
if request.Json != nil {
if request.Headers == nil {
request.Headers = make(map[string]string)
}
request.Headers["Content-Type"] = "application/json; charset=utf-8"
request.Body = request.Json
request.Json = nil
// json 字段也没有时,校验data字段,data字段有值则赋给json,然后置空
} else if request.Data != nil {
request.Body = request.Data
request.Data = nil
}
}
}
配置(config)
编写格式
config:
name: 配置名称
verify: false # 客户端是否进行SSL校验
base_url: http://www.baidu.com # 当前请求的基础 URL
headers:
Content-Type: application/json # 请求头
environs: # 环境变量(使用优先级:env文件 > 此处配置)
variables: # 变量设置
user_name: zhangsan
password: xxxxxxxx
parameters: # 参数化
user_agent: [ "iOS/10.1", "iOS/10.2" ]
user_name-password:
- {"user_name": "test1", "password": "111111"}
- {"user_name": "test2", "password": "222222"}
parameters_setting: # 参数化策略
strategies:
user_agent:
name: "user-identity"
pick_order: "sequential"
username-password:
name: "user-info"
pick_order: "random"
limit: 6
timeout: 60 # 超时时间(秒)
export: # 变量导出(变量做为全局变量使用)
- task_id
对应结构体
type TConfig struct {
Name string `json:"name" yaml:"name"` // required
Verify bool `json:"verify,omitempty" yaml:"verify,omitempty"`
BaseURL string `json:"base_url,omitempty" yaml:"base_url,omitempty"` // deprecated in v4.1, moved to env
Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"` // public request headers
Environs map[string]string `json:"environs,omitempty" yaml:"environs,omitempty"` // environment variables
Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` // global variables
Parameters map[string]interface{} `json:"parameters,omitempty" yaml:"parameters,omitempty"`
ParametersSetting *TParamsConfig `json:"parameters_setting,omitempty" yaml:"parameters_setting,omitempty"`
ThinkTimeSetting *ThinkTimeConfig `json:"think_time,omitempty" yaml:"think_time,omitempty"`
WebSocketSetting *WebSocketConfig `json:"websocket,omitempty" yaml:"websocket,omitempty"`
IOS []*uixt.IOSDevice `json:"ios,omitempty" yaml:"ios,omitempty"`
Android []*uixt.AndroidDevice `json:"android,omitempty" yaml:"android,omitempty"`
Timeout float64 `json:"timeout,omitempty" yaml:"timeout,omitempty"` // global timeout in seconds
Export []string `json:"export,omitempty" yaml:"export,omitempty"`
Weight int `json:"weight,omitempty" yaml:"weight,omitempty"`
Path string `json:"path,omitempty" yaml:"path,omitempty"` // testcase file path
PluginSetting *PluginConfig `json:"plugin,omitempty" yaml:"plugin,omitempty"` // plugin config
}
测试用例
编写格式
config: # 编写配置
name: 配置名称
···
teststeps: # 编写测试步骤
-
name: 测试步骤1
···
-
name: 测试步骤2
···
对应结构体
type TestCase struct {
Config *TConfig
TestSteps []IStep
}
拓展:用例是如何加载的
实际在执行时是通过命令行执行要执行的用例文件路径执行,文件路径又是如何转换成测试用例的呢?
LoadTestCases 方法过滤接口类型,遍历目录树,获取所有待执行用例,在调用转换方法,转为TestCase
// 参数iTestCases是一个接口,其实现类只有两个:
// 一个是可执行的TestCase,另外一个就是表示文件路径的TestCasePath
func LoadTestCases(iTestCases ...ITestCase) ([]*TestCase, error) {
testCases := make([]*TestCase, 0)
for _, iTestCase := range iTestCases {
// 如果iTestCase就是一个TestCase类型,则不需要特殊处理,转换后直接放入结果数组
if _, ok := iTestCase.(*TestCase); ok {
testcase, err := iTestCase.ToTestCase()
if err != nil {
log.Error().Err(err).Msg("failed to convert ITestCase interface to TestCase struct")
return nil, err
}
testCases = append(testCases, testcase)
continue
}
// iTestCase should be a TestCasePath, file path or folder path
// 如果不是TestCase则iTestCase应该是 一个文件或文件夹路径,否则抛出异常
tcPath, ok := iTestCase.(*TestCasePath)
if !ok {
return nil, errors.New("invalid iTestCase type")
}
casePath := tcPath.GetPath()
// 获取用例的文件目录树,进行:遍历、过滤、转换 得到用例
err := fs.WalkDir(os.DirFS(casePath), ".", func(path string, dir fs.DirEntry, e error) error {
if dir == nil {
// casePath is a file other than a dir
path = casePath
} else if dir.IsDir() && path != "." && strings.HasPrefix(path, ".") {
// skip hidden folders
return fs.SkipDir
} else {
// casePath is a dir
path = filepath.Join(casePath, path)
}
// 判断用例文件后缀,目前只支持:yml yaml json
ext := filepath.Ext(path)
if ext != ".yml" && ext != ".yaml" && ext != ".json" {
return nil
}
// filtered testcases
testCasePath := TestCasePath(path)
// 【重点】将文件路径转换为TestCase
tc, err := testCasePath.ToTestCase()
if err != nil {
return nil
}
testCases = append(testCases, tc)
return nil
})
if err != nil {
return nil, errors.Wrap(err, "read dir failed")
}
}
log.Info().Int("count", len(testCases)).Msg("load testcases successfully")
return testCases, nil
}
实际将用例文件转为TestCase
调用栈
testCasePath.ToTestCase 读取文件内容映射hrp.TCase{}
TCase.ToTestCase 校验config和teststeps 不为空
TCase.toTestCase 实际处理方法最后返回:TestCase
func (tc *TCase) toTestCase() (*TestCase, error) {
// ··· 省略代码
// 遍历,处理没一个测试步骤
for _, step := range tc.TestSteps {
// 步骤里包含API类型步骤
if step.API != nil {
apiPath, ok := step.API.(string)
// api 字段指定的是文件路径
if ok {
path := filepath.Join(projectRootDir, apiPath)
if !builtin.IsFilePathExists(path) {
return nil, errors.Wrap(code.ReferencedFileNotFound,
fmt.Sprintf("referenced api file not found: %s", path))
}
// 将引入的api类型文件路径转换为API类型
refAPI := APIPath(path)
apiContent, err := refAPI.ToAPI()
if err != nil {
return nil, err
}
step.API = apiContent
} else {
// api 字段是一个完整的配置(map)
apiMap, ok := step.API.(map[string]interface{})
if !ok {
return nil, errors.Wrap(code.InvalidCaseFormat,
fmt.Sprintf("referenced api should be map or path(string), got %v", step.API))
}
api := &API{}
// 将map转换为api
err = mapstructure.Decode(apiMap, api)
if err != nil {
return nil, err
}
step.API = api
}
// 校验api字段是否为API类型,不是则抛出异常
_, ok = step.API.(*API)
if !ok {
return nil, errors.Wrap(code.InvalidCaseFormat,
fmt.Sprintf("failed to handle referenced API, got %v", step.TestCase))
}
// 将处理后的测试步骤加入测试用例中
testCase.TestSteps = append(testCase.TestSteps, &StepAPIWithOptionalArgs{
step: step,
})
// 步骤里包含了引用其他用例的步骤
} else if step.TestCase != nil {
casePath, ok := step.TestCase.(string)
// 通过文件路径引入其他用例的步骤
if ok {
path := filepath.Join(projectRootDir, casePath)
if !builtin.IsFilePathExists(path) {
return nil, errors.Wrap(code.ReferencedFileNotFound,
fmt.Sprintf("referenced testcase file not found: %s", path))
}
// 继续调用ToTestCase方法转TestCase
refTestCase := TestCasePath(path)
tc, err := refTestCase.ToTestCase()
if err != nil {
return nil, err
}
step.TestCase = tc
} else {
// testcase字段不是引入其他文件,而是一个步骤配置(map)
testCaseMap, ok := step.TestCase.(map[string]interface{})
if !ok {
return nil, errors.Wrap(code.InvalidCaseFormat,
fmt.Sprintf("referenced testcase should be map or path(string), got %v", step.TestCase))
}
tCase := &TCase{}
// 将map转为tCase类型
err = mapstructure.Decode(testCaseMap, tCase)
if err != nil {
return nil, err
}
// 递归调用转为TestCase
tc, err := tCase.toTestCase()
if err != nil {
return nil, err
}
step.TestCase = tc
}
// 校验testcase字段是否为TestCase类型,不是则抛出异常
_, ok = step.TestCase.(*TestCase)
if !ok {
return nil, errors.Wrap(code.InvalidCaseFormat,
fmt.Sprintf("failed to handle referenced testcase, got %v", step.TestCase))
}
// 将测试步骤加入到测试用例
testCase.TestSteps = append(testCase.TestSteps, &StepTestCaseWithOptionalArgs{
step: step,
})
}
// ··· 省略代码
else {
log.Warn().Interface("step", step).Msg("[convertTestCase] unexpected step")
}
}
return testCase, nil
}