diff --git a/boomer.go b/boomer.go index 61367240..d50109cd 100644 --- a/boomer.go +++ b/boomer.go @@ -150,6 +150,10 @@ func (b *HRPBoomer) convertBoomerTask(testcase *TestCase, rendezvousList []*Rend } } else if stepData.StepType == stepTypeRendezvous { // rendezvous + // TODO: implement rendezvous in boomer + } else if stepData.StepType == stepTypeThinkTime { + // think time + // no record required } else { // request or testcase step b.RecordSuccess(step.Type(), step.Name(), stepData.Elapsed, stepData.ContentSize) diff --git a/convert.go b/convert.go index d29b4212..c0f49516 100644 --- a/convert.go +++ b/convert.go @@ -156,6 +156,10 @@ func (tc *TCase) ToTestCase() (*TestCase, error) { testCase.TestSteps = append(testCase.TestSteps, &StepTestCaseWithOptionalArgs{ step: step, }) + } else if step.ThinkTime != nil { + testCase.TestSteps = append(testCase.TestSteps, &StepThinkTime{ + step: step, + }) } else if step.Request != nil { testCase.TestSteps = append(testCase.TestSteps, &StepRequestWithOptionalArgs{ step: step, diff --git a/convert_test.go b/convert_test.go index ea919646..f92647b7 100644 --- a/convert_test.go +++ b/convert_test.go @@ -11,6 +11,7 @@ var ( demoTestCaseYAMLPath TestCasePath = "examples/demo.yaml" demoRefAPIYAMLPath TestCasePath = "examples/ref_api_test.yaml" demoRefTestCaseJSONPath TestCasePath = "examples/ref_testcase_test.json" + demoThinkTimeJsonPath TestCasePath = "examples/think_time_test.json" demoAPIYAMLPath APIPath = "examples/api/put.yml" ) diff --git a/examples/think_time_test.json b/examples/think_time_test.json new file mode 100644 index 00000000..fddb4545 --- /dev/null +++ b/examples/think_time_test.json @@ -0,0 +1,63 @@ +{ + "config": { + "name": "think time test demo", + "variables": { + "app_version": "v1", + "user_agent": "iOS/10.3" + }, + "base_url": "https://postman-echo.com", + "think_time": { + "strategy": "random_percentage", + "setting": { + "min_percentage": 1, + "max_percentage": 1.5 + }, + "limit": 4 + }, + "verify": false + }, + "teststeps": [ + { + "name": "get with params", + "request": { + "method": "GET", + "url": "/get", + "headers": { + "User-Agent": "$user_agent,$app_version" + } + }, + "validate": [ + { + "check": "status_code", + "assert": "equals", + "expect": 200, + "msg": "check status code" + } + ] + }, + { + "name": "think time 1", + "think_time": { + "time": 3 + } + }, + { + "name": "post with params", + "request": { + "method": "POST", + "url": "/post", + "headers": { + "User-Agent": "$user_agent,$app_version" + } + }, + "validate": [ + { + "check": "status_code", + "assert": "equals", + "expect": 200, + "msg": "check status code" + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/think_time_test.yaml b/examples/think_time_test.yaml new file mode 100644 index 00000000..9f2f5129 --- /dev/null +++ b/examples/think_time_test.yaml @@ -0,0 +1,40 @@ +config: + name: "think time test demo" + variables: + app_version: v1 + user_agent: iOS/10.3 + base_url: "https://postman-echo.com" + think_time: + strategy: random_percentage + setting: + min_percentage: 1.0 + max_percentage: 1.5 + limit: 4 + verify: False + +teststeps: + - name: get with params + request: + method: GET + url: /get + headers: + User-Agent: $user_agent,$app_version + validate: + - check: status_code + assert: equals + expect: 200 + msg: check status code + - name: think time 1 + think_time: + time: 3 + - name: post with params + request: + method: POST + url: /post + headers: + User-Agent: $user_agent,$app_version + validate: + - check: status_code + assert: equals + expect: 200 + msg: check status code \ No newline at end of file diff --git a/internal/builtin/function.go b/internal/builtin/function.go index 6c4a1937..b78d8fc7 100644 --- a/internal/builtin/function.go +++ b/internal/builtin/function.go @@ -5,12 +5,15 @@ import ( "crypto/md5" "encoding/csv" "encoding/hex" + builtinJSON "encoding/json" + "errors" "fmt" "math" "math/rand" "os" "os/exec" "path/filepath" + "strconv" "strings" "time" @@ -220,3 +223,38 @@ func Contains(s []string, e string) bool { } return false } + +func GetRandomNumber(min, max int) int { + if min > max { + return 0 + } + r := rand.Intn(max - min + 1) + return min + r +} + +func Interface2Float64(i interface{}) (float64, error) { + switch i.(type) { + case int: + return float64(i.(int)), nil + case int32: + return float64(i.(int32)), nil + case int64: + return float64(i.(int64)), nil + case float32: + return float64(i.(float32)), nil + case float64: + return i.(float64), nil + case string: + intVar, err := strconv.Atoi(i.(string)) + if err != nil { + return 0, err + } + return float64(intVar), err + } + // json.Number + value, ok := i.(builtinJSON.Number) + if ok { + return value.Float64() + } + return 0, errors.New("failed to convert interface to float64") +} diff --git a/models.go b/models.go index 2377a770..950206be 100644 --- a/models.go +++ b/models.go @@ -3,10 +3,12 @@ package hrp import ( "fmt" "math/rand" + "reflect" "runtime" "sync" "time" + "github.com/httprunner/hrp/internal/builtin" "github.com/httprunner/hrp/internal/version" ) @@ -30,13 +32,14 @@ type TConfig struct { 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"` + ThinkTime *ThinkTimeConfig `json:"think_time,omitempty" yaml:"think_time,omitempty"` 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 } type TParamsConfig struct { - Strategy interface{} `json:"strategy,omitempty" yaml:"strategy,omitempty"` + Strategy interface{} `json:"strategy,omitempty" yaml:"strategy,omitempty"` // map[string]string、string Iteration int `json:"iteration,omitempty" yaml:"iteration,omitempty"` Iterators []*Iterator `json:"parameterIterator,omitempty" yaml:"parameterIterator,omitempty"` // 保存参数的迭代器 } @@ -46,6 +49,82 @@ const ( strategySequential string = "Sequential" ) +type ThinkTimeConfig struct { + Strategy string `json:"strategy,omitempty" yaml:"strategy,omitempty"` // default、random、limit、multiply、ignore + Setting interface{} `json:"setting,omitempty" yaml:"setting,omitempty"` // random(map): {"min_percentage": 0.5, "max_percentage": 1.5}; 10、multiply(float64): 1.5 + Limit float64 `json:"limit,omitempty" yaml:"limit,omitempty"` // limit think time no more than specific time, ignore if value <= 0 +} + +const ( + thinkTimeDefault string = "default" // as recorded + thinkTimeRandomPercentage string = "random_percentage" // use random percentage of recorded think time + thinkTimeMultiply string = "multiply" // multiply recorded think time + thinkTimeIgnore string = "ignore" // ignore recorded think time +) + +const ( + thinkTimeDefaultMultiply = 1 +) + +var ( + thinkTimeDefaultRandom = map[string]float64{"min_percentage": 0.5, "max_percentage": 1.5} +) + +func (ttc *ThinkTimeConfig) checkThinkTime() { + if ttc == nil { + return + } + // unset strategy, set default strategy + if ttc.Strategy == "" { + ttc.Strategy = thinkTimeDefault + } + // check think time + if ttc.Strategy == thinkTimeRandomPercentage { + if ttc.Setting == nil || reflect.TypeOf(ttc.Setting).Kind() != reflect.Map { + ttc.Setting = thinkTimeDefaultRandom + return + } + value, ok := ttc.Setting.(map[string]interface{}) + if !ok { + ttc.Setting = thinkTimeDefaultRandom + return + } + if _, ok := value["min_percentage"]; !ok { + ttc.Setting = thinkTimeDefaultRandom + return + } + if _, ok := value["max_percentage"]; !ok { + ttc.Setting = thinkTimeDefaultRandom + return + } + left, err := builtin.Interface2Float64(value["min_percentage"]) + if err != nil { + ttc.Setting = thinkTimeDefaultRandom + return + } + right, err := builtin.Interface2Float64(value["max_percentage"]) + if err != nil { + ttc.Setting = thinkTimeDefaultRandom + return + } + ttc.Setting = map[string]float64{"min_percentage": left, "max_percentage": right} + } else if ttc.Strategy == thinkTimeMultiply { + if ttc.Setting == nil { + ttc.Setting = float64(0) // default + return + } + value, err := builtin.Interface2Float64(ttc.Setting) + if err != nil { + ttc.Setting = float64(0) // default + return + } + ttc.Setting = value + } else if ttc.Strategy != thinkTimeIgnore { + // unrecognized strategy, set default strategy + ttc.Strategy = thinkTimeDefault + } +} + type paramsType []map[string]interface{} type Iterator struct { @@ -145,6 +224,7 @@ type TStep struct { 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"` + ThinkTime *ThinkTime `json:"think_time,omitempty" yaml:"think_time,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"` @@ -160,8 +240,13 @@ const ( stepTypeTestCase stepType = "testcase" stepTypeTransaction stepType = "transaction" stepTypeRendezvous stepType = "rendezvous" + stepTypeThinkTime stepType = "thinktime" ) +type ThinkTime struct { + Time float64 `json:"time" yaml:"time"` +} + type transactionType string const ( diff --git a/plugin.go b/plugin.go index 6a8b58b9..16068cc5 100644 --- a/plugin.go +++ b/plugin.go @@ -41,7 +41,6 @@ func initPlugin(path string, logOn bool) (plugin funplugin.IPlugin, err error) { go func() { <-c plugin.Quit() - os.Exit(0) }() // report event for initializing plugin diff --git a/runner.go b/runner.go index fadd503c..2564bc05 100644 --- a/runner.go +++ b/runner.go @@ -297,13 +297,16 @@ func (r *caseRunner) run() error { func (r *caseRunner) runStep(index int, caseConfig *TConfig) (stepResult *stepData, err error) { step := r.TestCase.TestSteps[index] - // step type priority order: transaction > rendezvous > testcase > request + // step type priority order: transaction > rendezvous > thinktime > testcase > request if stepTran, ok := step.(*StepTransaction); ok { // transaction step return r.runStepTransaction(stepTran.step.Transaction) } else if stepRend, ok := step.(*StepRendezvous); ok { // rendezvous step return r.runStepRendezvous(stepRend.step.Rendezvous) + } else if stepThink, ok := step.(*StepThinkTime); ok { + // think time step + return r.runStepThinkTime(stepThink.step, caseConfig.ThinkTime) } log.Info().Str("step", step.Name()).Msg("run step start") @@ -377,6 +380,52 @@ func (r *caseRunner) runStep(index int, caseConfig *TConfig) (stepResult *stepDa return stepResult, err } +func (r *caseRunner) runStepThinkTime(step *TStep, ttc *ThinkTimeConfig) (stepResult *stepData, err error) { + thinkTime := step.ThinkTime + log.Info(). + Str("name", step.Name). + Float64("time", thinkTime.Time). + Msg("think time") + stepResult = &stepData{ + Name: step.Name, + StepType: stepTypeThinkTime, + Success: true, + } + if ttc == nil { + ttc = &ThinkTimeConfig{thinkTimeDefault, nil, 0} + } + var tt time.Duration + switch ttc.Strategy { + case thinkTimeDefault: + tt = time.Duration(thinkTime.Time*1000) * time.Millisecond + case thinkTimeRandomPercentage: + m, ok := ttc.Setting.(map[string]float64) // e.g. {"min_percentage": 0.5, "max_percentage": 1.5} + if !ok { + tt = time.Duration(thinkTime.Time*1000) * time.Millisecond + break + } + res := builtin.GetRandomNumber(int(thinkTime.Time*m["min_percentage"]*1000), int(thinkTime.Time*m["max_percentage"]*1000)) + tt = time.Duration(res) * time.Millisecond + case thinkTimeMultiply: + value, ok := ttc.Setting.(float64) // e.g. 0.5 + if !ok || value <= 0 { + value = thinkTimeDefaultMultiply + } + tt = time.Duration(thinkTime.Time*value*1000) * time.Millisecond + case thinkTimeIgnore: + // nothing to do + } + // no more than limit + if ttc.Limit > 0 { + limit := time.Duration(ttc.Limit*1000) * time.Millisecond + if limit < tt { + tt = limit + } + } + time.Sleep(tt) + return stepResult, nil +} + func (r *caseRunner) runStepTransaction(transaction *Transaction) (stepResult *stepData, err error) { log.Info(). Str("name", transaction.Name). @@ -966,6 +1015,9 @@ func (r *caseRunner) parseConfig(cfg *TConfig) error { } cfg.BaseURL = convertString(parsedBaseURL) + // ensure correction of think time config + cfg.ThinkTime.checkThinkTime() + return nil } diff --git a/runner_test.go b/runner_test.go index 032c3f64..8f3dcc8b 100644 --- a/runner_test.go +++ b/runner_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "testing" + "time" "github.com/rs/zerolog/log" ) @@ -146,6 +147,63 @@ func TestInitRendezvous(t *testing.T) { } } +func TestThinkTime(t *testing.T) { + buildHashicorpPlugin() + defer removeHashicorpPlugin() + + testcases := []*TestCase{ + { + Config: NewConfig("TestCase1"), + TestSteps: []IStep{ + NewStep("thinkTime").SetThinkTime(2), + }, + }, + { + Config: NewConfig("TestCase2"). + SetThinkTime(thinkTimeIgnore, nil, 0), + TestSteps: []IStep{ + NewStep("thinkTime").SetThinkTime(0.5), + }, + }, + { + Config: NewConfig("TestCase3"). + SetThinkTime(thinkTimeRandomPercentage, nil, 0), + TestSteps: []IStep{ + NewStep("thinkTime").SetThinkTime(1), + }, + }, + { + Config: NewConfig("TestCase4"). + SetThinkTime(thinkTimeRandomPercentage, map[string]interface{}{"min_percentage": 2, "max_percentage": 3}, 2.5), + TestSteps: []IStep{ + NewStep("thinkTime").SetThinkTime(1), + }, + }, + { + Config: NewConfig("TestCase5"), + TestSteps: []IStep{ + NewStep("thinkTime").CallRefCase(&demoThinkTimeJsonPath), // think time: 3s, random pct: {"min_percentage":1, "max_percentage":1.5}, limit: 4s + }, + }, + } + expectedMinValue := []float64{2, 0, 0.5, 2, 3} + expectedMaxValue := []float64{2.5, 0.5, 2, 3, 10} + for idx, testcase := range testcases { + r := NewRunner(t) + startTime := time.Now() + err := r.Run(testcase) + if err != nil { + t.Fatalf("run testcase error: %v", err) + } + duration := time.Since(startTime) + minValue := time.Duration(expectedMinValue[idx]*1000) * time.Millisecond + maxValue := time.Duration(expectedMaxValue[idx]*1000) * time.Millisecond + if duration < minValue || duration > maxValue { + t.Fatalf("failed to test think time, expect value: [%v, %v], actual value: %v", minValue, maxValue, duration) + } + } +} + func TestGenHTMLReport(t *testing.T) { summary := newOutSummary() caseSummary1 := newSummary() diff --git a/step.go b/step.go index 570120a1..829e9aad 100644 --- a/step.go +++ b/step.go @@ -40,6 +40,12 @@ func (c *TConfig) WithParameters(parameters map[string]interface{}) *TConfig { return c } +// SetThinkTime sets think time config for current testcase. +func (c *TConfig) SetThinkTime(strategy string, cfg interface{}, limit float64) *TConfig { + c.ThinkTime = &ThinkTimeConfig{strategy, cfg, limit} + return c +} + // ExportVars specifies variable names to export for current testcase. func (c *TConfig) ExportVars(vars ...string) *TConfig { c.Export = vars @@ -193,6 +199,16 @@ func (s *StepRequest) EndTransaction(name string) *StepTransaction { } } +// SetThinkTime sets think time. +func (s *StepRequest) SetThinkTime(time float64) *StepThinkTime { + s.step.ThinkTime = &ThinkTime{ + Time: time, + } + return &StepThinkTime{ + step: s.step, + } +} + // StepRequestWithOptionalArgs implements IStep interface. type StepRequestWithOptionalArgs struct { step *TStep @@ -355,6 +371,23 @@ func (s *StepTestCaseWithOptionalArgs) ToStruct() *TStep { return s.step } +// StepThinkTime implements IStep interface. +type StepThinkTime struct { + step *TStep +} + +func (s *StepThinkTime) Name() string { + return s.step.Name +} + +func (s *StepThinkTime) Type() string { + return "thinktime" +} + +func (s *StepThinkTime) ToStruct() *TStep { + return s.step +} + // StepTransaction implements IStep interface. type StepTransaction struct { step *TStep