From af6c433069cdc85f72c31417078d27edb2bf9fc4 Mon Sep 17 00:00:00 2001 From: xucong053 Date: Tue, 15 Mar 2022 12:39:41 +0800 Subject: [PATCH] feat: support api layer and global headers for testcase --- README.md | 2 +- boomer.go | 2 + boomer_test.go | 2 +- cli/hrp/cmd/boom.go | 9 +- cli/hrp/cmd/run.go | 3 +- convert.go | 180 ++++++++++++++++++++------------ convert_test.go | 13 ++- docs/cmd/hrp.md | 2 +- docs/cmd/hrp_boom.md | 4 +- docs/cmd/hrp_har2case.md | 2 +- docs/cmd/hrp_run.md | 2 +- docs/cmd/hrp_startproject.md | 2 +- examples/api/get.json | 34 ++++++ examples/api/get.yml | 22 ++++ examples/api/post.json | 45 ++++++++ examples/api/post.yml | 30 ++++++ examples/api/put.json | 45 ++++++++ examples/api/put.yml | 30 ++++++ examples/compat_test.go | 12 +-- examples/postman-echo.json | 8 +- examples/postman-echo.yaml | 8 +- examples/ref_api_test.json | 78 ++++++++++++++ examples/ref_api_test.yaml | 47 +++++++++ examples/ref_testcase_test.json | 18 ++++ examples/ref_testcase_test.yaml | 11 ++ internal/boomer/boomer.go | 21 ++++ internal/boomer/output.go | 4 + internal/builtin/function.go | 9 ++ internal/json/json.go | 1 + internal/scaffold/demo_test.go | 14 ++- models.go | 47 +++++++-- parser.go | 93 +++++++++++++++++ parser_test.go | 106 +++++++++++++++++++ runner.go | 48 +++++++-- runner_test.go | 13 ++- step.go | 55 +++++++++- step_test.go | 14 +-- 37 files changed, 904 insertions(+), 132 deletions(-) create mode 100644 examples/api/get.json create mode 100644 examples/api/get.yml create mode 100644 examples/api/post.json create mode 100644 examples/api/post.yml create mode 100644 examples/api/put.json create mode 100644 examples/api/put.yml create mode 100644 examples/ref_api_test.json create mode 100644 examples/ref_api_test.yaml create mode 100644 examples/ref_testcase_test.json create mode 100644 examples/ref_testcase_test.yaml diff --git a/README.md b/README.md index 749da83..f27e5d1 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ func TestCaseDemo(t *testing.T) { }). GET("/get"). WithParams(map[string]interface{}{"foo1": "$varFoo1", "foo2": "$varFoo2"}). // request with params - WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus"}). // request with headers + WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus"}). // request with headers Extract(). WithJmesPath("body.args.foo1", "varFoo1"). // extract variable with jmespath Validate(). diff --git a/boomer.go b/boomer.go index 1f4ff73..114c14c 100644 --- a/boomer.go +++ b/boomer.go @@ -68,6 +68,8 @@ func (b *HRPBoomer) Quit() { func (b *HRPBoomer) convertBoomerTask(testcase *TestCase, rendezvousList []*Rendezvous) *boomer.Task { hrpRunner := NewRunner(nil) + // set client transport for high concurrency load testing + hrpRunner.SetClientTransport(b.GetSpawnCount(), b.GetDisableKeepAlive(), b.GetDisableCompression()) config := testcase.Config // each testcase has its own plugin process diff --git a/boomer_test.go b/boomer_test.go index ab61e62..79ffd2f 100644 --- a/boomer_test.go +++ b/boomer_test.go @@ -25,7 +25,7 @@ func TestBoomerStandaloneRun(t *testing.T) { NewStep("TestCase3").CallRefCase(&TestCase{Config: NewConfig("TestCase3")}), }, } - testcase2 := &TestCasePath{demoTestCaseJSONPath} + testcase2 := &demoTestCaseJSONPath b := NewBoomer(2, 1) go b.Run(testcase1, testcase2) diff --git a/cli/hrp/cmd/boom.go b/cli/hrp/cmd/boom.go index e6bc89d..1dc5744 100644 --- a/cli/hrp/cmd/boom.go +++ b/cli/hrp/cmd/boom.go @@ -25,7 +25,8 @@ var boomCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { var paths []hrp.ITestCase for _, arg := range args { - paths = append(paths, &hrp.TestCasePath{Path: arg}) + path := hrp.TestCasePath(arg) + paths = append(paths, &path) } hrpBoomer := hrp.NewBoomer(spawnCount, spawnRate) hrpBoomer.SetRateLimiter(maxRPS, requestIncreaseRate) @@ -38,6 +39,8 @@ var boomCmd = &cobra.Command{ if prometheusPushgatewayURL != "" { hrpBoomer.AddOutput(boomer.NewPrometheusPusherOutput(prometheusPushgatewayURL, "hrp")) } + hrpBoomer.SetDisableKeepAlive(disableKeepalive) + hrpBoomer.SetDisableCompression(disableCompression) hrpBoomer.EnableCPUProfile(cpuProfile, cpuProfileDuration) hrpBoomer.EnableMemoryProfile(memoryProfile, memoryProfileDuration) hrpBoomer.Run(paths...) @@ -56,6 +59,8 @@ var ( cpuProfileDuration time.Duration prometheusPushgatewayURL string disableConsoleOutput bool + disableCompression bool + disableKeepalive bool ) func init() { @@ -72,4 +77,6 @@ func init() { boomCmd.Flags().DurationVar(&cpuProfileDuration, "cpu-profile-duration", 30*time.Second, "CPU profile duration.") boomCmd.Flags().StringVar(&prometheusPushgatewayURL, "prometheus-gateway", "", "Prometheus Pushgateway url.") boomCmd.Flags().BoolVar(&disableConsoleOutput, "disable-console-output", false, "Disable console output.") + boomCmd.Flags().BoolVar(&disableCompression, "disable-compression", false, "Disable compression") + boomCmd.Flags().BoolVar(&disableKeepalive, "disable-keepalive", false, "Disable keepalive") } diff --git a/cli/hrp/cmd/run.go b/cli/hrp/cmd/run.go index 5a96767..d4bd6df 100644 --- a/cli/hrp/cmd/run.go +++ b/cli/hrp/cmd/run.go @@ -23,7 +23,8 @@ var runCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { var paths []hrp.ITestCase for _, arg := range args { - paths = append(paths, &hrp.TestCasePath{Path: arg}) + path := hrp.TestCasePath(arg) + paths = append(paths, &path) } runner := hrp.NewRunner(nil). SetFailfast(!continueOnFailure). diff --git a/convert.go b/convert.go index d9876bc..d29b421 100644 --- a/convert.go +++ b/convert.go @@ -13,52 +13,80 @@ import ( "github.com/httprunner/hrp/internal/json" ) -func loadFromJSON(path string) (*TCase, error) { +func loadFromJSON(path string, structObj interface{}) error { path, err := filepath.Abs(path) if err != nil { log.Error().Str("path", path).Err(err).Msg("convert absolute path failed") - return nil, err + return err } - log.Info().Str("path", path).Msg("load json testcase") + log.Info().Str("path", path).Msg("load json") file, err := os.ReadFile(path) if err != nil { log.Error().Err(err).Msg("load json path failed") - return nil, err + return err } - tc := &TCase{} decoder := json.NewDecoder(bytes.NewReader(file)) decoder.UseNumber() - err = decoder.Decode(tc) - if err != nil { - return tc, err - } - err = convertCompatTestCase(tc) - return tc, err + err = decoder.Decode(structObj) + return err } -func loadFromYAML(path string) (*TCase, error) { +func loadFromYAML(path string, structObj interface{}) error { path, err := filepath.Abs(path) if err != nil { log.Error().Str("path", path).Err(err).Msg("convert absolute path failed") - return nil, err + return err } - log.Info().Str("path", path).Msg("load yaml testcase") + log.Info().Str("path", path).Msg("load yaml") file, err := os.ReadFile(path) if err != nil { log.Error().Err(err).Msg("load yaml path failed") - return nil, err + return err } - tc := &TCase{} - err = yaml.Unmarshal(file, tc) - if err != nil { - return tc, nil + err = yaml.Unmarshal(file, structObj) + return err +} + +func convertCompatValidator(Validators []interface{}) (err error) { + for i, iValidator := range Validators { + validatorMap := iValidator.(map[string]interface{}) + validator := Validator{} + _, checkExisted := validatorMap["check"] + _, assertExisted := validatorMap["assert"] + _, expectExisted := validatorMap["expect"] + // check priority: HRP > HttpRunner + if checkExisted && assertExisted && expectExisted { + // HRP validator format + validator.Check = validatorMap["check"].(string) + validator.Assert = validatorMap["assert"].(string) + validator.Expect = validatorMap["expect"] + if msg, existed := validatorMap["msg"]; existed { + validator.Message = msg.(string) + } + validator.Check = convertCheckExpr(validator.Check) + Validators[i] = validator + } else if len(validatorMap) == 1 { + // HttpRunner validator format + for assertMethod, iValidatorContent := range validatorMap { + checkAndExpect := iValidatorContent.([]interface{}) + if len(checkAndExpect) != 2 { + return fmt.Errorf("unexpected validator format: %v", validatorMap) + } + validator.Check = checkAndExpect[0].(string) + validator.Assert = assertMethod + validator.Expect = checkAndExpect[1] + } + validator.Check = convertCheckExpr(validator.Check) + Validators[i] = validator + } else { + return fmt.Errorf("unexpected validator format: %v", validatorMap) + } } - err = convertCompatTestCase(tc) - return tc, err + return nil } func convertCompatTestCase(tc *TCase) (err error) { @@ -79,42 +107,12 @@ func convertCompatTestCase(tc *TCase) (err error) { } // 2. deal with validators compatible with HttpRunner - for i, iValidator := range step.Validators { - validatorMap := iValidator.(map[string]interface{}) - validator := Validator{} - _, checkExisted := validatorMap["check"] - _, assertExisted := validatorMap["assert"] - _, expectExisted := validatorMap["expect"] - // check priority: HRP > HttpRunner - if checkExisted && assertExisted && expectExisted { - // HRP validator format - validator.Check = validatorMap["check"].(string) - validator.Assert = validatorMap["assert"].(string) - validator.Expect = validatorMap["expect"] - if msg, existed := validatorMap["msg"]; existed { - validator.Message = msg.(string) - } - validator.Check = convertCheckExpr(validator.Check) - step.Validators[i] = validator - } else if len(validatorMap) == 1 { - // HttpRunner validator format - for assertMethod, iValidatorContent := range validatorMap { - checkAndExpect := iValidatorContent.([]interface{}) - if len(checkAndExpect) != 2 { - return fmt.Errorf("unexpected validator format: %v", validatorMap) - } - validator.Check = checkAndExpect[0].(string) - validator.Assert = assertMethod - validator.Expect = checkAndExpect[1] - } - validator.Check = convertCheckExpr(validator.Check) - step.Validators[i] = validator - } else { - return fmt.Errorf("unexpected validator format: %v", validatorMap) - } + err = convertCompatValidator(step.Validators) + if err != nil { + return err } } - return err + return nil } // convertCheckExpr deals with check expression including hyphen @@ -136,14 +134,32 @@ func (tc *TCase) ToTestCase() (*TestCase, error) { Config: tc.Config, } for _, step := range tc.TestSteps { - if step.Request != nil { - testCase.TestSteps = append(testCase.TestSteps, &StepRequestWithOptionalArgs{ + if step.APIPath != "" { + refAPI := APIPath(step.APIPath) + step.APIContent = &refAPI + apiContent, err := step.APIContent.ToAPI() + if err != nil { + return nil, err + } + step.APIContent = apiContent + testCase.TestSteps = append(testCase.TestSteps, &StepAPIWithOptionalArgs{ step: step, }) - } else if step.TestCase != nil { + } else if step.TestCasePath != "" { + refTestCase := TestCasePath(step.TestCasePath) + step.TestCaseContent = &refTestCase + tc, err := step.TestCaseContent.ToTestCase() + if err != nil { + return nil, err + } + step.TestCaseContent = tc testCase.TestSteps = append(testCase.TestSteps, &StepTestCaseWithOptionalArgs{ step: step, }) + } else if step.Request != nil { + testCase.TestSteps = append(testCase.TestSteps, &StepRequestWithOptionalArgs{ + step: step, + }) } else if step.Transaction != nil { testCase.TestSteps = append(testCase.TestSteps, &StepTransaction{ step: step, @@ -161,29 +177,63 @@ func (tc *TCase) ToTestCase() (*TestCase, error) { var ErrUnsupportedFileExt = fmt.Errorf("unsupported testcase file extension") +// APIPath implements IAPI interface. +type APIPath string + +func (path *APIPath) ToString() string { + return fmt.Sprintf("%v", *path) +} + +func (path *APIPath) ToAPI() (*API, error) { + api := &API{} + var err error + + apiPath := path.ToString() + ext := filepath.Ext(apiPath) + switch ext { + case ".json": + err = loadFromJSON(apiPath, api) + case ".yaml", ".yml": + err = loadFromYAML(apiPath, api) + default: + err = ErrUnsupportedFileExt + } + if err != nil { + return nil, err + } + err = convertCompatValidator(api.Validators) + return api, err +} + // TestCasePath implements ITestCase interface. -type TestCasePath struct { - Path string +type TestCasePath string + +func (path *TestCasePath) ToString() string { + return fmt.Sprintf("%v", *path) } func (path *TestCasePath) ToTestCase() (*TestCase, error) { - var tc *TCase + tc := &TCase{} var err error - casePath := path.Path + casePath := path.ToString() ext := filepath.Ext(casePath) switch ext { case ".json": - tc, err = loadFromJSON(casePath) + err = loadFromJSON(casePath, tc) case ".yaml", ".yml": - tc, err = loadFromYAML(casePath) + err = loadFromYAML(casePath, tc) default: err = ErrUnsupportedFileExt } if err != nil { return nil, err } - tc.Config.Path = path.Path + err = convertCompatTestCase(tc) + if err != nil { + return nil, err + } + tc.Config.Path = path.ToString() testcase, err := tc.ToTestCase() if err != nil { return nil, err diff --git a/convert_test.go b/convert_test.go index 75aea86..ea91964 100644 --- a/convert_test.go +++ b/convert_test.go @@ -7,16 +7,21 @@ import ( ) var ( - demoTestCaseJSONPath = "examples/demo.json" - demoTestCaseYAMLPath = "examples/demo.yaml" + demoTestCaseJSONPath TestCasePath = "examples/demo.json" + demoTestCaseYAMLPath TestCasePath = "examples/demo.yaml" + demoRefAPIYAMLPath TestCasePath = "examples/ref_api_test.yaml" + demoRefTestCaseJSONPath TestCasePath = "examples/ref_testcase_test.json" + demoAPIYAMLPath APIPath = "examples/api/put.yml" ) func TestLoadCase(t *testing.T) { - tcJSON, err := loadFromJSON(demoTestCaseJSONPath) + tcJSON := &TCase{} + tcYAML := &TCase{} + err := loadFromJSON(demoTestCaseJSONPath.ToString(), tcJSON) if !assert.NoError(t, err) { t.Fail() } - tcYAML, err := loadFromYAML(demoTestCaseYAMLPath) + err = loadFromYAML(demoTestCaseYAMLPath.ToString(), tcYAML) if !assert.NoError(t, err) { t.Fail() } diff --git a/docs/cmd/hrp.md b/docs/cmd/hrp.md index 7bb91a4..632789a 100644 --- a/docs/cmd/hrp.md +++ b/docs/cmd/hrp.md @@ -33,4 +33,4 @@ Copyright 2021 debugtalk * [hrp run](hrp_run.md) - run API test * [hrp startproject](hrp_startproject.md) - create a scaffold project -###### Auto generated by spf13/cobra on 10-Mar-2022 +###### Auto generated by spf13/cobra on 15-Mar-2022 diff --git a/docs/cmd/hrp_boom.md b/docs/cmd/hrp_boom.md index 5ab7437..577deac 100644 --- a/docs/cmd/hrp_boom.md +++ b/docs/cmd/hrp_boom.md @@ -23,7 +23,9 @@ hrp boom [flags] ``` --cpu-profile string Enable CPU profiling. --cpu-profile-duration duration CPU profile duration. (default 30s) + --disable-compression Disable compression --disable-console-output Disable console output. + --disable-keepalive Disable keepalive -h, --help help for boom --loop-count int The specify running cycles for load testing (default -1) --max-rps int Max RPS that boomer can generate, disabled by default. @@ -39,4 +41,4 @@ hrp boom [flags] * [hrp](hrp.md) - One-stop solution for HTTP(S) testing. -###### Auto generated by spf13/cobra on 10-Mar-2022 +###### Auto generated by spf13/cobra on 15-Mar-2022 diff --git a/docs/cmd/hrp_har2case.md b/docs/cmd/hrp_har2case.md index def322d..05d36e4 100644 --- a/docs/cmd/hrp_har2case.md +++ b/docs/cmd/hrp_har2case.md @@ -23,4 +23,4 @@ hrp har2case $har_path... [flags] * [hrp](hrp.md) - One-stop solution for HTTP(S) testing. -###### Auto generated by spf13/cobra on 10-Mar-2022 +###### Auto generated by spf13/cobra on 15-Mar-2022 diff --git a/docs/cmd/hrp_run.md b/docs/cmd/hrp_run.md index fdcdac9..5748b4e 100644 --- a/docs/cmd/hrp_run.md +++ b/docs/cmd/hrp_run.md @@ -34,4 +34,4 @@ hrp run $path... [flags] * [hrp](hrp.md) - One-stop solution for HTTP(S) testing. -###### Auto generated by spf13/cobra on 10-Mar-2022 +###### Auto generated by spf13/cobra on 15-Mar-2022 diff --git a/docs/cmd/hrp_startproject.md b/docs/cmd/hrp_startproject.md index 78563b1..20b3b0c 100644 --- a/docs/cmd/hrp_startproject.md +++ b/docs/cmd/hrp_startproject.md @@ -16,4 +16,4 @@ hrp startproject $project_name [flags] * [hrp](hrp.md) - One-stop solution for HTTP(S) testing. -###### Auto generated by spf13/cobra on 10-Mar-2022 +###### Auto generated by spf13/cobra on 15-Mar-2022 diff --git a/examples/api/get.json b/examples/api/get.json new file mode 100644 index 0000000..14730e6 --- /dev/null +++ b/examples/api/get.json @@ -0,0 +1,34 @@ +{ + "name": "", + "request": { + "method": "GET", + "url": "/get", + "params": { + "foo1": "bar1", + "foo2": "bar2" + }, + "headers": { + "Postman-Token": "ea19464c-ddd4-4724-abe9-5e2b254c2723" + } + }, + "validate": [ + { + "check": "status_code", + "assert": "equals", + "expect": 200, + "msg": "assert response status code" + }, + { + "check": "headers.\"Content-Type\"", + "assert": "equals", + "expect": "application/json; charset=utf-8", + "msg": "assert response header Content-Type" + }, + { + "check": "body.url", + "assert": "equals", + "expect": "https://postman-echo.com/get?foo1=bar1&foo2=bar2", + "msg": "assert response body url" + } + ] +} \ No newline at end of file diff --git a/examples/api/get.yml b/examples/api/get.yml new file mode 100644 index 0000000..de44702 --- /dev/null +++ b/examples/api/get.yml @@ -0,0 +1,22 @@ +name: "" +request: + method: GET + url: /get + params: + foo1: bar1 + foo2: bar2 + headers: + Postman-Token: ea19464c-ddd4-4724-abe9-5e2b254c2723 +validate: + - check: status_code + assert: equals + expect: 200 + msg: assert response status code + - check: headers."Content-Type" + assert: equals + expect: application/json; charset=utf-8 + msg: assert response header Content-Type + - check: body.url + assert: equals + expect: https://postman-echo.com/get?foo1=bar1&foo2=bar2 + msg: assert response body url \ No newline at end of file diff --git a/examples/api/post.json b/examples/api/post.json new file mode 100644 index 0000000..a0be491 --- /dev/null +++ b/examples/api/post.json @@ -0,0 +1,45 @@ +{ + "name": "", + "request": { + "method": "POST", + "url": "/post", + "headers": { + "Content-Length": "58", + "Content-Type": "text/plain", + "Postman-Token": "$session_token" + }, + "body": "This is expected to be sent back as part of response body." + }, + "validate": [ + { + "check": "status_code", + "assert": "equals", + "expect": 200, + "msg": "assert response status code" + }, + { + "check": "headers.\"Content-Type\"", + "assert": "equals", + "expect": "application/json; charset=utf-8", + "msg": "assert response header Content-Type" + }, + { + "check": "body.data", + "assert": "equals", + "expect": "This is expected to be sent back as part of response body.", + "msg": "assert response body data" + }, + { + "check": "body.json", + "assert": "equals", + "expect": null, + "msg": "assert response body json" + }, + { + "check": "body.url", + "assert": "equals", + "expect": "https://postman-echo.com/post", + "msg": "assert response body url" + } + ] +} \ No newline at end of file diff --git a/examples/api/post.yml b/examples/api/post.yml new file mode 100644 index 0000000..1cfac61 --- /dev/null +++ b/examples/api/post.yml @@ -0,0 +1,30 @@ +name: "" +request: + method: POST + url: /post + headers: + Content-Length: "58" + Content-Type: text/plain + Postman-Token: $session_token + body: This is expected to be sent back as part of response body. +validate: + - check: status_code + assert: equals + expect: 200 + msg: assert response status code + - check: headers."Content-Type" + assert: equals + expect: application/json; charset=utf-8 + msg: assert response header Content-Type + - check: body.data + assert: equals + expect: This is expected to be sent back as part of response body. + msg: assert response body data + - check: body.json + assert: equals + expect: null + msg: assert response body json + - check: body.url + assert: equals + expect: https://postman-echo.com/post + msg: assert response body url \ No newline at end of file diff --git a/examples/api/put.json b/examples/api/put.json new file mode 100644 index 0000000..f68fa7e --- /dev/null +++ b/examples/api/put.json @@ -0,0 +1,45 @@ +{ + "name": "", + "request": { + "method": "PUT", + "url": "/put", + "headers": { + "Content-Length": "58", + "Content-Type": "text/plain", + "Postman-Token": "5d357b2b-0f10-4ded-bc9a-299ebef7a2d5" + }, + "body": "This is expected to be sent back as part of response body." + }, + "validate": [ + { + "check": "status_code", + "assert": "equals", + "expect": 200, + "msg": "assert response status code" + }, + { + "check": "headers.\"Content-Type\"", + "assert": "equals", + "expect": "application/json; charset=utf-8", + "msg": "assert response header Content-Type" + }, + { + "check": "body.data", + "assert": "equals", + "expect": "This is expected to be sent back as part of response body.", + "msg": "assert response body data" + }, + { + "check": "body.json", + "assert": "equals", + "expect": null, + "msg": "assert response body json" + }, + { + "check": "body.url", + "assert": "equals", + "expect": "https://postman-echo.com/put", + "msg": "assert response body url" + } + ] +} \ No newline at end of file diff --git a/examples/api/put.yml b/examples/api/put.yml new file mode 100644 index 0000000..4fa1abf --- /dev/null +++ b/examples/api/put.yml @@ -0,0 +1,30 @@ +name: "" +request: + method: PUT + url: /put + headers: + Content-Length: "58" + Content-Type: text/plain + Postman-Token: 5d357b2b-0f10-4ded-bc9a-299ebef7a2d5 + body: This is expected to be sent back as part of response body. +validate: + - check: status_code + assert: equals + expect: 200 + msg: assert response status code + - check: headers."Content-Type" + assert: equals + expect: application/json; charset=utf-8 + msg: assert response header Content-Type + - check: body.data + assert: equals + expect: This is expected to be sent back as part of response body. + msg: assert response body data + - check: body.json + assert: equals + expect: null + msg: assert response body json + - check: body.url + assert: equals + expect: https://postman-echo.com/put + msg: assert response body url \ No newline at end of file diff --git a/examples/compat_test.go b/examples/compat_test.go index 85a61b0..8f33c4c 100644 --- a/examples/compat_test.go +++ b/examples/compat_test.go @@ -7,18 +7,18 @@ import ( ) // generated by examples/har/demo.har using HttpRunner v3.1.6 -const demoHttpRunnerJSONPath = "demo_httprunner.json" -const demoHttpRunnerYAMLPath = "demo_httprunner.yaml" +var ( + demoHttpRunnerJSONPath hrp.TestCasePath = "demo_httprunner.json" + demoHttpRunnerYAMLPath hrp.TestCasePath = "demo_httprunner.yaml" +) func TestCompatTestCase(t *testing.T) { - testcaseFromJSON := &hrp.TestCasePath{Path: demoHttpRunnerJSONPath} - err := hrp.NewRunner(t).Run(testcaseFromJSON) + err := hrp.NewRunner(t).Run(&demoHttpRunnerJSONPath) if err != nil { t.Fatalf("run testcase error: %v", err) } - testcaseFromYAML := &hrp.TestCasePath{Path: demoHttpRunnerYAMLPath} - err = hrp.NewRunner(t).Run(testcaseFromYAML) + err = hrp.NewRunner(t).Run(&demoHttpRunnerYAMLPath) if err != nil { t.Fatalf("run testcase error: %v", err) } diff --git a/examples/postman-echo.json b/examples/postman-echo.json index 483852b..5029499 100644 --- a/examples/postman-echo.json +++ b/examples/postman-echo.json @@ -585,13 +585,13 @@ { "check": "status_code", "assert": "equals", - "expect": 302, + "expect": 200, "msg": "assert response status code" }, { "check": "headers.\"Content-Type\"", "assert": "equals", - "expect": "text/plain; charset=utf-8", + "expect": "application/json; charset=utf-8", "msg": "assert response header Content-Type" } ] @@ -695,13 +695,13 @@ { "check": "status_code", "assert": "equals", - "expect": 302, + "expect": 200, "msg": "assert response status code" }, { "check": "headers.\"Content-Type\"", "assert": "equals", - "expect": "text/plain; charset=utf-8", + "expect": "application/json; charset=utf-8", "msg": "assert response header Content-Type" } ] diff --git a/examples/postman-echo.yaml b/examples/postman-echo.yaml index ea92ed1..3278081 100644 --- a/examples/postman-echo.yaml +++ b/examples/postman-echo.yaml @@ -411,11 +411,11 @@ teststeps: validate: - check: status_code assert: equals - expect: 302 + expect: 200 msg: assert response status code - check: headers."Content-Type" assert: equals - expect: text/plain; charset=utf-8 + expect: application/json; charset=utf-8 msg: assert response header Content-Type - name: "" request: @@ -490,11 +490,11 @@ teststeps: validate: - check: status_code assert: equals - expect: 302 + expect: 200 msg: assert response status code - check: headers."Content-Type" assert: equals - expect: text/plain; charset=utf-8 + expect: application/json; charset=utf-8 msg: assert response header Content-Type - name: "" request: diff --git a/examples/ref_api_test.json b/examples/ref_api_test.json new file mode 100644 index 0000000..cebf1c0 --- /dev/null +++ b/examples/ref_api_test.json @@ -0,0 +1,78 @@ +{ + "config": { + "name": "api test demo", + "variables": { + "user_agent": "iOS/10.3", + "device_sn": "TESTCASE_SETUP_XXX", + "os_platform": "ios", + "app_version": "2.8.6" + }, + "base_url": "https://postman-echo.com", + "herader": [ + { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "User-Agent": "PostmanRuntime/7.28.4" + } + ], + "verify": false, + "export": [ + "session_token" + ] + }, + "teststeps": [ + { + "name": "test api /get", + "api": "examples/api/get.json", + "variables": { + "user_agent": "iOS/10.4", + "device_sn": "$device_sn", + "os_platform": "ios", + "app_version": "2.8.7" + }, + "extract": { + "session_token": "body.headers.\"postman-token\"" + } + }, + { + "name": "test api /post", + "api": "examples/api/post.json", + "variables": { + "user_agent": "iOS/10.5", + "device_sn": "$device_sn", + "os_platform": "ios", + "app_version": "2.8.9" + }, + "validate": [ + { + "eq": [ + "status_code", + 200 + ] + }, + { + "eq": [ + "body.headers.postman-token", + "ea19464c-ddd4-4724-abe9-5e2b254c2723" + ] + } + ] + }, + { + "name": "test api /put", + "api": "examples/api/put.json", + "variables": { + "user_agent": "iOS/10.6", + "device_sn": "$device_sn", + "os_platform": "ios", + "app_version": "2.8.10" + }, + "extract": { + "session_token": "body.headers.\"postman-token\"" + } + } + ] +} \ No newline at end of file diff --git a/examples/ref_api_test.yaml b/examples/ref_api_test.yaml new file mode 100644 index 0000000..16aff28 --- /dev/null +++ b/examples/ref_api_test.yaml @@ -0,0 +1,47 @@ +config: + name: 'api test demo' + variables: + user_agent: iOS/10.3 + device_sn: TESTCASE_SETUP_XXX + os_platform: ios + app_version: 2.8.6 + base_url: 'https://postman-echo.com' + herader: + - Accept: '*/*' + Accept-Encoding: 'gzip, deflate, br' + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + User-Agent: PostmanRuntime/7.28.4 + verify: false + export: + - session_token +teststeps: + - name: 'test api /get' + api: examples/api/get.json + variables: + user_agent: iOS/10.4 + device_sn: $device_sn + os_platform: ios + app_version: 2.8.7 + extract: + session_token: 'body.headers."postman-token"' + - name: 'test api /post' + api: examples/api/post.json + variables: + user_agent: iOS/10.5 + device_sn: $device_sn + os_platform: ios + app_version: 2.8.9 + validate: + - { eq: [ status_code, 200 ] } + - { eq: [ body.headers.postman-token, ea19464c-ddd4-4724-abe9-5e2b254c2723 ] } + - name: 'test api /put' + api: examples/api/put.json + variables: + user_agent: iOS/10.6 + device_sn: $device_sn + os_platform: ios + app_version: 2.8.10 + extract: + session_token: 'body.headers."postman-token"' diff --git a/examples/ref_testcase_test.json b/examples/ref_testcase_test.json new file mode 100644 index 0000000..0cb2f6d --- /dev/null +++ b/examples/ref_testcase_test.json @@ -0,0 +1,18 @@ +{ + "config": { + "name": "reference testcase test", + "base_url": "https://postman-echo.com", + "variables": { + "os_platform": "ios" + } + }, + "teststeps": [ + { + "name": "run demo_httprunner.json", + "testcase": "examples/demo_httprunner.json", + "variables": { + "os_platform": "$os_platform" + } + } + ] +} \ No newline at end of file diff --git a/examples/ref_testcase_test.yaml b/examples/ref_testcase_test.yaml new file mode 100644 index 0000000..bfd8fa0 --- /dev/null +++ b/examples/ref_testcase_test.yaml @@ -0,0 +1,11 @@ +config: + name: "reference testcase test" + base_url: "https://postman-echo.com" + variables: + os_platform: 'ios' + +teststeps: + - name: run demo_httprunner.yaml + testcase: examples/demo_httprunner.yaml + variables: + os_platform: $os_platform \ No newline at end of file diff --git a/internal/boomer/boomer.go b/internal/boomer/boomer.go index a66b556..d204b7e 100644 --- a/internal/boomer/boomer.go +++ b/internal/boomer/boomer.go @@ -16,6 +16,9 @@ type Boomer struct { memoryProfile string memoryProfileDuration time.Duration + + disableKeepalive bool + disableCompression bool } // NewStandaloneBoomer returns a new Boomer, which can run without master. @@ -52,6 +55,24 @@ func (b *Boomer) SetRateLimiter(maxRPS int64, requestIncreaseRate string) { } } +// SetDisableKeepAlive disable keep-alive for tcp +func (b *Boomer) SetDisableKeepAlive(disableKeepalive bool) { + b.disableKeepalive = disableKeepalive +} + +// SetDisableCompression disable compression to prevent the Transport from requesting compression with an "Accept-Encoding: gzip" +func (b *Boomer) SetDisableCompression(disableCompression bool) { + b.disableCompression = disableCompression +} + +func (b *Boomer) GetDisableKeepAlive() bool { + return b.disableKeepalive +} + +func (b *Boomer) GetDisableCompression() bool { + return b.disableCompression +} + // SetLoopCount set loop count for test. func (b *Boomer) SetLoopCount(loopCount int64) { b.localRunner.loop = &Loop{loopCount: loopCount} diff --git a/internal/boomer/output.go b/internal/boomer/output.go index a2aa642..dff3f97 100644 --- a/internal/boomer/output.go +++ b/internal/boomer/output.go @@ -255,6 +255,10 @@ func deserializeStatsEntry(stat interface{}) (entryOutput *statsEntryOutput, err var duration float64 if entry.Name == "Total" { duration = float64(entry.LastRequestTimestamp - entry.StartTime) + // fix: avoid divide by zero + if duration < 1 { + duration = 1 + } } else { duration = float64(reportStatsInterval / time.Second) } diff --git a/internal/builtin/function.go b/internal/builtin/function.go index ea7a672..6c4a193 100644 --- a/internal/builtin/function.go +++ b/internal/builtin/function.go @@ -211,3 +211,12 @@ func EnsureFolderExists(folderPath string) error { } return nil } + +func Contains(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} diff --git a/internal/json/json.go b/internal/json/json.go index ca3efbf..859d1e2 100644 --- a/internal/json/json.go +++ b/internal/json/json.go @@ -12,4 +12,5 @@ var ( MarshalIndent = json.MarshalIndent Unmarshal = json.Unmarshal NewDecoder = json.NewDecoder + Get = json.Get ) diff --git a/internal/scaffold/demo_test.go b/internal/scaffold/demo_test.go index eb9c101..593a2e0 100644 --- a/internal/scaffold/demo_test.go +++ b/internal/scaffold/demo_test.go @@ -12,8 +12,8 @@ import ( ) var ( - demoTestCaseJSONPath = "../../examples/demo.json" - demoTestCaseYAMLPath = "../../examples/demo.yaml" + demoTestCaseJSONPath hrp.TestCasePath = "../../examples/demo.json" + demoTestCaseYAMLPath hrp.TestCasePath = "../../examples/demo.yaml" ) func buildHashicorpPlugin() { @@ -33,11 +33,11 @@ func removeHashicorpPlugin() { func TestGenDemoTestCase(t *testing.T) { tCase, _ := demoTestCase.ToTCase() - err := builtin.Dump2JSON(tCase, demoTestCaseJSONPath) + err := builtin.Dump2JSON(tCase, demoTestCaseJSONPath.ToString()) if err != nil { t.Fail() } - err = builtin.Dump2YAML(tCase, demoTestCaseYAMLPath) + err = builtin.Dump2YAML(tCase, demoTestCaseYAMLPath.ToString()) if err != nil { t.Fail() } @@ -58,8 +58,7 @@ func TestJsonDemo(t *testing.T) { buildHashicorpPlugin() defer removeHashicorpPlugin() - testCase := &hrp.TestCasePath{Path: demoTestCaseJSONPath} - err := hrp.NewRunner(nil).Run(testCase) // hrp.Run(testCase) + err := hrp.NewRunner(nil).Run(&demoTestCaseJSONPath) // hrp.Run(testCase) if err != nil { t.Fail() } @@ -69,8 +68,7 @@ func TestYamlDemo(t *testing.T) { buildHashicorpPlugin() defer removeHashicorpPlugin() - testCase := &hrp.TestCasePath{Path: demoTestCaseYAMLPath} - err := hrp.NewRunner(nil).Run(testCase) // hrp.Run(testCase) + err := hrp.NewRunner(nil).Run(&demoTestCaseYAMLPath) // hrp.Run(testCase) if err != nil { t.Fail() } diff --git a/models.go b/models.go index 9b524c1..2377a77 100644 --- a/models.go +++ b/models.go @@ -26,6 +26,7 @@ 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"` + Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"` Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` Parameters map[string]interface{} `json:"parameters,omitempty" yaml:"parameters,omitempty"` ParametersSetting *TParamsConfig `json:"parameters_setting,omitempty" yaml:"parameters_setting,omitempty"` @@ -104,6 +105,21 @@ type Request struct { Verify bool `json:"verify,omitempty" yaml:"verify,omitempty"` } +type API struct { + Name string `json:"name" yaml:"name"` // required + Request *Request `json:"request,omitempty" yaml:"request,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"` +} + +func (api *API) ToAPI() (*API, error) { + return api, nil +} + // Validator represents validator for one HTTP response. type Validator struct { Check string `json:"check" yaml:"check"` // get value with jmespath @@ -112,20 +128,29 @@ type Validator struct { Message string `json:"msg,omitempty" yaml:"msg,omitempty"` // optional } +// IAPI represents interface for api, +// includes API and APIPath. +type IAPI interface { + ToAPI() (*API, error) +} + // TStep represents teststep data structure. // Each step maybe two different type: make one HTTP request or reference another testcase. type TStep struct { - Name string `json:"name" yaml:"name"` // required - Request *Request `json:"request,omitempty" yaml:"request,omitempty"` - TestCase *TestCase `json:"testcase,omitempty" yaml:"testcase,omitempty"` - Transaction *Transaction `json:"transaction,omitempty" yaml:"transaction,omitempty"` - Rendezvous *Rendezvous `json:"rendezvous,omitempty" yaml:"rendezvous,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"` + Name string `json:"name" yaml:"name"` // required + Request *Request `json:"request,omitempty" yaml:"request,omitempty"` + APIPath string `json:"api,omitempty" yaml:"api,omitempty"` + TestCasePath string `json:"testcase,omitempty" yaml:"testcase,omitempty"` + APIContent IAPI `json:"api_content,omitempty" yaml:"api_content,omitempty"` + TestCaseContent ITestCase `json:"testcase_content,omitempty" yaml:"testcase_content,omitempty"` + Transaction *Transaction `json:"transaction,omitempty" yaml:"transaction,omitempty"` + Rendezvous *Rendezvous `json:"rendezvous,omitempty" yaml:"rendezvous,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 stepType string diff --git a/parser.go b/parser.go index a1e49f8..ced3bc0 100644 --- a/parser.go +++ b/parser.go @@ -281,6 +281,99 @@ func mergeVariables(variables, overriddenVariables map[string]interface{}) map[s return mergedVariables } +// merge two map, the first map have higher priority +func mergeMap(m, overriddenMap map[string]string) map[string]string { + if overriddenMap == nil { + return m + } + if m == nil { + return overriddenMap + } + + mergedMap := make(map[string]string) + for k, v := range overriddenMap { + mergedMap[k] = v + } + for k, v := range m { + mergedMap[k] = v + } + return mergedMap +} + +// merge two validators slice, the first validators have higher priority +func mergeValidators(validators, overriddenValidators []interface{}) []interface{} { + if validators == nil { + return overriddenValidators + } + if overriddenValidators == nil { + return validators + } + var mergedValidators []interface{} + validators = append(validators, overriddenValidators...) + for _, validator := range validators { + flag := true + for _, mergedValidator := range mergedValidators { + if validator.(Validator).Check == mergedValidator.(Validator).Check { + flag = false + break + } + } + if flag { + mergedValidators = append(mergedValidators, validator) + } + } + return mergedValidators +} + +// merge two slices, the first slice have higher priority +func mergeSlices(slice, overriddenSlice []string) []string { + if slice == nil { + return overriddenSlice + } + if overriddenSlice == nil { + return slice + } + + for _, value := range overriddenSlice { + if !builtin.Contains(slice, value) { + slice = append(slice, value) + } + } + return slice +} + +// extend teststep with api, teststep will merge and override referenced api +func extendWithAPI(testStep *TStep, overriddenStep *API) { + // override api name + if testStep.Name == "" { + testStep.Name = overriddenStep.Name + } + // merge & override request + testStep.Request = overriddenStep.Request + // merge & override variables + testStep.Variables = mergeVariables(testStep.Variables, overriddenStep.Variables) + // merge & override extractors + testStep.Extract = mergeMap(testStep.Extract, overriddenStep.Extract) + // merge & override validators + testStep.Validators = mergeValidators(testStep.Validators, overriddenStep.Validators) + // merge & override setupHooks + testStep.SetupHooks = mergeSlices(testStep.SetupHooks, overriddenStep.SetupHooks) + // merge & override teardownHooks + testStep.TeardownHooks = mergeSlices(testStep.TeardownHooks, overriddenStep.TeardownHooks) +} + +// extend referenced testcase with teststep, teststep config merge and override referenced testcase config +func extendWithTestCase(testStep *TStep, overriddenTestCase *TestCase) { + // override testcase name + if testStep.Name != "" { + overriddenTestCase.Config.Name = testStep.Name + } + // merge & override variables + overriddenTestCase.Config.Variables = mergeVariables(testStep.Variables, overriddenTestCase.Config.Variables) + // merge & override extractors + overriddenTestCase.Config.Export = mergeSlices(testStep.Export, overriddenTestCase.Config.Export) +} + var eval = goval.NewEvaluator() // literalEval parse string to number if possible diff --git a/parser_test.go b/parser_test.go index dbf75af..cf3f2bb 100644 --- a/parser_test.go +++ b/parser_test.go @@ -333,6 +333,112 @@ func TestMergeVariables(t *testing.T) { } } +func TestMergeMap(t *testing.T) { + testData := []struct { + m map[string]string + overriddenMap map[string]string + expectMap map[string]string + }{ + { + map[string]string{"Accept": "*/*", "Accept-Encoding": "gzip, deflate, br", "Connection": "close"}, + map[string]string{"Cache-Control": "no-cache", "Connection": "keep-alive"}, + map[string]string{"Accept": "*/*", "Accept-Encoding": "gzip, deflate, br", "Connection": "close", "Cache-Control": "no-cache"}, + }, + { + map[string]string{"Host": "postman-echo.com", "Postman-Token": "ea19464c-ddd4-4724-abe9-5e2b254c2723"}, + map[string]string{"Host": "Postman-echo.com", "Connection": "keep-alive", "Postman-Token": "ea19464c-ddd4-4724-abe9-5e2b342c2723"}, + map[string]string{"Host": "postman-echo.com", "Postman-Token": "ea19464c-ddd4-4724-abe9-5e2b254c2723", "Connection": "keep-alive"}, + }, + { + map[string]string{"Accept": "*/*", "Accept-Encoding": "gzip, deflate, br", "Connection": "close"}, + nil, + map[string]string{"Accept": "*/*", "Accept-Encoding": "gzip, deflate, br", "Connection": "close"}, + }, + { + nil, + map[string]string{"Cache-Control": "no-cache", "Connection": "keep-alive"}, + map[string]string{"Cache-Control": "no-cache", "Connection": "keep-alive"}, + }, + } + + for _, data := range testData { + mergedMap := mergeMap(data.m, data.overriddenMap) + if !assert.Equal(t, data.expectMap, mergedMap) { + t.Fail() + } + } +} + +func TestMergeSlices(t *testing.T) { + testData := []struct { + slice []string + overriddenSlice []string + expectSlice []string + }{ + { + []string{"${setup_hook_example1($name)}", "${setup_hook_example2($name)}"}, + []string{"${setup_hook_example3($name)}", "${setup_hook_example4($name)}"}, + []string{"${setup_hook_example1($name)}", "${setup_hook_example2($name)}", "${setup_hook_example3($name)}", "${setup_hook_example4($name)}"}, + }, + { + []string{"${setup_hook_example1($name)}", "${setup_hook_example2($name)}"}, + nil, + []string{"${setup_hook_example1($name)}", "${setup_hook_example2($name)}"}, + }, + { + nil, + []string{"${setup_hook_example3($name)}", "${setup_hook_example4($name)}"}, + []string{"${setup_hook_example3($name)}", "${setup_hook_example4($name)}"}, + }, + } + + for _, data := range testData { + mergedSlice := mergeSlices(data.slice, data.overriddenSlice) + if !assert.Equal(t, data.expectSlice, mergedSlice) { + t.Fail() + } + } +} + +func TestMergeValidators(t *testing.T) { + testData := []struct { + validators []interface{} + overriddenValidators []interface{} + expectValidators []interface{} + }{ + { + []interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 200, Message: "assert response status code"}}, + []interface{}{Validator{Check: `headers."Content-Type"`, Assert: "equals", Expect: "application/json; charset=utf-8", Message: "assert response header Content-Typ"}}, + []interface{}{ + Validator{Check: "status_code", Assert: "equals", Expect: 200, Message: "assert response status code"}, + Validator{Check: `headers."Content-Type"`, Assert: "equals", Expect: "application/json; charset=utf-8", Message: "assert response header Content-Typ"}, + }, + }, + { + []interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 302, Message: "assert response status code"}}, + []interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 200, Message: "assert response status code"}}, + []interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 302, Message: "assert response status code"}}, + }, + { + nil, + []interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 200, Message: "assert response status code"}}, + []interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 200, Message: "assert response status code"}}, + }, + { + []interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 302, Message: "assert response status code"}}, + nil, + []interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 302, Message: "assert response status code"}}, + }, + } + + for _, data := range testData { + mergedValidators := mergeValidators(data.validators, data.overriddenValidators) + if !assert.Equal(t, data.expectValidators, mergedValidators) { + t.Fail() + } + } +} + func TestCallBuiltinFunction(t *testing.T) { parser := newParser() diff --git a/runner.go b/runner.go index 03c43d2..4b1c2ee 100644 --- a/runner.go +++ b/runner.go @@ -3,13 +3,14 @@ package hrp import ( "bufio" "bytes" - "compress/flate" "compress/gzip" + "compress/zlib" "crypto/tls" _ "embed" "fmt" "html/template" "io" + "net" "net/http" "net/http/httputil" "net/url" @@ -74,6 +75,20 @@ type HRPRunner struct { client *http.Client } +// SetClientTransport configures transport of http client for high concurrency load testing +func (r *HRPRunner) SetClientTransport(maxConns int, disableKeepAlive bool, disableCompression bool) *HRPRunner { + log.Info().Int("maxConns", maxConns).Msg("[init] SetClientTransport") + r.client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + DialContext: (&net.Dialer{}).DialContext, + MaxIdleConns: 0, + MaxIdleConnsPerHost: maxConns, + DisableKeepAlives: disableKeepAlive, + DisableCompression: disableCompression, + } + return r +} + // SetFailfast configures whether to stop running when one step fails. func (r *HRPRunner) SetFailfast(failfast bool) *HRPRunner { log.Info().Bool("failfast", failfast).Msg("[init] SetFailfast") @@ -356,12 +371,21 @@ func (r *caseRunner) runStep(index int, caseConfig *TConfig) (stepResult *stepDa if _, ok := step.(*StepTestCaseWithOptionalArgs); ok { // run referenced testcase log.Info().Str("testcase", copiedStep.Name).Msg("run referenced testcase") - // TODO: override testcase config stepResult, err = r.runStepTestCase(copiedStep) if err != nil { log.Error().Err(err).Msg("run referenced testcase step failed") } } else { + if _, ok := step.(*StepAPIWithOptionalArgs); ok { + // run referenced API + log.Info().Str("api", copiedStep.Name).Msg("run referenced api") + api, _ := copiedStep.APIContent.ToAPI() + extendWithAPI(copiedStep, api) + } + // override headers + if caseConfig.Headers != nil { + copiedStep.Request.Headers = mergeMap(copiedStep.Request.Headers, caseConfig.Headers) + } // parse step request url var requestUrl interface{} requestUrl, err = r.parser.parseString(copiedStep.Request.URL, copiedStep.Variables) @@ -634,7 +658,6 @@ func (r *caseRunner) runStepRequest(step *TStep) (stepResult *stepData, err erro Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, - Close: true, // prevent the connection from being re-used } // prepare request headers @@ -906,19 +929,22 @@ func shouldPrintBody(contentType string) bool { return false } -func decodeResponseBody(resp *http.Response) error { +func decodeResponseBody(resp *http.Response) (err error) { switch resp.Header.Get("Content-Encoding") { case "br": resp.Body = io.NopCloser(brotli.NewReader(resp.Body)) case "gzip": - gr, err := gzip.NewReader(resp.Body) + resp.Body, err = gzip.NewReader(resp.Body) if err != nil { return err } - resp.Body = gr resp.ContentLength = -1 // set to unknown to avoid Content-Length mismatched case "deflate": - resp.Body = flate.NewReader(resp.Body) + resp.Body, err = zlib.NewReader(resp.Body) + if err != nil { + return err + } + resp.ContentLength = -1 // set to unknown to avoid Content-Length mismatched } return nil } @@ -929,7 +955,7 @@ func (r *caseRunner) runStepTestCase(step *TStep) (stepResult *stepData, err err StepType: stepTypeTestCase, Success: false, } - testcase := step.TestCase + testcase := step.TestCaseContent // copy testcase to avoid data racing copiedTestCase := &TestCase{} @@ -937,6 +963,8 @@ func (r *caseRunner) runStepTestCase(step *TStep) (stepResult *stepData, err err log.Error().Err(err).Msg("copy testcase failed") return stepResult, err } + // override testcase config + extendWithTestCase(step, copiedTestCase) start := time.Now() caseRunnerObj := r.hrpRunner.newCaseRunner(copiedTestCase) @@ -946,6 +974,8 @@ func (r *caseRunner) runStepTestCase(step *TStep) (stepResult *stepData, err err return stepResult, err } stepResult.Data = caseRunnerObj.getSummary() + // export testcase export variables + stepResult.ExportVars = caseRunnerObj.summary.InOut.ExportVars stepResult.Success = true return stepResult, nil } @@ -991,7 +1021,7 @@ func (r *caseRunner) getSummary() *testCaseSummary { caseSummary.Time.Duration = time.Since(r.startTime).Seconds() exportVars := make(map[string]interface{}) for _, value := range r.Config.Export { - exportVars[value] = r.Config.Variables[value] + exportVars[value] = r.sessionVariables[value] } caseSummary.InOut.ExportVars = exportVars caseSummary.InOut.ConfigVars = r.Config.Variables diff --git a/runner_test.go b/runner_test.go index 7e27282..4060df2 100644 --- a/runner_test.go +++ b/runner_test.go @@ -49,17 +49,26 @@ func TestHttpRunner(t *testing.T) { AssertEqual("status_code", 200, "check status code"). AssertEqual("headers.\"Content-Type\"", "application/json", "check http response Content-Type"), }}), + NewStep("TestCase4").CallRefCase(&demoRefAPIYAMLPath), + NewStep("TestCase5").CallRefCase(&demoTestCaseJSONPath), }, } testcase2 := &TestCase{ Config: NewConfig("TestCase2").SetWeight(3), } - testcase3 := &TestCasePath{demoTestCaseJSONPath} + testcase3 := &TestCase{ + Config: NewConfig("TestCase1"). + SetBaseURL("https://postman-echo.com"), + TestSteps: []IStep{ + NewStep("TestCase5").CallRefAPI(&demoAPIYAMLPath), + }, + } + testcase4 := &demoRefTestCaseJSONPath r := NewRunner(t) r.saveTests = true r.genHTMLReport = true - err := r.Run(testcase1, testcase2, testcase3) + err := r.Run(testcase1, testcase2, testcase3, testcase4) if err != nil { t.Fatalf("run testcase error: %v", err) } diff --git a/step.go b/step.go index 2b24833..570120a 100644 --- a/step.go +++ b/step.go @@ -22,6 +22,12 @@ func (c *TConfig) SetBaseURL(baseURL string) *TConfig { return c } +// SetHeaders sets global headers for current testcase. +func (c *TConfig) SetHeaders(headers map[string]string) *TConfig { + c.Headers = headers + return c +} + // SetVerifySSL sets whether to verify SSL for current testcase. func (c *TConfig) SetVerifySSL(verify bool) *TConfig { c.Verify = verify @@ -150,13 +156,21 @@ func (s *StepRequest) PATCH(url string) *StepRequestWithOptionalArgs { } // CallRefCase calls a referenced testcase. -func (s *StepRequest) CallRefCase(tc *TestCase) *StepTestCaseWithOptionalArgs { - s.step.TestCase = tc +func (s *StepRequest) CallRefCase(tc ITestCase) *StepTestCaseWithOptionalArgs { + s.step.TestCaseContent, _ = tc.ToTestCase() return &StepTestCaseWithOptionalArgs{ step: s.step, } } +// CallRefAPI calls a referenced api. +func (s *StepRequest) CallRefAPI(api IAPI) *StepAPIWithOptionalArgs { + s.step.APIContent, _ = api.ToAPI() + return &StepAPIWithOptionalArgs{ + step: s.step, + } +} + // StartTransaction starts a transaction. func (s *StepRequest) StartTransaction(name string) *StepTransaction { s.step.Transaction = &Transaction{ @@ -274,6 +288,40 @@ func (s *StepRequestWithOptionalArgs) ToStruct() *TStep { return s.step } +// StepAPIWithOptionalArgs implements IStep interface. +type StepAPIWithOptionalArgs struct { + step *TStep +} + +// TeardownHook adds a teardown hook for current teststep. +func (s *StepAPIWithOptionalArgs) TeardownHook(hook string) *StepAPIWithOptionalArgs { + s.step.TeardownHooks = append(s.step.TeardownHooks, hook) + return s +} + +// Export specifies variable names to export from referenced api for current step. +func (s *StepAPIWithOptionalArgs) Export(names ...string) *StepAPIWithOptionalArgs { + api, _ := s.step.APIContent.ToAPI() + s.step.Export = append(api.Export, names...) + return s +} + +func (s *StepAPIWithOptionalArgs) Name() string { + if s.step.Name != "" { + return s.step.Name + } + api, _ := s.step.APIContent.ToAPI() + return api.Name +} + +func (s *StepAPIWithOptionalArgs) Type() string { + return "api" +} + +func (s *StepAPIWithOptionalArgs) ToStruct() *TStep { + return s.step +} + // StepTestCaseWithOptionalArgs implements IStep interface. type StepTestCaseWithOptionalArgs struct { step *TStep @@ -295,7 +343,8 @@ func (s *StepTestCaseWithOptionalArgs) Name() string { if s.step.Name != "" { return s.step.Name } - return s.step.TestCase.Config.Name + ts, _ := s.step.TestCaseContent.ToTestCase() + return ts.Config.Name } func (s *StepTestCaseWithOptionalArgs) Type() string { diff --git a/step_test.go b/step_test.go index 64d0378..c84a07c 100644 --- a/step_test.go +++ b/step_test.go @@ -16,13 +16,13 @@ var ( AssertEqual("body.args.foo1", "bar1", "check param foo1"). AssertEqual("body.args.foo2", "bar2", "check param foo2") stepPOSTData = NewStep("post form data"). - POST("/post"). - WithParams(map[string]interface{}{"foo1": "bar1", "foo2": "bar2"}). - WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus", "Content-Type": "application/x-www-form-urlencoded"}). - WithBody("a=1&b=2"). - WithCookies(map[string]string{"user": "debugtalk"}). - Validate(). - AssertEqual("status_code", 200, "check status code") + POST("/post"). + WithParams(map[string]interface{}{"foo1": "bar1", "foo2": "bar2"}). + WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus", "Content-Type": "application/x-www-form-urlencoded"}). + WithBody("a=1&b=2"). + WithCookies(map[string]string{"user": "debugtalk"}). + Validate(). + AssertEqual("status_code", 200, "check status code") ) func TestRunRequestGetToStruct(t *testing.T) {