Merge pull request #1214 from httprunner/refactor-protocol

- refactor: redesign `IStep` to make step extensible to support implementing new protocols and test types
This commit is contained in:
debugtalk
2022-03-30 17:06:30 +08:00
committed by GitHub
31 changed files with 2712 additions and 2456 deletions

View File

@@ -3,9 +3,8 @@ name: Run scaffold for hrp
on:
push:
branches:
- main
- master
pull_request:
types: [synchronize]
jobs:
scaffold-with-python-plugin:

View File

@@ -3,9 +3,8 @@ name: run smoke tests for httprunner
on:
push:
branches:
- main
- master
pull_request:
types: [synchronize]
jobs:
smoke-test:

View File

@@ -3,9 +3,8 @@ name: Run unittests
on:
push:
branches:
- main
- master
pull_request:
types: [synchronize]
jobs:
py-httprunner:

View File

@@ -11,6 +11,7 @@
- change: integrate [sentry sdk][sentry sdk] for panic reporting and analysis
- change: lock funplugin version when creating scaffold project
- fix: call referenced api/testcase with relative path
- refactor: redesign `IStep` to make step extensible to support implementing new protocols and test types
**python version**

95
docs/dev.md Normal file
View File

@@ -0,0 +1,95 @@
## 核心数据结构
HttpRunner 以 `TestCase` 为核心,将任意测试场景抽象为有序步骤的集合。
```go
type TestCase struct {
Config *TConfig
TestSteps []IStep
}
```
其中,测试步骤 `IStep` 采用了 `go interface` 的设计理念,支持进行任意拓展;步骤内容统一在 `Run` 方法中进行实现。
```go
type IStep interface {
Name() string
Type() StepType
Struct() *TStep
Run(*SessionRunner) (*StepResult, error)
}
```
我们只需遵循 `IStep` 的接口定义,即可实现各种类型的测试步骤类型。当前已支持的步骤类型包括:
- request发起单次 HTTP 请求
- api引用执行其它 API 文件
- testcase引用执行其它测试用例文件
- thinktime思考时间按照配置的逻辑进行等待
- transaction事务机制用于压测
- rendezvous集合点机制用于压测
基于该机制,我们可以扩展支持新的协议类型,例如 HTTP2/WebSocket/RPC 等;同时也可以支持新的测试类型,例如 UI 自动化。甚至我们还可以在一个测试用例中混合调用多种不同的 Step 类型,例如实现 HTTP/RPC/UI 混合场景。
## 运行主流程
### 整体控制器 HRPRunner
执行接口测试时,会初始化一个 `HRPRunner`,用于控制测试的执行策略。
```go
type HRPRunner struct {
t *testing.T
failfast bool
requestsLogOn bool
pluginLogOn bool
saveTests bool
genHTMLReport bool
client *http.Client
}
func (r *HRPRunner) Run(testcases ...ITestCase) error
func (r *HRPRunner) NewSessionRunner(testcase *TestCase) *SessionRunner
```
重点关注两个方法:
- Run测试执行的主入口支持运行一个或多个测试用例
- NewSessionRunner针对给定的测试用例初始化一个 SessionRunner
### 用例执行器 SessionRunner
测试用例的具体执行都由 `SessionRunner` 完成,每个 TestCase 对应一个实例,在该实例中除了包含测试用例自身内容外,还会包含测试过程的 session 数据和最终测试结果 summary。
```go
type SessionRunner struct {
testCase *TestCase
hrpRunner *HRPRunner
parser *Parser
sessionVariables map[string]interface{}
transactions map[string]map[transactionType]time.Time
startTime time.Time // record start time of the testcase
summary *TestCaseSummary // record test case summary
}
```
重点关注一个方法:
- Start启动执行用例依次执行所有测试步骤
```go
func (r *SessionRunner) Start() error {
...
// run step in sequential order
for _, step := range r.testCase.TestSteps {
_, err := step.Run(r)
if err != nil && r.hrpRunner.failfast {
return errors.Wrap(err, "abort running due to failfast setting")
}
}
...
}
```
在主流程中SessionRunner 并不需要关注 step 的具体类型,统一都是调用 `step.Run(r)`,具体实现逻辑都在对应 step 的 `Run(*SessionRunner)` 方法中。

View File

@@ -95,8 +95,8 @@ func (b *HRPBoomer) convertBoomerTask(testcase *TestCase, rendezvousList []*Rend
Name: config.Name,
Weight: config.Weight,
Fn: func() {
runner := hrpRunner.newCaseRunner(testcase)
runner.parser.plugin = plugin
sessionRunner := hrpRunner.NewSessionRunner(testcase)
sessionRunner.parser.plugin = plugin
testcaseSuccess := true // flag whole testcase result
var transactionSuccess = true // flag current transaction result
@@ -115,27 +115,27 @@ func (b *HRPBoomer) convertBoomerTask(testcase *TestCase, rendezvousList []*Rend
}
}
if err := runner.parseConfig(caseConfig); err != nil {
if err := sessionRunner.parseConfig(caseConfig); err != nil {
log.Error().Err(err).Msg("parse config failed")
return
}
startTime := time.Now()
for index, step := range testcase.TestSteps {
stepData, err := runner.runStep(index, caseConfig)
for _, step := range testcase.TestSteps {
stepResult, err := step.Run(sessionRunner)
if err != nil {
// step failed
var elapsed int64
if stepData != nil {
elapsed = stepData.Elapsed
if stepResult != nil {
elapsed = stepResult.Elapsed
}
b.RecordFailure(step.Type(), step.Name(), elapsed, err.Error())
b.RecordFailure(string(step.Type()), step.Name(), elapsed, err.Error())
// update flag
testcaseSuccess = false
transactionSuccess = false
if runner.hrpRunner.failfast {
if hrpRunner.failfast {
log.Error().Msg("abort running due to failfast setting")
break
}
@@ -144,28 +144,27 @@ func (b *HRPBoomer) convertBoomerTask(testcase *TestCase, rendezvousList []*Rend
}
// step success
if stepData.StepType == stepTypeTransaction {
if stepResult.StepType == stepTypeTransaction {
// transaction
// FIXME: support nested transactions
if step.ToStruct().Transaction.Type == transactionEnd { // only record when transaction ends
b.RecordTransaction(stepData.Name, transactionSuccess, stepData.Elapsed, 0)
if step.Struct().Transaction.Type == transactionEnd { // only record when transaction ends
b.RecordTransaction(stepResult.Name, transactionSuccess, stepResult.Elapsed, 0)
transactionSuccess = true // reset flag for next transaction
}
} else if stepData.StepType == stepTypeRendezvous {
} else if stepResult.StepType == stepTypeRendezvous {
// rendezvous
// TODO: implement rendezvous in boomer
} else if stepData.StepType == stepTypeThinkTime {
} else if stepResult.StepType == stepTypeThinkTime {
// think time
// no record required
} else {
// request or testcase step
b.RecordSuccess(step.Type(), step.Name(), stepData.Elapsed, stepData.ContentSize)
b.RecordSuccess(string(step.Type()), step.Name(), stepResult.Elapsed, stepResult.ContentSize)
}
}
endTime := time.Now()
// report duration for transaction without end
for name, transaction := range runner.transactions {
for name, transaction := range sessionRunner.transactions {
if len(transaction) == 1 {
// if transaction end time not exists, use testcase end time instead
duration := endTime.Sub(transaction[transactionStart])

216
hrp/config.go Normal file
View File

@@ -0,0 +1,216 @@
package hrp
import (
"math/rand"
"reflect"
"sync"
"time"
"github.com/httprunner/httprunner/hrp/internal/builtin"
)
// NewConfig returns a new constructed testcase config with specified testcase name.
func NewConfig(name string) *TConfig {
return &TConfig{
Name: name,
Variables: make(map[string]interface{}),
}
}
// TConfig represents config data structure for testcase.
// Each testcase should contain one config part.
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"`
ThinkTimeSetting *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
}
// WithVariables sets variables for current testcase.
func (c *TConfig) WithVariables(variables map[string]interface{}) *TConfig {
c.Variables = variables
return c
}
// SetBaseURL sets base URL for current testcase.
func (c *TConfig) SetBaseURL(baseURL string) *TConfig {
c.BaseURL = baseURL
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
return c
}
// WithParameters sets parameters for current testcase.
func (c *TConfig) WithParameters(parameters map[string]interface{}) *TConfig {
c.Parameters = parameters
return c
}
// SetThinkTime sets think time config for current testcase.
func (c *TConfig) SetThinkTime(strategy thinkTimeStrategy, cfg interface{}, limit float64) *TConfig {
c.ThinkTimeSetting = &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
return c
}
// SetWeight sets weight for current testcase, which is used in load testing.
func (c *TConfig) SetWeight(weight int) *TConfig {
c.Weight = weight
return c
}
type ThinkTimeConfig struct {
Strategy thinkTimeStrategy `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
}
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 thinkTimeStrategy string
const (
thinkTimeDefault thinkTimeStrategy = "default" // as recorded
thinkTimeRandomPercentage thinkTimeStrategy = "random_percentage" // use random percentage of recorded think time
thinkTimeMultiply thinkTimeStrategy = "multiply" // multiply recorded think time
thinkTimeIgnore thinkTimeStrategy = "ignore" // ignore recorded think time
)
const (
thinkTimeDefaultMultiply = 1
)
var (
thinkTimeDefaultRandom = map[string]float64{"min_percentage": 0.5, "max_percentage": 1.5}
)
type TParamsConfig struct {
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"` // 保存参数的迭代器
}
type Iterator struct {
sync.Mutex
data iteratorParamsType
strategy iteratorStrategyType // random, sequential
iteration int
index int
}
type iteratorStrategyType string
const (
strategyRandom iteratorStrategyType = "random"
strategySequential iteratorStrategyType = "Sequential"
)
type iteratorParamsType []map[string]interface{}
func (params iteratorParamsType) Iterator() *Iterator {
return &Iterator{
data: params,
iteration: len(params),
index: 0,
}
}
func (iter *Iterator) HasNext() bool {
if iter.iteration == -1 {
return true
}
return iter.index < iter.iteration
}
func (iter *Iterator) Next() (value map[string]interface{}) {
iter.Lock()
defer iter.Unlock()
if len(iter.data) == 0 {
iter.index++
return map[string]interface{}{}
}
if iter.strategy == strategyRandom {
randSource := rand.New(rand.NewSource(time.Now().Unix()))
randIndex := randSource.Intn(len(iter.data))
value = iter.data[randIndex]
} else {
value = iter.data[iter.index%len(iter.data)]
}
iter.index++
return value
}

View File

@@ -1,33 +0,0 @@
package hrp
import "fmt"
// StepRequestExtraction implements IStep interface.
type StepRequestExtraction struct {
step *TStep
}
// WithJmesPath sets the JMESPath expression to extract from the response.
func (s *StepRequestExtraction) WithJmesPath(jmesPath string, varName string) *StepRequestExtraction {
s.step.Extract[varName] = jmesPath
return s
}
// Validate switches to step validation.
func (s *StepRequestExtraction) Validate() *StepRequestValidation {
return &StepRequestValidation{
step: s.step,
}
}
func (s *StepRequestExtraction) Name() string {
return s.step.Name
}
func (s *StepRequestExtraction) Type() string {
return fmt.Sprintf("request-%v", s.step.Request.Method)
}
func (s *StepRequestExtraction) ToStruct() *TStep {
return s.step
}

View File

@@ -177,7 +177,7 @@ type tStep struct {
}
func (s *tStep) makeRequestMethod(entry *Entry) error {
s.Request.Method = entry.Request.Method
s.Request.Method = hrp.HTTPMethod(entry.Request.Method)
return nil
}

View File

@@ -1,462 +0,0 @@
package hrp
import (
"fmt"
"math/rand"
"reflect"
"runtime"
"sync"
"time"
"github.com/httprunner/httprunner/hrp/internal/builtin"
"github.com/httprunner/httprunner/hrp/internal/version"
)
const (
httpGET string = "GET"
httpHEAD string = "HEAD"
httpPOST string = "POST"
httpPUT string = "PUT"
httpDELETE string = "DELETE"
httpOPTIONS string = "OPTIONS"
httpPATCH string = "PATCH"
)
// TConfig represents config data structure for testcase.
// Each testcase should contain one config part.
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"`
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"` // map[string]string、string
Iteration int `json:"iteration,omitempty" yaml:"iteration,omitempty"`
Iterators []*Iterator `json:"parameterIterator,omitempty" yaml:"parameterIterator,omitempty"` // 保存参数的迭代器
}
const (
strategyRandom string = "random"
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 {
sync.Mutex
data paramsType
strategy string // random, sequential
iteration int
index int
}
func (params paramsType) Iterator() *Iterator {
return &Iterator{
data: params,
iteration: len(params),
index: 0,
}
}
func (iter *Iterator) HasNext() bool {
if iter.iteration == -1 {
return true
}
return iter.index < iter.iteration
}
func (iter *Iterator) Next() (value map[string]interface{}) {
iter.Lock()
defer iter.Unlock()
if len(iter.data) == 0 {
iter.index++
return map[string]interface{}{}
}
if iter.strategy == strategyRandom {
randSource := rand.New(rand.NewSource(time.Now().Unix()))
randIndex := randSource.Intn(len(iter.data))
value = iter.data[randIndex]
} else {
value = iter.data[iter.index%len(iter.data)]
}
iter.index++
return value
}
// Request represents HTTP request data structure.
// This is used for teststep.
type Request struct {
Method string `json:"method" yaml:"method"` // required
URL string `json:"url" yaml:"url"` // required
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 float32 `json:"timeout,omitempty" yaml:"timeout,omitempty"`
AllowRedirects bool `json:"allow_redirects,omitempty" yaml:"allow_redirects,omitempty"`
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"`
Path string
}
func (api *API) GetPath() string {
return api.Path
}
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
Assert string `json:"assert" yaml:"assert"`
Expect interface{} `json:"expect" yaml:"expect"`
Message string `json:"msg,omitempty" yaml:"msg,omitempty"` // optional
}
// IAPI represents interface for api,
// includes API and APIPath.
type IAPI interface {
GetPath() string
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"`
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"`
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
const (
stepTypeRequest stepType = "request"
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 (
transactionStart transactionType = "start"
transactionEnd transactionType = "end"
)
type Transaction struct {
Name string `json:"name" yaml:"name"`
Type transactionType `json:"type" yaml:"type"`
}
const (
defaultRendezvousTimeout int64 = 5000
defaultRendezvousPercent float32 = 1.0
)
type Rendezvous struct {
Name string `json:"name" yaml:"name"` // required
Percent float32 `json:"percent,omitempty" yaml:"percent,omitempty"` // default to 1(100%)
Number int64 `json:"number,omitempty" yaml:"number,omitempty"`
Timeout int64 `json:"timeout,omitempty" yaml:"timeout,omitempty"` // milliseconds
cnt int64
releasedFlag uint32
spawnDoneFlag uint32
wg sync.WaitGroup
timerResetChan chan struct{}
activateChan chan struct{}
releaseChan chan struct{}
once *sync.Once
lock sync.Mutex
}
// TCase represents testcase data structure.
// Each testcase includes one public config and several sequential teststeps.
type TCase struct {
Config *TConfig `json:"config" yaml:"config"`
TestSteps []*TStep `json:"teststeps" yaml:"teststeps"`
}
// IStep represents interface for all types for teststeps, includes:
// StepRequest, StepRequestWithOptionalArgs, StepRequestValidation, StepRequestExtraction,
// StepTestCaseWithOptionalArgs,
// StepTransaction, StepRendezvous.
type IStep interface {
Name() string
Type() string
ToStruct() *TStep
}
// ITestCase represents interface for testcases,
// includes TestCase and TestCasePath.
type ITestCase interface {
GetPath() string
ToTestCase() (*TestCase, error)
}
// TestCase is a container for one testcase, which is used for testcase runner.
// TestCase implements ITestCase interface.
type TestCase struct {
Config *TConfig
TestSteps []IStep
}
func (tc *TestCase) GetPath() string {
return tc.Config.Path
}
func (tc *TestCase) ToTestCase() (*TestCase, error) {
return tc, nil
}
func (tc *TestCase) ToTCase() *TCase {
tCase := &TCase{
Config: tc.Config,
}
for _, step := range tc.TestSteps {
tCase.TestSteps = append(tCase.TestSteps, step.ToStruct())
}
return tCase
}
type testCaseStat struct {
Total int `json:"total" yaml:"total"`
Success int `json:"success" yaml:"success"`
Fail int `json:"fail" yaml:"fail"`
}
type testStepStat struct {
Total int `json:"total" yaml:"total"`
Successes int `json:"successes" yaml:"successes"`
Failures int `json:"failures" yaml:"failures"`
}
type stat struct {
TestCases testCaseStat `json:"testcases" yaml:"test_cases"`
TestSteps testStepStat `json:"teststeps" yaml:"test_steps"`
}
type testCaseTime struct {
StartAt time.Time `json:"start_at,omitempty" yaml:"start_at,omitempty"`
Duration float64 `json:"duration,omitempty" yaml:"duration,omitempty"`
}
type platform struct {
HttprunnerVersion string `json:"httprunner_version" yaml:"httprunner_version"`
GoVersion string `json:"go_version" yaml:"go_version"`
Platform string `json:"platform" yaml:"platform"`
}
// Summary stores tests summary for current task execution, maybe include one or multiple testcases
type Summary struct {
Success bool `json:"success" yaml:"success"`
Stat *stat `json:"stat" yaml:"stat"`
Time *testCaseTime `json:"time" yaml:"time"`
Platform *platform `json:"platform" yaml:"platform"`
Details []*testCaseSummary `json:"details" yaml:"details"`
}
func newOutSummary() *Summary {
platForm := &platform{
HttprunnerVersion: version.VERSION,
GoVersion: runtime.Version(),
Platform: fmt.Sprintf("%v-%v", runtime.GOOS, runtime.GOARCH),
}
return &Summary{
Success: true,
Stat: &stat{},
Time: &testCaseTime{
StartAt: time.Now(),
},
Platform: platForm,
}
}
func (s *Summary) appendCaseSummary(caseSummary *testCaseSummary) {
s.Success = s.Success && caseSummary.Success
s.Stat.TestCases.Total += 1
s.Stat.TestSteps.Total += len(caseSummary.Records)
if caseSummary.Success {
s.Stat.TestCases.Success += 1
} else {
s.Stat.TestCases.Fail += 1
}
s.Stat.TestSteps.Successes += caseSummary.Stat.Successes
s.Stat.TestSteps.Failures += caseSummary.Stat.Failures
s.Details = append(s.Details, caseSummary)
s.Success = s.Success && caseSummary.Success
}
type stepData struct {
Name string `json:"name" yaml:"name"` // step name
StepType stepType `json:"step_type" yaml:"step_type"` // step type, testcase/request/transaction/rendezvous
Success bool `json:"success" yaml:"success"` // step execution result
Elapsed int64 `json:"elapsed_ms" yaml:"elapsed_ms"` // step execution time in millisecond(ms)
Data interface{} `json:"data,omitempty" yaml:"data,omitempty"` // session data or slice of step data
ContentSize int64 `json:"content_size" yaml:"content_size"` // response body length
ExportVars map[string]interface{} `json:"export_vars,omitempty" yaml:"export_vars,omitempty"` // extract variables
Attachment string `json:"attachment,omitempty" yaml:"attachment,omitempty"` // step error information
}
type testCaseInOut struct {
ConfigVars map[string]interface{} `json:"config_vars" yaml:"config_vars"`
ExportVars map[string]interface{} `json:"export_vars" yaml:"export_vars"`
}
// testCaseSummary stores tests summary for one testcase
type testCaseSummary struct {
Name string `json:"name" yaml:"name"`
Success bool `json:"success" yaml:"success"`
CaseId string `json:"case_id,omitempty" yaml:"case_id,omitempty"` // TODO
Stat *testStepStat `json:"stat" yaml:"stat"`
Time *testCaseTime `json:"time" yaml:"time"`
InOut *testCaseInOut `json:"in_out" yaml:"in_out"`
Log string `json:"log,omitempty" yaml:"log,omitempty"` // TODO
Records []*stepData `json:"records" yaml:"records"`
}
type validationResult struct {
Validator
CheckValue interface{} `json:"check_value" yaml:"check_value"`
CheckResult string `json:"check_result" yaml:"check_result"`
}
type reqResps struct {
Request interface{} `json:"request" yaml:"request"`
Response interface{} `json:"response" yaml:"response"`
}
type address struct {
ClientIP string `json:"client_ip,omitempty" yaml:"client_ip,omitempty"`
ClientPort string `json:"client_port,omitempty" yaml:"client_port,omitempty"`
ServerIP string `json:"server_ip,omitempty" yaml:"server_ip,omitempty"`
ServerPort string `json:"server_port,omitempty" yaml:"server_port,omitempty"`
}
type SessionData struct {
Success bool `json:"success" yaml:"success"`
ReqResps *reqResps `json:"req_resps" yaml:"req_resps"`
Address *address `json:"address,omitempty" yaml:"address,omitempty"` // TODO
Validators []*validationResult `json:"validators,omitempty" yaml:"validators,omitempty"`
}
func newSessionData() *SessionData {
return &SessionData{
Success: false,
ReqResps: &reqResps{},
}
}

View File

@@ -17,11 +17,11 @@ import (
"github.com/httprunner/httprunner/hrp/internal/builtin"
)
func newParser() *parser {
return &parser{}
func newParser() *Parser {
return &Parser{}
}
type parser struct {
type Parser struct {
plugin funplugin.IPlugin // plugin is used to call functions
}
@@ -42,9 +42,9 @@ func buildURL(baseURL, stepURL string) string {
return uStep.String()
}
func (p *parser) parseHeaders(rawHeaders map[string]string, variablesMapping map[string]interface{}) (map[string]string, error) {
func (p *Parser) ParseHeaders(rawHeaders map[string]string, variablesMapping map[string]interface{}) (map[string]string, error) {
parsedHeaders := make(map[string]string)
headers, err := p.parseData(rawHeaders, variablesMapping)
headers, err := p.Parse(rawHeaders, variablesMapping)
if err != nil {
return rawHeaders, err
}
@@ -64,7 +64,7 @@ func convertString(raw interface{}) string {
}
}
func (p *parser) parseData(raw interface{}, variablesMapping map[string]interface{}) (interface{}, error) {
func (p *Parser) Parse(raw interface{}, variablesMapping map[string]interface{}) (interface{}, error) {
rawValue := reflect.ValueOf(raw)
switch rawValue.Kind() {
case reflect.String:
@@ -75,11 +75,11 @@ func (p *parser) parseData(raw interface{}, variablesMapping map[string]interfac
// other string
value := rawValue.String()
value = strings.TrimSpace(value)
return p.parseString(value, variablesMapping)
return p.ParseString(value, variablesMapping)
case reflect.Slice:
parsedSlice := make([]interface{}, rawValue.Len())
for i := 0; i < rawValue.Len(); i++ {
parsedValue, err := p.parseData(rawValue.Index(i).Interface(), variablesMapping)
parsedValue, err := p.Parse(rawValue.Index(i).Interface(), variablesMapping)
if err != nil {
return raw, err
}
@@ -89,12 +89,12 @@ func (p *parser) parseData(raw interface{}, variablesMapping map[string]interfac
case reflect.Map: // convert any map to map[string]interface{}
parsedMap := make(map[string]interface{})
for _, k := range rawValue.MapKeys() {
parsedKey, err := p.parseString(k.String(), variablesMapping)
parsedKey, err := p.ParseString(k.String(), variablesMapping)
if err != nil {
return raw, err
}
v := rawValue.MapIndex(k)
parsedValue, err := p.parseData(v.Interface(), variablesMapping)
parsedValue, err := p.Parse(v.Interface(), variablesMapping)
if err != nil {
return raw, err
}
@@ -131,8 +131,8 @@ var (
regexCompileNumber = regexp.MustCompile(regexNumber) // parse number
)
// parseString parse string with variables
func (p *parser) parseString(raw string, variablesMapping map[string]interface{}) (interface{}, error) {
// ParseString parse string with variables
func (p *Parser) ParseString(raw string, variablesMapping map[string]interface{}) (interface{}, error) {
matchStartPosition := 0
parsedString := ""
remainedString := raw
@@ -171,12 +171,12 @@ func (p *parser) parseString(raw string, variablesMapping map[string]interface{}
if err != nil {
return raw, err
}
parsedArgs, err := p.parseData(arguments, variablesMapping)
parsedArgs, err := p.Parse(arguments, variablesMapping)
if err != nil {
return raw, err
}
result, err := p.callFunc(funcName, parsedArgs.([]interface{})...)
result, err := p.CallFunc(funcName, parsedArgs.([]interface{})...)
if err != nil {
log.Error().Str("funcName", funcName).Interface("arguments", arguments).
Err(err).Msg("call function failed")
@@ -237,9 +237,9 @@ func (p *parser) parseString(raw string, variablesMapping map[string]interface{}
return parsedString, nil
}
// callFunc calls function with arguments
// CallFunc calls function with arguments
// only support return at most one result value
func (p *parser) callFunc(funcName string, arguments ...interface{}) (interface{}, error) {
func (p *Parser) CallFunc(funcName string, arguments ...interface{}) (interface{}, error) {
// call with plugin function
if p.plugin != nil && p.plugin.Has(funcName) {
return p.plugin.Call(funcName, arguments...)
@@ -342,38 +342,6 @@ func mergeSlices(slice, overriddenSlice []string) []string {
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
@@ -420,7 +388,7 @@ func parseFunctionArguments(argsStr string) ([]interface{}, error) {
return arguments, nil
}
func (p *parser) parseVariables(variables map[string]interface{}) (map[string]interface{}, error) {
func (p *Parser) ParseVariables(variables map[string]interface{}) (map[string]interface{}, error) {
parsedVariables := make(map[string]interface{})
var traverseRounds int
@@ -458,7 +426,7 @@ func (p *parser) parseVariables(variables map[string]interface{}) (map[string]in
return variables, fmt.Errorf("variable not defined: %v", undefinedVars)
}
parsedValue, err := p.parseData(varValue, parsedVariables)
parsedValue, err := p.Parse(varValue, parsedVariables)
if err != nil {
continue
}
@@ -553,18 +521,18 @@ func findallVariables(raw string) variableSet {
return varSet
}
func genCartesianProduct(paramsMap map[string]paramsType) paramsType {
func genCartesianProduct(paramsMap map[string]iteratorParamsType) iteratorParamsType {
if len(paramsMap) == 0 {
return nil
}
var params []paramsType
var params []iteratorParamsType
for _, v := range paramsMap {
params = append(params, v)
}
var cartesianProduct paramsType
var cartesianProduct iteratorParamsType
cartesianProduct = params[0]
for i := 0; i < len(params)-1; i++ {
var tempProduct paramsType
var tempProduct iteratorParamsType
for _, param1 := range cartesianProduct {
for _, param2 := range params[i+1] {
tempProduct = append(tempProduct, mergeVariables(param1, param2))
@@ -575,20 +543,20 @@ func genCartesianProduct(paramsMap map[string]paramsType) paramsType {
return cartesianProduct
}
func parseParameters(parameters map[string]interface{}, variablesMapping map[string]interface{}) (map[string]paramsType, error) {
func parseParameters(parameters map[string]interface{}, variablesMapping map[string]interface{}) (map[string]iteratorParamsType, error) {
if len(parameters) == 0 {
return nil, nil
}
parsedParametersSlice := make(map[string]paramsType)
parsedParametersSlice := make(map[string]iteratorParamsType)
var err error
for k, v := range parameters {
var parameterSlice paramsType
var parameterSlice iteratorParamsType
rawValue := reflect.ValueOf(v)
switch rawValue.Kind() {
case reflect.String:
// e.g. username-password: ${parameterize(examples/hrp/account.csv)} -> [{"username": "test1", "password": "111111"}, {"username": "test2", "password": "222222"}]
var parsedParameterContent interface{}
parsedParameterContent, err = newParser().parseString(rawValue.String(), variablesMapping)
parsedParameterContent, err = newParser().ParseString(rawValue.String(), variablesMapping)
if err != nil {
log.Error().Interface("parameterContent", rawValue).Msg("[parseParameters] parse parameter content error")
return nil, err
@@ -662,7 +630,7 @@ func parseSlice(parameterName string, parameterContent interface{}) ([]map[strin
}
func initParameterIterator(cfg *TConfig, mode string) (err error) {
var parameters map[string]paramsType
var parameters map[string]iteratorParamsType
parameters, err = parseParameters(cfg.Parameters, cfg.Variables)
if err != nil {
return err
@@ -684,7 +652,7 @@ func initParameterIterator(cfg *TConfig, mode string) (err error) {
// use strategy if configured
cfg.ParametersSetting.Iterators = append(
cfg.ParametersSetting.Iterators,
newIterator(v, rawValue.MapIndex(reflect.ValueOf(k)).Interface().(string), cfg.ParametersSetting.Iteration),
newIterator(v, rawValue.MapIndex(reflect.ValueOf(k)).Interface().(iteratorStrategyType), cfg.ParametersSetting.Iteration),
)
} else {
// use sequential strategy by default
@@ -703,20 +671,20 @@ func initParameterIterator(cfg *TConfig, mode string) (err error) {
}
cfg.ParametersSetting.Iterators = append(
cfg.ParametersSetting.Iterators,
newIterator(genCartesianProduct(parameters), cfg.ParametersSetting.Strategy.(string), cfg.ParametersSetting.Iteration),
newIterator(genCartesianProduct(parameters), cfg.ParametersSetting.Strategy.(iteratorStrategyType), cfg.ParametersSetting.Iteration),
)
default:
// default strategy: sequential, 仅生成一个的迭代器该迭代器在参数笛卡尔积slice中顺序选取元素
cfg.ParametersSetting.Strategy = strategySequential
cfg.ParametersSetting.Iterators = append(
cfg.ParametersSetting.Iterators,
newIterator(genCartesianProduct(parameters), cfg.ParametersSetting.Strategy.(string), cfg.ParametersSetting.Iteration),
newIterator(genCartesianProduct(parameters), cfg.ParametersSetting.Strategy.(iteratorStrategyType), cfg.ParametersSetting.Iteration),
)
}
return nil
}
func newIterator(parameters paramsType, strategy string, iteration int) *Iterator {
func newIterator(parameters iteratorParamsType, strategy iteratorStrategyType, iteration int) *Iterator {
iter := parameters.Iterator()
iter.strategy = strategy
if iteration > 0 {

View File

@@ -163,7 +163,7 @@ func TestParseDataStringWithVariables(t *testing.T) {
parser := newParser()
for _, data := range testData {
parsedData, err := parser.parseData(data.expr, variablesMapping)
parsedData, err := parser.Parse(data.expr, variablesMapping)
if !assert.NoError(t, err) {
t.Fail()
}
@@ -188,7 +188,7 @@ func TestParseDataStringWithUndefinedVariables(t *testing.T) {
parser := newParser()
for _, data := range testData {
parsedData, err := parser.parseData(data.expr, variablesMapping)
parsedData, err := parser.Parse(data.expr, variablesMapping)
if !assert.Error(t, err) {
t.Fail()
}
@@ -233,7 +233,7 @@ func TestParseDataStringWithVariablesAbnormal(t *testing.T) {
parser := newParser()
for _, data := range testData {
parsedData, err := parser.parseData(data.expr, variablesMapping)
parsedData, err := parser.Parse(data.expr, variablesMapping)
if !assert.NoError(t, err) {
t.Fail()
}
@@ -264,7 +264,7 @@ func TestParseDataMapWithVariables(t *testing.T) {
parser := newParser()
for _, data := range testData {
parsedData, err := parser.parseData(data.expr, variablesMapping)
parsedData, err := parser.Parse(data.expr, variablesMapping)
if !assert.NoError(t, err) {
t.Fail()
}
@@ -298,7 +298,7 @@ func TestParseHeaders(t *testing.T) {
parser := newParser()
for _, data := range testData {
parsedHeaders, err := parser.parseHeaders(data.rawHeaders, variablesMapping)
parsedHeaders, err := parser.ParseHeaders(data.rawHeaders, variablesMapping)
if !assert.NoError(t, err) {
t.Fail()
}
@@ -444,14 +444,14 @@ func TestCallBuiltinFunction(t *testing.T) {
parser := newParser()
// call function without arguments
_, err := parser.callFunc("get_timestamp")
_, err := parser.CallFunc("get_timestamp")
if !assert.NoError(t, err) {
t.Fail()
}
// call function with one argument
timeStart := time.Now()
_, err = parser.callFunc("sleep", 1)
_, err = parser.CallFunc("sleep", 1)
if !assert.NoError(t, err) {
t.Fail()
}
@@ -460,7 +460,7 @@ func TestCallBuiltinFunction(t *testing.T) {
}
// call function with one argument
result, err := parser.callFunc("gen_random_string", 10)
result, err := parser.CallFunc("gen_random_string", 10)
if !assert.NoError(t, err) {
t.Fail()
}
@@ -469,7 +469,7 @@ func TestCallBuiltinFunction(t *testing.T) {
}
// call function with two argument
result, err = parser.callFunc("max", float64(10), 9.99)
result, err = parser.CallFunc("max", float64(10), 9.99)
if !assert.NoError(t, err) {
t.Fail()
}
@@ -556,7 +556,7 @@ func TestParseDataStringWithFunctions(t *testing.T) {
parser := newParser()
for _, data := range testData1 {
value, err := parser.parseData(data.expr, variablesMapping)
value, err := parser.Parse(data.expr, variablesMapping)
if !assert.NoError(t, err) {
t.Fail()
}
@@ -575,7 +575,7 @@ func TestParseDataStringWithFunctions(t *testing.T) {
}
for _, data := range testData2 {
value, err := parser.parseData(data.expr, variablesMapping)
value, err := parser.Parse(data.expr, variablesMapping)
if !assert.NoError(t, err) {
t.Fail()
}
@@ -623,7 +623,7 @@ func TestParseVariables(t *testing.T) {
parser := newParser()
for _, data := range testData {
value, err := parser.parseVariables(data.rawVars)
value, err := parser.ParseVariables(data.rawVars)
if !assert.NoError(t, err) {
t.Fail()
}
@@ -654,7 +654,7 @@ func TestParseVariablesAbnormal(t *testing.T) {
parser := newParser()
for _, data := range testData {
value, err := parser.parseVariables(data.rawVars)
value, err := parser.ParseVariables(data.rawVars)
if !assert.Error(t, err) {
t.Fail()
}

View File

@@ -115,3 +115,16 @@ func locateFile(startPath string, destFile string) (string, error) {
return locateFile(parentDir, destFile)
}
func getProjectRootDirPath(path string) (rootDir string, err error) {
pluginPath, err := locatePlugin(path)
if err == nil {
rootDir = filepath.Dir(pluginPath)
return
}
// failed to locate project root dir
// maybe project plugin debugtalk.xx is not exist
// use current dir instead
return os.Getwd()
}

View File

@@ -18,7 +18,7 @@ import (
"github.com/httprunner/httprunner/hrp/internal/json"
)
func newResponseObject(t *testing.T, parser *parser, resp *http.Response) (*responseObject, error) {
func newResponseObject(t *testing.T, parser *Parser, resp *http.Response) (*responseObject, error) {
// prepare response headers
headers := make(map[string]string)
for k, v := range resp.Header {
@@ -82,9 +82,9 @@ type respObjMeta struct {
type responseObject struct {
t *testing.T
parser *parser
parser *Parser
respObjMeta interface{}
validationResults []*validationResult
validationResults []*ValidationResult
}
const textExtractorSubRegexp string = `(.*)`
@@ -126,7 +126,7 @@ func (v *responseObject) Validate(iValidators []interface{}, variablesMapping ma
var checkValue interface{}
if strings.Contains(checkItem, "$") {
// reference variable
checkValue, err = v.parser.parseData(checkItem, variablesMapping)
checkValue, err = v.parser.Parse(checkItem, variablesMapping)
if err != nil {
return err
}
@@ -143,11 +143,11 @@ func (v *responseObject) Validate(iValidators []interface{}, variablesMapping ma
}
// parse expected value
expectValue, err := v.parser.parseData(validator.Expect, variablesMapping)
expectValue, err := v.parser.Parse(validator.Expect, variablesMapping)
if err != nil {
return err
}
validResult := &validationResult{
validResult := &ValidationResult{
Validator: Validator{
Check: validator.Check,
Expect: expectValue,

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,14 @@
package hrp
import (
"math"
"os"
"os/exec"
"testing"
"time"
"github.com/httprunner/httprunner/hrp/internal/scaffold"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/assert"
"github.com/httprunner/httprunner/hrp/internal/scaffold"
)
func buildHashicorpGoPlugin() {
@@ -97,71 +95,6 @@ func assertRunTestCases(t *testing.T) {
}
}
func TestRunCaseWithRendezvous(t *testing.T) {
rendezvousBoundaryTestcase := &TestCase{
Config: NewConfig("run request with functions").
SetBaseURL("https://postman-echo.com").
WithVariables(map[string]interface{}{
"n": 5,
"a": 12.3,
"b": 3.45,
}),
TestSteps: []IStep{
NewStep("test negative number").
Rendezvous("test negative number").
WithUserNumber(-1),
NewStep("test overflow number").
Rendezvous("test overflow number").
WithUserNumber(1000000),
NewStep("test negative percent").
Rendezvous("test very low percent").
WithUserPercent(-0.5),
NewStep("test very low percent").
Rendezvous("test very low percent").
WithUserPercent(0.00001),
NewStep("test overflow percent").
Rendezvous("test overflow percent").
WithUserPercent(1.5),
NewStep("test conflict params").
Rendezvous("test conflict params").
WithUserNumber(1).
WithUserPercent(0.123),
NewStep("test negative timeout").
Rendezvous("test negative timeout").
WithTimeout(-1000),
},
}
type rendezvousParam struct {
number int64
percent float32
timeout int64
}
expectedRendezvousParams := []rendezvousParam{
{number: 100, percent: 1, timeout: 5000},
{number: 100, percent: 1, timeout: 5000},
{number: 100, percent: 1, timeout: 5000},
{number: 0, percent: 0.00001, timeout: 5000},
{number: 100, percent: 1, timeout: 5000},
{number: 100, percent: 1, timeout: 5000},
{number: 100, percent: 1, timeout: 5000},
}
rendezvousList := initRendezvous(rendezvousBoundaryTestcase, 100)
for i, r := range rendezvousList {
if r.Number != expectedRendezvousParams[i].number {
t.Fatalf("run rendezvous %v error: expected number: %v, real number: %v", r.Name, expectedRendezvousParams[i].number, r.Number)
}
if math.Abs(float64(r.Percent-expectedRendezvousParams[i].percent)) > 0.001 {
t.Fatalf("run rendezvous %v error: expected percent: %v, real percent: %v", r.Name, expectedRendezvousParams[i].percent, r.Percent)
}
if r.Timeout != expectedRendezvousParams[i].timeout {
t.Fatalf("run rendezvous %v error: expected timeout: %v, real timeout: %v", r.Name, expectedRendezvousParams[i].timeout, r.Timeout)
}
}
}
func TestRunCaseWithThinkTime(t *testing.T) {
buildHashicorpGoPlugin()
defer removeHashicorpGoPlugin()
@@ -220,27 +153,6 @@ func TestRunCaseWithThinkTime(t *testing.T) {
}
}
func TestGenHTMLReport(t *testing.T) {
summary := newOutSummary()
caseSummary1 := newSummary()
caseSummary2 := newSummary()
stepResult1 := &stepData{}
stepResult2 := &stepData{
Name: "Test",
StepType: stepTypeRequest,
Success: false,
ContentSize: 0,
Attachment: "err",
}
caseSummary1.Records = []*stepData{stepResult1, stepResult2, nil}
summary.appendCaseSummary(caseSummary1)
summary.appendCaseSummary(caseSummary2)
err := summary.genHTMLReport()
if err != nil {
t.Error(err)
}
}
func TestRunCaseWithPluginJSON(t *testing.T) {
buildHashicorpGoPlugin()
defer removeHashicorpGoPlugin()

159
hrp/session.go Normal file
View File

@@ -0,0 +1,159 @@
package hrp
import (
_ "embed"
"time"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// SessionRunner is used to run testcase and its steps.
// each testcase has its own SessionRunner instance and share session variables.
type SessionRunner struct {
testCase *TestCase
hrpRunner *HRPRunner
parser *Parser
sessionVariables map[string]interface{}
// transactions stores transaction timing info.
// key is transaction name, value is map of transaction type and time, e.g. start time and end time.
transactions map[string]map[transactionType]time.Time
startTime time.Time // record start time of the testcase
summary *TestCaseSummary // record test case summary
}
func (r *SessionRunner) init() {
log.Info().Msg("init session runner")
r.sessionVariables = make(map[string]interface{})
r.transactions = make(map[string]map[transactionType]time.Time)
r.startTime = time.Now()
r.summary.Name = r.testCase.Config.Name
}
func (r *SessionRunner) GetParser() *Parser {
return r.parser
}
func (r *SessionRunner) GetConfig() *TConfig {
return r.testCase.Config
}
func (r *SessionRunner) LogOn() bool {
return r.hrpRunner.requestsLogOn
}
// Start runs the test steps in sequential order.
func (r *SessionRunner) Start() error {
config := r.testCase.Config
log.Info().Str("testcase", config.Name).Msg("run testcase start")
// init session runner
r.init()
// init plugin
var err error
if r.parser.plugin, err = initPlugin(config.Path, r.hrpRunner.pluginLogOn); err != nil {
return err
}
defer func() {
if r.parser.plugin != nil {
r.parser.plugin.Quit()
}
}()
// parse config
if err := r.parseConfig(config); err != nil {
return err
}
r.startTime = time.Now()
// run step in sequential order
for _, step := range r.testCase.TestSteps {
_, err := step.Run(r)
if err != nil && r.hrpRunner.failfast {
return errors.Wrap(err, "abort running due to failfast setting")
}
}
log.Info().Str("testcase", config.Name).Msg("run testcase end")
return nil
}
func (r *SessionRunner) UpdateSession(vars map[string]interface{}) {
for k, v := range vars {
r.sessionVariables[k] = v
}
}
// UpdateSummary appends step result to summary
func (r *SessionRunner) UpdateSummary(stepResult *StepResult) {
r.summary.Records = append(r.summary.Records, stepResult)
r.summary.Stat.Total += 1
if stepResult.Success {
r.summary.Stat.Successes += 1
} else {
r.summary.Stat.Failures += 1
// update summary result to failed
r.summary.Success = false
}
}
// MergeStepVariables merges step variables with config variables and session variables
func (r *SessionRunner) MergeStepVariables(vars map[string]interface{}) (map[string]interface{}, error) {
// override variables
// step variables > session variables (extracted variables from previous steps)
overrideVars := mergeVariables(vars, r.sessionVariables)
// step variables > testcase config variables
overrideVars = mergeVariables(overrideVars, r.testCase.Config.Variables)
// parse step variables
parsedVariables, err := r.parser.ParseVariables(overrideVars)
if err != nil {
log.Error().Interface("variables", r.testCase.Config.Variables).
Err(err).Msg("parse step variables failed")
return nil, err
}
return parsedVariables, nil
}
func (r *SessionRunner) parseConfig(cfg *TConfig) error {
// parse config variables
parsedVariables, err := r.parser.ParseVariables(cfg.Variables)
if err != nil {
log.Error().Interface("variables", cfg.Variables).Err(err).Msg("parse config variables failed")
return err
}
cfg.Variables = parsedVariables
// parse config name
parsedName, err := r.parser.ParseString(cfg.Name, cfg.Variables)
if err != nil {
return err
}
cfg.Name = convertString(parsedName)
// parse config base url
parsedBaseURL, err := r.parser.ParseString(cfg.BaseURL, cfg.Variables)
if err != nil {
return err
}
cfg.BaseURL = convertString(parsedBaseURL)
// ensure correction of think time config
cfg.ThinkTimeSetting.checkThinkTime()
return nil
}
func (r *SessionRunner) GetSummary() *TestCaseSummary {
caseSummary := r.summary
caseSummary.Time.StartAt = r.startTime
caseSummary.Time.Duration = time.Since(r.startTime).Seconds()
exportVars := make(map[string]interface{})
for _, value := range r.testCase.Config.Export {
exportVars[value] = r.sessionVariables[value]
}
caseSummary.InOut.ExportVars = exportVars
caseSummary.InOut.ConfigVars = r.testCase.Config.Variables
return caseSummary
}

View File

@@ -1,480 +1,52 @@
package hrp
import (
"fmt"
"os"
type StepType string
"github.com/rs/zerolog/log"
const (
stepTypeRequest StepType = "request"
stepTypeAPI StepType = "api"
stepTypeTestCase StepType = "testcase"
stepTypeTransaction StepType = "transaction"
stepTypeRendezvous StepType = "rendezvous"
stepTypeThinkTime StepType = "thinktime"
)
// NewConfig returns a new constructed testcase config with specified testcase name.
func NewConfig(name string) *TConfig {
return &TConfig{
Name: name,
Variables: make(map[string]interface{}),
}
}
// WithVariables sets variables for current testcase.
func (c *TConfig) WithVariables(variables map[string]interface{}) *TConfig {
c.Variables = variables
return c
}
// SetBaseURL sets base URL for current testcase.
func (c *TConfig) SetBaseURL(baseURL string) *TConfig {
c.BaseURL = baseURL
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
return c
}
// WithParameters sets parameters for current testcase.
func (c *TConfig) WithParameters(parameters map[string]interface{}) *TConfig {
c.Parameters = parameters
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
return c
}
// SetWeight sets weight for current testcase, which is used in load testing.
func (c *TConfig) SetWeight(weight int) *TConfig {
c.Weight = weight
return c
}
// NewStep returns a new constructed teststep with specified step name.
func NewStep(name string) *StepRequest {
return &StepRequest{
step: &TStep{
Name: name,
Variables: make(map[string]interface{}),
},
}
}
type StepRequest struct {
step *TStep
}
// WithVariables sets variables for current teststep.
func (s *StepRequest) WithVariables(variables map[string]interface{}) *StepRequest {
s.step.Variables = variables
return s
}
// SetupHook adds a setup hook for current teststep.
func (s *StepRequest) SetupHook(hook string) *StepRequest {
s.step.SetupHooks = append(s.step.SetupHooks, hook)
return s
}
// GET makes a HTTP GET request.
func (s *StepRequest) GET(url string) *StepRequestWithOptionalArgs {
s.step.Request = &Request{
Method: httpGET,
URL: url,
}
return &StepRequestWithOptionalArgs{
step: s.step,
}
}
// HEAD makes a HTTP HEAD request.
func (s *StepRequest) HEAD(url string) *StepRequestWithOptionalArgs {
s.step.Request = &Request{
Method: httpHEAD,
URL: url,
}
return &StepRequestWithOptionalArgs{
step: s.step,
}
}
// POST makes a HTTP POST request.
func (s *StepRequest) POST(url string) *StepRequestWithOptionalArgs {
s.step.Request = &Request{
Method: httpPOST,
URL: url,
}
return &StepRequestWithOptionalArgs{
step: s.step,
}
}
// PUT makes a HTTP PUT request.
func (s *StepRequest) PUT(url string) *StepRequestWithOptionalArgs {
s.step.Request = &Request{
Method: httpPUT,
URL: url,
}
return &StepRequestWithOptionalArgs{
step: s.step,
}
}
// DELETE makes a HTTP DELETE request.
func (s *StepRequest) DELETE(url string) *StepRequestWithOptionalArgs {
s.step.Request = &Request{
Method: httpDELETE,
URL: url,
}
return &StepRequestWithOptionalArgs{
step: s.step,
}
}
// OPTIONS makes a HTTP OPTIONS request.
func (s *StepRequest) OPTIONS(url string) *StepRequestWithOptionalArgs {
s.step.Request = &Request{
Method: httpOPTIONS,
URL: url,
}
return &StepRequestWithOptionalArgs{
step: s.step,
}
}
// PATCH makes a HTTP PATCH request.
func (s *StepRequest) PATCH(url string) *StepRequestWithOptionalArgs {
s.step.Request = &Request{
Method: httpPATCH,
URL: url,
}
return &StepRequestWithOptionalArgs{
step: s.step,
}
}
// CallRefCase calls a referenced testcase.
func (s *StepRequest) CallRefCase(tc ITestCase) *StepTestCaseWithOptionalArgs {
var err error
s.step.TestCase, err = tc.ToTestCase()
if err != nil {
log.Error().Err(err).Msg("failed to load testcase")
os.Exit(1)
}
return &StepTestCaseWithOptionalArgs{
step: s.step,
}
}
// CallRefAPI calls a referenced api.
func (s *StepRequest) CallRefAPI(api IAPI) *StepAPIWithOptionalArgs {
var err error
s.step.API, err = api.ToAPI()
if err != nil {
log.Error().Err(err).Msg("failed to load api")
os.Exit(1)
}
return &StepAPIWithOptionalArgs{
step: s.step,
}
}
// StartTransaction starts a transaction.
func (s *StepRequest) StartTransaction(name string) *StepTransaction {
s.step.Transaction = &Transaction{
Name: name,
Type: transactionStart,
}
return &StepTransaction{
step: s.step,
}
}
// EndTransaction ends a transaction.
func (s *StepRequest) EndTransaction(name string) *StepTransaction {
s.step.Transaction = &Transaction{
Name: name,
Type: transactionEnd,
}
return &StepTransaction{
step: s.step,
}
}
// 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
}
// SetVerify sets whether to verify SSL for current HTTP request.
func (s *StepRequestWithOptionalArgs) SetVerify(verify bool) *StepRequestWithOptionalArgs {
s.step.Request.Verify = verify
return s
}
// SetTimeout sets timeout for current HTTP request.
func (s *StepRequestWithOptionalArgs) SetTimeout(timeout float32) *StepRequestWithOptionalArgs {
s.step.Request.Timeout = timeout
return s
}
// SetProxies sets proxies for current HTTP request.
func (s *StepRequestWithOptionalArgs) SetProxies(proxies map[string]string) *StepRequestWithOptionalArgs {
// TODO
return s
}
// SetAllowRedirects sets whether to allow redirects for current HTTP request.
func (s *StepRequestWithOptionalArgs) SetAllowRedirects(allowRedirects bool) *StepRequestWithOptionalArgs {
s.step.Request.AllowRedirects = allowRedirects
return s
}
// SetAuth sets auth for current HTTP request.
func (s *StepRequestWithOptionalArgs) SetAuth(auth map[string]string) *StepRequestWithOptionalArgs {
// TODO
return s
}
// WithParams sets HTTP request params for current step.
func (s *StepRequestWithOptionalArgs) WithParams(params map[string]interface{}) *StepRequestWithOptionalArgs {
s.step.Request.Params = params
return s
}
// WithHeaders sets HTTP request headers for current step.
func (s *StepRequestWithOptionalArgs) WithHeaders(headers map[string]string) *StepRequestWithOptionalArgs {
s.step.Request.Headers = headers
return s
}
// WithCookies sets HTTP request cookies for current step.
func (s *StepRequestWithOptionalArgs) WithCookies(cookies map[string]string) *StepRequestWithOptionalArgs {
s.step.Request.Cookies = cookies
return s
}
// WithBody sets HTTP request body for current step.
func (s *StepRequestWithOptionalArgs) WithBody(body interface{}) *StepRequestWithOptionalArgs {
s.step.Request.Body = body
return s
}
// TeardownHook adds a teardown hook for current teststep.
func (s *StepRequestWithOptionalArgs) TeardownHook(hook string) *StepRequestWithOptionalArgs {
s.step.TeardownHooks = append(s.step.TeardownHooks, hook)
return s
}
// Validate switches to step validation.
func (s *StepRequestWithOptionalArgs) Validate() *StepRequestValidation {
return &StepRequestValidation{
step: s.step,
}
}
// Extract switches to step extraction.
func (s *StepRequestWithOptionalArgs) Extract() *StepRequestExtraction {
s.step.Extract = make(map[string]string)
return &StepRequestExtraction{
step: s.step,
}
}
func (s *StepRequestWithOptionalArgs) Name() string {
if s.step.Name != "" {
return s.step.Name
}
return fmt.Sprintf("%s %s", s.step.Request.Method, s.step.Request.URL)
}
func (s *StepRequestWithOptionalArgs) Type() string {
return fmt.Sprintf("request-%v", s.step.Request.Method)
}
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, ok := s.step.API.(*API)
if ok {
s.step.Export = append(api.Export, names...)
}
return s
}
func (s *StepAPIWithOptionalArgs) Name() string {
if s.step.Name != "" {
return s.step.Name
}
api, ok := s.step.API.(*API)
if ok {
return api.Name
}
return ""
}
func (s *StepAPIWithOptionalArgs) Type() string {
return "api"
}
func (s *StepAPIWithOptionalArgs) ToStruct() *TStep {
return s.step
}
// StepTestCaseWithOptionalArgs implements IStep interface.
type StepTestCaseWithOptionalArgs struct {
step *TStep
}
// TeardownHook adds a teardown hook for current teststep.
func (s *StepTestCaseWithOptionalArgs) TeardownHook(hook string) *StepTestCaseWithOptionalArgs {
s.step.TeardownHooks = append(s.step.TeardownHooks, hook)
return s
}
// Export specifies variable names to export from referenced testcase for current step.
func (s *StepTestCaseWithOptionalArgs) Export(names ...string) *StepTestCaseWithOptionalArgs {
s.step.Export = append(s.step.Export, names...)
return s
}
func (s *StepTestCaseWithOptionalArgs) Name() string {
if s.step.Name != "" {
return s.step.Name
}
ts, ok := s.step.TestCase.(*TestCase)
if ok {
return ts.Config.Name
}
return ""
}
func (s *StepTestCaseWithOptionalArgs) Type() string {
return "testcase"
}
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
}
func (s *StepTransaction) Name() string {
if s.step.Name != "" {
return s.step.Name
}
return fmt.Sprintf("transaction %s %s", s.step.Transaction.Name, s.step.Transaction.Type)
}
func (s *StepTransaction) Type() string {
return "transaction"
}
func (s *StepTransaction) ToStruct() *TStep {
return s.step
}
// StepRendezvous implements IStep interface.
type StepRendezvous struct {
step *TStep
}
func (s *StepRendezvous) Name() string {
if s.step.Name != "" {
return s.step.Name
}
return s.step.Rendezvous.Name
}
func (s *StepRendezvous) Type() string {
return "rendezvous"
}
func (s *StepRendezvous) ToStruct() *TStep {
return s.step
}
// Rendezvous creates a new rendezvous
func (s *StepRequest) Rendezvous(name string) *StepRendezvous {
s.step.Rendezvous = &Rendezvous{
Name: name,
}
return &StepRendezvous{
step: s.step,
}
}
// WithUserNumber sets the user number needed to release the current rendezvous
func (s *StepRendezvous) WithUserNumber(number int64) *StepRendezvous {
s.step.Rendezvous.Number = number
return s
}
// WithUserPercent sets the user percent needed to release the current rendezvous
func (s *StepRendezvous) WithUserPercent(percent float32) *StepRendezvous {
s.step.Rendezvous.Percent = percent
return s
}
// WithTimeout sets the timeout of duration between each user arriving at the current rendezvous
func (s *StepRendezvous) WithTimeout(timeout int64) *StepRendezvous {
s.step.Rendezvous.Timeout = timeout
return s
type StepResult struct {
Name string `json:"name" yaml:"name"` // step name
StepType StepType `json:"step_type" yaml:"step_type"` // step type, testcase/request/transaction/rendezvous
Success bool `json:"success" yaml:"success"` // step execution result
Elapsed int64 `json:"elapsed_ms" yaml:"elapsed_ms"` // step execution time in millisecond(ms)
Data interface{} `json:"data,omitempty" yaml:"data,omitempty"` // session data or slice of step data
ContentSize int64 `json:"content_size" yaml:"content_size"` // response body length
ExportVars map[string]interface{} `json:"export_vars,omitempty" yaml:"export_vars,omitempty"` // extract variables
Attachment string `json:"attachment,omitempty" yaml:"attachment,omitempty"` // step error information
}
// TStep represents teststep data structure.
// Each step maybe three different types: make one request or reference another api/testcase.
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"`
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"`
}
// IStep represents interface for all types for teststeps, includes:
// StepRequest, StepRequestWithOptionalArgs, StepRequestValidation, StepRequestExtraction,
// StepTestCaseWithOptionalArgs,
// StepTransaction, StepRendezvous.
type IStep interface {
Name() string
Type() StepType
Struct() *TStep
Run(*SessionRunner) (*StepResult, error)
}

129
hrp/step_api.go Normal file
View File

@@ -0,0 +1,129 @@
package hrp
import (
"fmt"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/hrp/internal/builtin"
)
// IAPI represents interface for api,
// includes API and APIPath.
type IAPI interface {
GetPath() string
ToAPI() (*API, error)
}
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"`
Path string
}
func (api *API) GetPath() string {
return api.Path
}
func (api *API) ToAPI() (*API, error) {
return api, nil
}
// APIPath implements IAPI interface.
type APIPath string
func (path *APIPath) GetPath() string {
return fmt.Sprintf("%v", *path)
}
func (path *APIPath) ToAPI() (*API, error) {
api := &API{}
apiPath := path.GetPath()
err := builtin.LoadFile(apiPath, api)
if err != nil {
return nil, err
}
err = convertCompatValidator(api.Validators)
return api, err
}
// 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, ok := s.step.API.(*API)
if ok {
s.step.Export = append(api.Export, names...)
}
return s
}
func (s *StepAPIWithOptionalArgs) Name() string {
if s.step.Name != "" {
return s.step.Name
}
api, ok := s.step.API.(*API)
if ok {
return api.Name
}
return ""
}
func (s *StepAPIWithOptionalArgs) Type() StepType {
return stepTypeAPI
}
func (s *StepAPIWithOptionalArgs) Struct() *TStep {
return s.step
}
func (s *StepAPIWithOptionalArgs) Run(r *SessionRunner) (*StepResult, error) {
log.Info().Str("api", s.step.Name).Msg("run referenced api")
// extend request with referenced API
api, _ := s.step.API.(*API)
extendWithAPI(s.step, api)
stepResult, err := runStepRequest(r, s.step)
if err != nil {
r.summary.Success = false
return nil, err
}
stepResult.StepType = stepTypeAPI
return stepResult, nil
}
// 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)
}

259
hrp/step_rendezvous.go Normal file
View File

@@ -0,0 +1,259 @@
package hrp
import (
"sync"
"sync/atomic"
"time"
"github.com/rs/zerolog/log"
)
// StepRendezvous implements IStep interface.
type StepRendezvous struct {
step *TStep
}
func (s *StepRendezvous) Name() string {
if s.step.Name != "" {
return s.step.Name
}
return s.step.Rendezvous.Name
}
func (s *StepRendezvous) Type() StepType {
return stepTypeRendezvous
}
func (s *StepRendezvous) Struct() *TStep {
return s.step
}
func (s *StepRendezvous) Run(r *SessionRunner) (*StepResult, error) {
rendezvous := s.step.Rendezvous
log.Info().
Str("name", rendezvous.Name).
Float32("percent", rendezvous.Percent).
Int64("number", rendezvous.Number).
Int64("timeout", rendezvous.Timeout).
Msg("rendezvous")
stepResult := &StepResult{
Name: rendezvous.Name,
StepType: stepTypeRendezvous,
Success: true,
}
// pass current rendezvous if already released, activate rendezvous sequentially after spawn done
if rendezvous.isReleased() || !isPreRendezvousAllReleased(rendezvous, r.testCase.ToTCase()) || !rendezvous.isSpawnDone() {
return stepResult, nil
}
// activate the rendezvous only once during each cycle
rendezvous.once.Do(func() {
close(rendezvous.activateChan)
})
// check current cnt using double check lock before updating to avoid negative WaitGroup counter
if atomic.LoadInt64(&rendezvous.cnt) < rendezvous.Number {
rendezvous.lock.Lock()
if atomic.LoadInt64(&rendezvous.cnt) < rendezvous.Number {
atomic.AddInt64(&rendezvous.cnt, 1)
rendezvous.wg.Done()
rendezvous.timerResetChan <- struct{}{}
}
rendezvous.lock.Unlock()
}
// block until current rendezvous released
<-rendezvous.releaseChan
return stepResult, nil
}
func isPreRendezvousAllReleased(rendezvous *Rendezvous, testCase *TCase) bool {
for _, step := range testCase.TestSteps {
preRendezvous := step.Rendezvous
if preRendezvous == nil {
continue
}
// meet current rendezvous, all previous rendezvous released, return true
if preRendezvous == rendezvous {
return true
}
if !preRendezvous.isReleased() {
return false
}
}
return true
}
// Rendezvous creates a new rendezvous
func (s *StepRequest) Rendezvous(name string) *StepRendezvous {
s.step.Rendezvous = &Rendezvous{
Name: name,
}
return &StepRendezvous{
step: s.step,
}
}
// WithUserNumber sets the user number needed to release the current rendezvous
func (s *StepRendezvous) WithUserNumber(number int64) *StepRendezvous {
s.step.Rendezvous.Number = number
return s
}
// WithUserPercent sets the user percent needed to release the current rendezvous
func (s *StepRendezvous) WithUserPercent(percent float32) *StepRendezvous {
s.step.Rendezvous.Percent = percent
return s
}
// WithTimeout sets the timeout of duration between each user arriving at the current rendezvous
func (s *StepRendezvous) WithTimeout(timeout int64) *StepRendezvous {
s.step.Rendezvous.Timeout = timeout
return s
}
const (
defaultRendezvousTimeout int64 = 5000
defaultRendezvousPercent float32 = 1.0
)
type Rendezvous struct {
Name string `json:"name" yaml:"name"` // required
Percent float32 `json:"percent,omitempty" yaml:"percent,omitempty"` // default to 1(100%)
Number int64 `json:"number,omitempty" yaml:"number,omitempty"`
Timeout int64 `json:"timeout,omitempty" yaml:"timeout,omitempty"` // milliseconds
cnt int64
releasedFlag uint32
spawnDoneFlag uint32
wg sync.WaitGroup
timerResetChan chan struct{}
activateChan chan struct{}
releaseChan chan struct{}
once *sync.Once
lock sync.Mutex
}
func (r *Rendezvous) reset() {
r.cnt = 0
r.releasedFlag = 0
r.wg.Add(int(r.Number))
// timerResetChan channel will not be closed, thus init only once
if r.timerResetChan == nil {
r.timerResetChan = make(chan struct{})
}
r.activateChan = make(chan struct{})
r.releaseChan = make(chan struct{})
r.once = new(sync.Once)
}
func (r *Rendezvous) isSpawnDone() bool {
return atomic.LoadUint32(&r.spawnDoneFlag) == 1
}
func (r *Rendezvous) setSpawnDone() {
atomic.StoreUint32(&r.spawnDoneFlag, 1)
}
func (r *Rendezvous) isReleased() bool {
return atomic.LoadUint32(&r.releasedFlag) == 1
}
func (r *Rendezvous) setReleased() {
atomic.StoreUint32(&r.releasedFlag, 1)
}
func initRendezvous(testcase *TestCase, total int64) []*Rendezvous {
tCase := testcase.ToTCase()
var rendezvousList []*Rendezvous
for _, step := range tCase.TestSteps {
if step.Rendezvous == nil {
continue
}
rendezvous := step.Rendezvous
// either number or percent should be correctly put, otherwise set to default (total)
if rendezvous.Number == 0 && rendezvous.Percent > 0 && rendezvous.Percent <= defaultRendezvousPercent {
rendezvous.Number = int64(rendezvous.Percent * float32(total))
} else if rendezvous.Number > 0 && rendezvous.Number <= total && rendezvous.Percent == 0 {
rendezvous.Percent = float32(rendezvous.Number) / float32(total)
} else {
log.Warn().
Str("name", rendezvous.Name).
Int64("default number", total).
Float32("default percent", defaultRendezvousPercent).
Msg("rendezvous parameter not defined or error, set to default value")
rendezvous.Number = total
rendezvous.Percent = defaultRendezvousPercent
}
if rendezvous.Timeout <= 0 {
rendezvous.Timeout = defaultRendezvousTimeout
}
rendezvous.reset()
rendezvousList = append(rendezvousList, rendezvous)
}
return rendezvousList
}
func waitRendezvous(rendezvousList []*Rendezvous) {
if rendezvousList != nil {
lastRendezvous := rendezvousList[len(rendezvousList)-1]
for _, rendezvous := range rendezvousList {
go waitSingleRendezvous(rendezvous, rendezvousList, lastRendezvous)
}
}
}
func waitSingleRendezvous(rendezvous *Rendezvous, rendezvousList []*Rendezvous, lastRendezvous *Rendezvous) {
for {
// cycle start: block current checking until current rendezvous activated
<-rendezvous.activateChan
stop := make(chan struct{})
timeout := time.Duration(rendezvous.Timeout) * time.Millisecond
timer := time.NewTimer(timeout)
go func() {
defer close(stop)
rendezvous.wg.Wait()
}()
for !rendezvous.isReleased() {
select {
case <-rendezvous.timerResetChan:
timer.Reset(timeout)
case <-stop:
rendezvous.setReleased()
close(rendezvous.releaseChan)
log.Info().
Str("name", rendezvous.Name).
Float32("percent", rendezvous.Percent).
Int64("number", rendezvous.Number).
Int64("timeout(ms)", rendezvous.Timeout).
Int64("cnt", rendezvous.cnt).
Str("reason", "rendezvous release condition satisfied").
Msg("rendezvous released")
case <-timer.C:
rendezvous.setReleased()
close(rendezvous.releaseChan)
log.Info().
Str("name", rendezvous.Name).
Float32("percent", rendezvous.Percent).
Int64("number", rendezvous.Number).
Int64("timeout(ms)", rendezvous.Timeout).
Int64("cnt", rendezvous.cnt).
Str("reason", "time's up").
Msg("rendezvous released")
}
}
// cycle end: reset all previous rendezvous after last rendezvous released
// otherwise, block current checker until the last rendezvous end
if rendezvous == lastRendezvous {
for _, r := range rendezvousList {
r.reset()
}
} else {
<-lastRendezvous.releaseChan
}
}
}

View File

@@ -0,0 +1,71 @@
package hrp
import (
"math"
"testing"
)
func TestRunCaseWithRendezvous(t *testing.T) {
rendezvousBoundaryTestcase := &TestCase{
Config: NewConfig("run request with functions").
SetBaseURL("https://postman-echo.com").
WithVariables(map[string]interface{}{
"n": 5,
"a": 12.3,
"b": 3.45,
}),
TestSteps: []IStep{
NewStep("test negative number").
Rendezvous("test negative number").
WithUserNumber(-1),
NewStep("test overflow number").
Rendezvous("test overflow number").
WithUserNumber(1000000),
NewStep("test negative percent").
Rendezvous("test very low percent").
WithUserPercent(-0.5),
NewStep("test very low percent").
Rendezvous("test very low percent").
WithUserPercent(0.00001),
NewStep("test overflow percent").
Rendezvous("test overflow percent").
WithUserPercent(1.5),
NewStep("test conflict params").
Rendezvous("test conflict params").
WithUserNumber(1).
WithUserPercent(0.123),
NewStep("test negative timeout").
Rendezvous("test negative timeout").
WithTimeout(-1000),
},
}
type rendezvousParam struct {
number int64
percent float32
timeout int64
}
expectedRendezvousParams := []rendezvousParam{
{number: 100, percent: 1, timeout: 5000},
{number: 100, percent: 1, timeout: 5000},
{number: 100, percent: 1, timeout: 5000},
{number: 0, percent: 0.00001, timeout: 5000},
{number: 100, percent: 1, timeout: 5000},
{number: 100, percent: 1, timeout: 5000},
{number: 100, percent: 1, timeout: 5000},
}
rendezvousList := initRendezvous(rendezvousBoundaryTestcase, 100)
for i, r := range rendezvousList {
if r.Number != expectedRendezvousParams[i].number {
t.Fatalf("run rendezvous %v error: expected number: %v, real number: %v", r.Name, expectedRendezvousParams[i].number, r.Number)
}
if math.Abs(float64(r.Percent-expectedRendezvousParams[i].percent)) > 0.001 {
t.Fatalf("run rendezvous %v error: expected percent: %v, real percent: %v", r.Name, expectedRendezvousParams[i].percent, r.Percent)
}
if r.Timeout != expectedRendezvousParams[i].timeout {
t.Fatalf("run rendezvous %v error: expected timeout: %v, real timeout: %v", r.Name, expectedRendezvousParams[i].timeout, r.Timeout)
}
}
}

976
hrp/step_request.go Normal file
View File

@@ -0,0 +1,976 @@
package hrp
import (
"bytes"
"compress/gzip"
"compress/zlib"
"fmt"
"io"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/andybalholm/brotli"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/hrp/internal/builtin"
"github.com/httprunner/httprunner/hrp/internal/json"
)
type HTTPMethod string
const (
httpGET HTTPMethod = "GET"
httpHEAD HTTPMethod = "HEAD"
httpPOST HTTPMethod = "POST"
httpPUT HTTPMethod = "PUT"
httpDELETE HTTPMethod = "DELETE"
httpOPTIONS HTTPMethod = "OPTIONS"
httpPATCH HTTPMethod = "PATCH"
)
// Request represents HTTP request data structure.
// This is used for teststep.
type Request struct {
Method HTTPMethod `json:"method" yaml:"method"` // required
URL string `json:"url" yaml:"url"` // required
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 float32 `json:"timeout,omitempty" yaml:"timeout,omitempty"`
AllowRedirects bool `json:"allow_redirects,omitempty" yaml:"allow_redirects,omitempty"`
Verify bool `json:"verify,omitempty" yaml:"verify,omitempty"`
}
func newRequestBuilder(parser *Parser, config *TConfig, stepRequest *Request) *requestBuilder {
// convert request struct to map
jsonRequest, _ := json.Marshal(stepRequest)
var requestMap map[string]interface{}
_ = json.Unmarshal(jsonRequest, &requestMap)
return &requestBuilder{
stepRequest: stepRequest,
req: &http.Request{
Header: make(http.Header),
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
},
config: config,
parser: parser,
requestMap: requestMap,
}
}
type requestBuilder struct {
stepRequest *Request
req *http.Request
parser *Parser
config *TConfig
requestMap map[string]interface{}
}
func (r *requestBuilder) prepareHeaders(stepVariables map[string]interface{}) error {
// prepare request headers
stepHeaders := r.stepRequest.Headers
if r.config.Headers != nil {
// override headers
stepHeaders = mergeMap(stepHeaders, r.config.Headers)
}
if len(stepHeaders) > 0 {
headers, err := r.parser.ParseHeaders(stepHeaders, stepVariables)
if err != nil {
return errors.Wrap(err, "parse headers failed")
}
for key, value := range headers {
// omit pseudo header names for HTTP/1, e.g. :authority, :method, :path, :scheme
if strings.HasPrefix(key, ":") {
continue
}
r.req.Header.Add(key, value)
// prepare content length
if strings.EqualFold(key, "Content-Length") && value != "" {
if l, err := strconv.ParseInt(value, 10, 64); err == nil {
r.req.ContentLength = l
}
}
}
}
// prepare request cookies
for cookieName, cookieValue := range r.stepRequest.Cookies {
value, err := r.parser.Parse(cookieValue, stepVariables)
if err != nil {
return errors.Wrap(err, "parse cookie value failed")
}
r.req.AddCookie(&http.Cookie{
Name: cookieName,
Value: fmt.Sprintf("%v", value),
})
}
// update header
headers := make(map[string]string)
for key, value := range r.req.Header {
headers[key] = value[0]
}
r.requestMap["headers"] = headers
return nil
}
func (r *requestBuilder) prepareUrlParams(stepVariables map[string]interface{}) error {
// parse step request url
requestUrl, err := r.parser.ParseString(r.stepRequest.URL, stepVariables)
if err != nil {
log.Error().Err(err).Msg("parse request url failed")
return err
}
rawUrl := buildURL(r.config.BaseURL, convertString(requestUrl))
// prepare request params
var queryParams url.Values
if len(r.stepRequest.Params) > 0 {
params, err := r.parser.Parse(r.stepRequest.Params, stepVariables)
if err != nil {
return errors.Wrap(err, "parse request params failed")
}
parsedParams := params.(map[string]interface{})
r.requestMap["params"] = parsedParams
if len(parsedParams) > 0 {
queryParams = make(url.Values)
for k, v := range parsedParams {
queryParams.Add(k, fmt.Sprint(v))
}
}
}
if queryParams != nil {
// append params to url
paramStr := queryParams.Encode()
if strings.IndexByte(rawUrl, '?') == -1 {
rawUrl = rawUrl + "?" + paramStr
} else {
rawUrl = rawUrl + "&" + paramStr
}
}
// prepare url
u, err := url.Parse(rawUrl)
if err != nil {
return errors.Wrap(err, "parse url failed")
}
r.req.URL = u
r.req.Host = u.Host
return nil
}
func (r *requestBuilder) prepareBody(stepVariables map[string]interface{}) error {
// prepare request body
if r.stepRequest.Body == nil {
return nil
}
data, err := r.parser.Parse(r.stepRequest.Body, stepVariables)
if err != nil {
return err
}
// check request body format if Content-Type specified as application/json
if strings.HasPrefix(r.req.Header.Get("Content-Type"), "application/json") {
switch data.(type) {
case bool, float64, string, map[string]interface{}, []interface{}, nil:
break
default:
return errors.Errorf("request body type inconsistent with Content-Type: %v",
r.req.Header.Get("Content-Type"))
}
}
r.requestMap["body"] = data
var dataBytes []byte
switch vv := data.(type) {
case map[string]interface{}:
contentType := r.req.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") {
// post form data
formData := make(url.Values)
for k, v := range vv {
formData.Add(k, fmt.Sprint(v))
}
dataBytes = []byte(formData.Encode())
} else {
// post json
dataBytes, err = json.Marshal(vv)
if err != nil {
return err
}
if contentType == "" {
r.req.Header.Set("Content-Type", "application/json; charset=utf-8")
}
}
case []interface{}:
contentType := r.req.Header.Get("Content-Type")
// post json
dataBytes, err = json.Marshal(vv)
if err != nil {
return err
}
if contentType == "" {
r.req.Header.Set("Content-Type", "application/json; charset=utf-8")
}
case string:
dataBytes = []byte(vv)
case []byte:
dataBytes = vv
case bytes.Buffer:
dataBytes = vv.Bytes()
default: // unexpected body type
return errors.New("unexpected request body type")
}
r.req.Body = io.NopCloser(bytes.NewReader(dataBytes))
r.req.ContentLength = int64(len(dataBytes))
return nil
}
func runStepRequest(r *SessionRunner, step *TStep) (stepResult *StepResult, err error) {
log.Info().Str("step", step.Name).Msg("run step start")
stepResult = &StepResult{
Name: step.Name,
StepType: stepTypeRequest,
Success: false,
ContentSize: 0,
}
defer func() {
// update testcase summary
if err != nil {
log.Error().Err(err).Msg("run request step failed")
stepResult.Attachment = err.Error()
} else {
// update extracted variables
r.UpdateSession(stepResult.ExportVars)
log.Info().
Str("step", step.Name).
Bool("success", stepResult.Success).
Interface("exportVars", stepResult.ExportVars).
Msg("run step end")
}
r.UpdateSummary(stepResult)
}()
sessionData := newSessionData()
parser := r.GetParser()
config := r.GetConfig()
rb := newRequestBuilder(parser, config, step.Request)
rb.req.Method = string(step.Request.Method)
// override step variables
stepVariables, err := r.MergeStepVariables(step.Variables)
if err != nil {
return
}
err = rb.prepareUrlParams(stepVariables)
if err != nil {
return
}
err = rb.prepareHeaders(stepVariables)
if err != nil {
return
}
err = rb.prepareBody(stepVariables)
if err != nil {
return
}
// add request object to step variables, could be used in setup hooks
stepVariables["hrp_step_name"] = step.Name
stepVariables["hrp_step_request"] = rb.requestMap
// deal with setup hooks
for _, setupHook := range step.SetupHooks {
_, err = parser.Parse(setupHook, stepVariables)
if err != nil {
return stepResult, errors.Wrap(err, "run setup hooks failed")
}
}
// log & print request
if r.LogOn() {
if err := printRequest(rb.req); err != nil {
return stepResult, err
}
}
// do request action
start := time.Now()
resp, err := r.hrpRunner.client.Do(rb.req)
stepResult.Elapsed = time.Since(start).Milliseconds()
if err != nil {
return stepResult, errors.Wrap(err, "do request failed")
}
defer resp.Body.Close()
// decode response body in br/gzip/deflate formats
err = decodeResponseBody(resp)
if err != nil {
return stepResult, errors.Wrap(err, "decode response body failed")
}
// log & print response
if r.LogOn() {
if err := printResponse(resp); err != nil {
return stepResult, err
}
}
// new response object
respObj, err := newResponseObject(r.hrpRunner.t, parser, resp)
if err != nil {
err = errors.Wrap(err, "init ResponseObject error")
return
}
// add response object to step variables, could be used in teardown hooks
stepVariables["hrp_step_response"] = respObj.respObjMeta
// deal with teardown hooks
for _, teardownHook := range step.TeardownHooks {
_, err = parser.Parse(teardownHook, stepVariables)
if err != nil {
return stepResult, errors.Wrap(err, "run teardown hooks failed")
}
}
sessionData.ReqResps.Request = rb.requestMap
sessionData.ReqResps.Response = builtin.FormatResponse(respObj.respObjMeta)
// extract variables from response
extractors := step.Extract
extractMapping := respObj.Extract(extractors)
stepResult.ExportVars = extractMapping
// override step variables with extracted variables
stepVariables = mergeVariables(stepVariables, extractMapping)
// validate response
err = respObj.Validate(step.Validators, stepVariables)
sessionData.Validators = respObj.validationResults
if err == nil {
sessionData.Success = true
stepResult.Success = true
}
stepResult.ContentSize = resp.ContentLength
stepResult.Data = sessionData
return stepResult, err
}
func printRequest(req *http.Request) error {
reqContentType := req.Header.Get("Content-Type")
printBody := shouldPrintBody(reqContentType)
reqDump, err := httputil.DumpRequest(req, printBody)
if err != nil {
return errors.Wrap(err, "dump request failed")
}
fmt.Println("-------------------- request --------------------")
reqContent := string(reqDump)
if req.Body != nil && !printBody {
reqContent += fmt.Sprintf("(request body omitted for Content-Type: %v)", reqContentType)
}
fmt.Println(reqContent)
return nil
}
func printResponse(resp *http.Response) error {
fmt.Println("==================== response ===================")
respContentType := resp.Header.Get("Content-Type")
printBody := shouldPrintBody(respContentType)
respDump, err := httputil.DumpResponse(resp, printBody)
if err != nil {
return errors.Wrap(err, "dump response failed")
}
respContent := string(respDump)
if !printBody {
respContent += fmt.Sprintf("(response body omitted for Content-Type: %v)", respContentType)
}
fmt.Println(respContent)
fmt.Println("--------------------------------------------------")
return nil
}
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":
resp.Body, err = gzip.NewReader(resp.Body)
if err != nil {
return err
}
resp.ContentLength = -1 // set to unknown to avoid Content-Length mismatched
case "deflate":
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
}
// shouldPrintBody return true if the Content-Type is printable
// including text/*, application/json, application/xml, application/www-form-urlencoded
func shouldPrintBody(contentType string) bool {
if strings.HasPrefix(contentType, "text/") {
return true
}
if strings.HasPrefix(contentType, "application/json") {
return true
}
if strings.HasPrefix(contentType, "application/xml") {
return true
}
if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") {
return true
}
return false
}
// NewStep returns a new constructed teststep with specified step name.
func NewStep(name string) *StepRequest {
return &StepRequest{
step: &TStep{
Name: name,
Variables: make(map[string]interface{}),
},
}
}
type StepRequest struct {
step *TStep
}
// WithVariables sets variables for current teststep.
func (s *StepRequest) WithVariables(variables map[string]interface{}) *StepRequest {
s.step.Variables = variables
return s
}
// SetupHook adds a setup hook for current teststep.
func (s *StepRequest) SetupHook(hook string) *StepRequest {
s.step.SetupHooks = append(s.step.SetupHooks, hook)
return s
}
// GET makes a HTTP GET request.
func (s *StepRequest) GET(url string) *StepRequestWithOptionalArgs {
s.step.Request = &Request{
Method: httpGET,
URL: url,
}
return &StepRequestWithOptionalArgs{
step: s.step,
}
}
// HEAD makes a HTTP HEAD request.
func (s *StepRequest) HEAD(url string) *StepRequestWithOptionalArgs {
s.step.Request = &Request{
Method: httpHEAD,
URL: url,
}
return &StepRequestWithOptionalArgs{
step: s.step,
}
}
// POST makes a HTTP POST request.
func (s *StepRequest) POST(url string) *StepRequestWithOptionalArgs {
s.step.Request = &Request{
Method: httpPOST,
URL: url,
}
return &StepRequestWithOptionalArgs{
step: s.step,
}
}
// PUT makes a HTTP PUT request.
func (s *StepRequest) PUT(url string) *StepRequestWithOptionalArgs {
s.step.Request = &Request{
Method: httpPUT,
URL: url,
}
return &StepRequestWithOptionalArgs{
step: s.step,
}
}
// DELETE makes a HTTP DELETE request.
func (s *StepRequest) DELETE(url string) *StepRequestWithOptionalArgs {
s.step.Request = &Request{
Method: httpDELETE,
URL: url,
}
return &StepRequestWithOptionalArgs{
step: s.step,
}
}
// OPTIONS makes a HTTP OPTIONS request.
func (s *StepRequest) OPTIONS(url string) *StepRequestWithOptionalArgs {
s.step.Request = &Request{
Method: httpOPTIONS,
URL: url,
}
return &StepRequestWithOptionalArgs{
step: s.step,
}
}
// PATCH makes a HTTP PATCH request.
func (s *StepRequest) PATCH(url string) *StepRequestWithOptionalArgs {
s.step.Request = &Request{
Method: httpPATCH,
URL: url,
}
return &StepRequestWithOptionalArgs{
step: s.step,
}
}
// CallRefCase calls a referenced testcase.
func (s *StepRequest) CallRefCase(tc ITestCase) *StepTestCaseWithOptionalArgs {
var err error
s.step.TestCase, err = tc.ToTestCase()
if err != nil {
log.Error().Err(err).Msg("failed to load testcase")
os.Exit(1)
}
return &StepTestCaseWithOptionalArgs{
step: s.step,
}
}
// CallRefAPI calls a referenced api.
func (s *StepRequest) CallRefAPI(api IAPI) *StepAPIWithOptionalArgs {
var err error
s.step.API, err = api.ToAPI()
if err != nil {
log.Error().Err(err).Msg("failed to load api")
os.Exit(1)
}
return &StepAPIWithOptionalArgs{
step: s.step,
}
}
// StartTransaction starts a transaction.
func (s *StepRequest) StartTransaction(name string) *StepTransaction {
s.step.Transaction = &Transaction{
Name: name,
Type: transactionStart,
}
return &StepTransaction{
step: s.step,
}
}
// EndTransaction ends a transaction.
func (s *StepRequest) EndTransaction(name string) *StepTransaction {
s.step.Transaction = &Transaction{
Name: name,
Type: transactionEnd,
}
return &StepTransaction{
step: s.step,
}
}
// 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
}
// SetVerify sets whether to verify SSL for current HTTP request.
func (s *StepRequestWithOptionalArgs) SetVerify(verify bool) *StepRequestWithOptionalArgs {
s.step.Request.Verify = verify
return s
}
// SetTimeout sets timeout for current HTTP request.
func (s *StepRequestWithOptionalArgs) SetTimeout(timeout float32) *StepRequestWithOptionalArgs {
s.step.Request.Timeout = timeout
return s
}
// SetProxies sets proxies for current HTTP request.
func (s *StepRequestWithOptionalArgs) SetProxies(proxies map[string]string) *StepRequestWithOptionalArgs {
// TODO
return s
}
// SetAllowRedirects sets whether to allow redirects for current HTTP request.
func (s *StepRequestWithOptionalArgs) SetAllowRedirects(allowRedirects bool) *StepRequestWithOptionalArgs {
s.step.Request.AllowRedirects = allowRedirects
return s
}
// SetAuth sets auth for current HTTP request.
func (s *StepRequestWithOptionalArgs) SetAuth(auth map[string]string) *StepRequestWithOptionalArgs {
// TODO
return s
}
// WithParams sets HTTP request params for current step.
func (s *StepRequestWithOptionalArgs) WithParams(params map[string]interface{}) *StepRequestWithOptionalArgs {
s.step.Request.Params = params
return s
}
// WithHeaders sets HTTP request headers for current step.
func (s *StepRequestWithOptionalArgs) WithHeaders(headers map[string]string) *StepRequestWithOptionalArgs {
s.step.Request.Headers = headers
return s
}
// WithCookies sets HTTP request cookies for current step.
func (s *StepRequestWithOptionalArgs) WithCookies(cookies map[string]string) *StepRequestWithOptionalArgs {
s.step.Request.Cookies = cookies
return s
}
// WithBody sets HTTP request body for current step.
func (s *StepRequestWithOptionalArgs) WithBody(body interface{}) *StepRequestWithOptionalArgs {
s.step.Request.Body = body
return s
}
// TeardownHook adds a teardown hook for current teststep.
func (s *StepRequestWithOptionalArgs) TeardownHook(hook string) *StepRequestWithOptionalArgs {
s.step.TeardownHooks = append(s.step.TeardownHooks, hook)
return s
}
// Validate switches to step validation.
func (s *StepRequestWithOptionalArgs) Validate() *StepRequestValidation {
return &StepRequestValidation{
step: s.step,
}
}
// Extract switches to step extraction.
func (s *StepRequestWithOptionalArgs) Extract() *StepRequestExtraction {
s.step.Extract = make(map[string]string)
return &StepRequestExtraction{
step: s.step,
}
}
func (s *StepRequestWithOptionalArgs) Name() string {
if s.step.Name != "" {
return s.step.Name
}
return fmt.Sprintf("%v %s", s.step.Request.Method, s.step.Request.URL)
}
func (s *StepRequestWithOptionalArgs) Type() StepType {
return StepType(fmt.Sprintf("request-%v", s.step.Request.Method))
}
func (s *StepRequestWithOptionalArgs) Struct() *TStep {
return s.step
}
func (s *StepRequestWithOptionalArgs) Run(r *SessionRunner) (*StepResult, error) {
return runStepRequest(r, s.step)
}
// StepRequestExtraction implements IStep interface.
type StepRequestExtraction struct {
step *TStep
}
// WithJmesPath sets the JMESPath expression to extract from the response.
func (s *StepRequestExtraction) WithJmesPath(jmesPath string, varName string) *StepRequestExtraction {
s.step.Extract[varName] = jmesPath
return s
}
// Validate switches to step validation.
func (s *StepRequestExtraction) Validate() *StepRequestValidation {
return &StepRequestValidation{
step: s.step,
}
}
func (s *StepRequestExtraction) Name() string {
return s.step.Name
}
func (s *StepRequestExtraction) Type() StepType {
return StepType(fmt.Sprintf("request-%v", s.step.Request.Method))
}
func (s *StepRequestExtraction) Struct() *TStep {
return s.step
}
func (s *StepRequestExtraction) Run(r *SessionRunner) (*StepResult, error) {
return runStepRequest(r, s.step)
}
// StepRequestValidation implements IStep interface.
type StepRequestValidation struct {
step *TStep
}
func (s *StepRequestValidation) Name() string {
if s.step.Name != "" {
return s.step.Name
}
return fmt.Sprintf("%s %s", s.step.Request.Method, s.step.Request.URL)
}
func (s *StepRequestValidation) Type() StepType {
return StepType(fmt.Sprintf("request-%v", s.step.Request.Method))
}
func (s *StepRequestValidation) Struct() *TStep {
return s.step
}
func (s *StepRequestValidation) Run(r *SessionRunner) (*StepResult, error) {
return runStepRequest(r, s.step)
}
func (s *StepRequestValidation) AssertEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "equals",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertGreater(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "greater_than",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertLess(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "less_than",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertGreaterOrEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "greater_or_equals",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertLessOrEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "less_or_equals",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertNotEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "not_equal",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertContains(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "contains",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertTypeMatch(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "type_match",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertRegexp(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "regex_match",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertStartsWith(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "startswith",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertEndsWith(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "endswith",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertLengthEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "length_equals",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertContainedBy(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "contained_by",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertLengthLessThan(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "length_less_than",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertStringEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "string_equals",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertLengthLessOrEquals(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "length_less_or_equals",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertLengthGreaterThan(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "length_greater_than",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertLengthGreaterOrEquals(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "length_greater_or_equals",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
// Validator represents validator for one HTTP response.
type Validator struct {
Check string `json:"check" yaml:"check"` // get value with jmespath
Assert string `json:"assert" yaml:"assert"`
Expect interface{} `json:"expect" yaml:"expect"`
Message string `json:"msg,omitempty" yaml:"msg,omitempty"` // optional
}

View File

@@ -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) {
@@ -79,11 +79,12 @@ func TestRunRequestRun(t *testing.T) {
Config: NewConfig("test").SetBaseURL("https://postman-echo.com"),
TestSteps: []IStep{stepGET, stepPOSTData},
}
runner := NewRunner(t).SetRequestsLogOn().newCaseRunner(testcase)
if _, err := runner.runStep(0, testcase.Config); err != nil {
t.Fatalf("tStep.Run() error: %s", err)
runner := NewRunner(t).SetRequestsLogOn()
sessionRunner := runner.NewSessionRunner(testcase)
if _, err := stepGET.Run(sessionRunner); err != nil {
t.Fatalf("stepGET.Run() error: %v", err)
}
if _, err := runner.runStep(1, testcase.Config); err != nil {
t.Fatalf("tStepPOSTData.Run() error: %s", err)
if _, err := stepPOSTData.Run(sessionRunner); err != nil {
t.Fatalf("stepPOSTData.Run() error: %v", err)
}
}

118
hrp/step_testcase.go Normal file
View File

@@ -0,0 +1,118 @@
package hrp
import (
"time"
"github.com/jinzhu/copier"
"github.com/rs/zerolog/log"
)
// StepTestCaseWithOptionalArgs implements IStep interface.
type StepTestCaseWithOptionalArgs struct {
step *TStep
}
// TeardownHook adds a teardown hook for current teststep.
func (s *StepTestCaseWithOptionalArgs) TeardownHook(hook string) *StepTestCaseWithOptionalArgs {
s.step.TeardownHooks = append(s.step.TeardownHooks, hook)
return s
}
// Export specifies variable names to export from referenced testcase for current step.
func (s *StepTestCaseWithOptionalArgs) Export(names ...string) *StepTestCaseWithOptionalArgs {
s.step.Export = append(s.step.Export, names...)
return s
}
func (s *StepTestCaseWithOptionalArgs) Name() string {
if s.step.Name != "" {
return s.step.Name
}
ts, ok := s.step.TestCase.(*TestCase)
if ok {
return ts.Config.Name
}
return ""
}
func (s *StepTestCaseWithOptionalArgs) Type() StepType {
return stepTypeTestCase
}
func (s *StepTestCaseWithOptionalArgs) Struct() *TStep {
return s.step
}
func (s *StepTestCaseWithOptionalArgs) Run(r *SessionRunner) (*StepResult, error) {
stepVariables, err := r.MergeStepVariables(s.step.Variables)
if err != nil {
return nil, err
}
s.step.Variables = stepVariables
log.Info().Str("testcase", s.step.Name).Msg("run referenced testcase")
stepResult := &StepResult{
Name: s.step.Name,
StepType: stepTypeTestCase,
Success: false,
}
testcase := s.step.TestCase.(*TestCase)
// copy testcase to avoid data racing
copiedTestCase := &TestCase{}
if err := copier.Copy(copiedTestCase, testcase); err != nil {
log.Error().Err(err).Msg("copy testcase failed")
return stepResult, err
}
// override testcase config
extendWithTestCase(s.step, copiedTestCase)
sessionRunner := r.hrpRunner.NewSessionRunner(copiedTestCase)
start := time.Now()
err = sessionRunner.Start()
stepResult.Elapsed = time.Since(start).Milliseconds()
if err != nil {
log.Error().Err(err).Msg("run referenced testcase step failed")
log.Info().Str("step", s.step.Name).Bool("success", false).Msg("run step end")
stepResult.Attachment = err.Error()
r.summary.Success = false
return stepResult, err
}
summary := sessionRunner.GetSummary()
stepResult.Data = summary
// export testcase export variables
stepResult.ExportVars = sessionRunner.summary.InOut.ExportVars
stepResult.Success = true
// update extracted variables
for k, v := range stepResult.ExportVars {
r.sessionVariables[k] = v
}
// merge testcase summary
r.summary.Records = append(r.summary.Records, summary.Records...)
r.summary.Stat.Total += summary.Stat.Total
r.summary.Stat.Successes += summary.Stat.Successes
r.summary.Stat.Failures += summary.Stat.Failures
log.Info().
Str("step", s.step.Name).
Bool("success", true).
Interface("exportVars", stepResult.ExportVars).
Msg("run step end")
return stepResult, nil
}
// 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)
}

80
hrp/step_thinktime.go Normal file
View File

@@ -0,0 +1,80 @@
package hrp
import (
"time"
"github.com/httprunner/httprunner/hrp/internal/builtin"
"github.com/rs/zerolog/log"
)
type ThinkTime struct {
Time float64 `json:"time" yaml:"time"`
}
// StepThinkTime implements IStep interface.
type StepThinkTime struct {
step *TStep
}
func (s *StepThinkTime) Name() string {
return s.step.Name
}
func (s *StepThinkTime) Type() StepType {
return stepTypeThinkTime
}
func (s *StepThinkTime) Struct() *TStep {
return s.step
}
func (s *StepThinkTime) Run(r *SessionRunner) (*StepResult, error) {
thinkTime := s.step.ThinkTime
log.Info().Str("name", s.step.Name).
Float64("time", thinkTime.Time).Msg("think time")
stepResult := &StepResult{
Name: s.step.Name,
StepType: stepTypeThinkTime,
Success: true,
}
cfg := r.testCase.Config.ThinkTimeSetting
if cfg == nil {
cfg = &ThinkTimeConfig{thinkTimeDefault, nil, 0}
}
var tt time.Duration
switch cfg.Strategy {
case thinkTimeDefault:
tt = time.Duration(thinkTime.Time*1000) * time.Millisecond
case thinkTimeRandomPercentage:
// e.g. {"min_percentage": 0.5, "max_percentage": 1.5}
m, ok := cfg.Setting.(map[string]float64)
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 := cfg.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 cfg.Limit > 0 {
limit := time.Duration(cfg.Limit*1000) * time.Millisecond
if limit < tt {
tt = limit
}
}
time.Sleep(tt)
return stepResult, nil
}

83
hrp/step_transaction.go Normal file
View File

@@ -0,0 +1,83 @@
package hrp
import (
"fmt"
"time"
"github.com/rs/zerolog/log"
)
type Transaction struct {
Name string `json:"name" yaml:"name"`
Type transactionType `json:"type" yaml:"type"`
}
type transactionType string
const (
transactionStart transactionType = "start"
transactionEnd transactionType = "end"
)
// StepTransaction implements IStep interface.
type StepTransaction struct {
step *TStep
}
func (s *StepTransaction) Name() string {
if s.step.Name != "" {
return s.step.Name
}
return fmt.Sprintf("transaction %s %s", s.step.Transaction.Name, s.step.Transaction.Type)
}
func (s *StepTransaction) Type() StepType {
return stepTypeTransaction
}
func (s *StepTransaction) Struct() *TStep {
return s.step
}
func (s *StepTransaction) Run(r *SessionRunner) (*StepResult, error) {
transaction := s.step.Transaction
log.Info().
Str("name", transaction.Name).
Str("type", string(transaction.Type)).
Msg("transaction")
stepResult := &StepResult{
Name: transaction.Name,
StepType: stepTypeTransaction,
Success: true,
Elapsed: 0,
ContentSize: 0, // TODO: record transaction total response length
}
// create transaction if not exists
if _, ok := r.transactions[transaction.Name]; !ok {
r.transactions[transaction.Name] = make(map[transactionType]time.Time)
}
// record transaction start time, override if already exists
if transaction.Type == transactionStart {
r.transactions[transaction.Name][transactionStart] = time.Now()
}
// record transaction end time, override if already exists
if transaction.Type == transactionEnd {
r.transactions[transaction.Name][transactionEnd] = time.Now()
// if transaction start time not exists, use testcase start time instead
if _, ok := r.transactions[transaction.Name][transactionStart]; !ok {
r.transactions[transaction.Name][transactionStart] = r.startTime
}
// calculate transaction duration
duration := r.transactions[transaction.Name][transactionEnd].Sub(
r.transactions[transaction.Name][transactionStart])
stepResult.Elapsed = duration.Milliseconds()
log.Info().Str("name", transaction.Name).Dur("elapsed", duration).Msg("transaction")
}
return stepResult, nil
}

173
hrp/summary.go Normal file
View File

@@ -0,0 +1,173 @@
package hrp
import (
"bufio"
_ "embed"
"fmt"
"html/template"
"os"
"path/filepath"
"runtime"
"time"
"github.com/httprunner/httprunner/hrp/internal/builtin"
"github.com/httprunner/httprunner/hrp/internal/version"
"github.com/rs/zerolog/log"
)
func newOutSummary() *Summary {
platForm := &Platform{
HttprunnerVersion: version.VERSION,
GoVersion: runtime.Version(),
Platform: fmt.Sprintf("%v-%v", runtime.GOOS, runtime.GOARCH),
}
return &Summary{
Success: true,
Stat: &Stat{},
Time: &TestCaseTime{
StartAt: time.Now(),
},
Platform: platForm,
}
}
// Summary stores tests summary for current task execution, maybe include one or multiple testcases
type Summary struct {
Success bool `json:"success" yaml:"success"`
Stat *Stat `json:"stat" yaml:"stat"`
Time *TestCaseTime `json:"time" yaml:"time"`
Platform *Platform `json:"platform" yaml:"platform"`
Details []*TestCaseSummary `json:"details" yaml:"details"`
}
func (s *Summary) appendCaseSummary(caseSummary *TestCaseSummary) {
s.Success = s.Success && caseSummary.Success
s.Stat.TestCases.Total += 1
s.Stat.TestSteps.Total += len(caseSummary.Records)
if caseSummary.Success {
s.Stat.TestCases.Success += 1
} else {
s.Stat.TestCases.Fail += 1
}
s.Stat.TestSteps.Successes += caseSummary.Stat.Successes
s.Stat.TestSteps.Failures += caseSummary.Stat.Failures
s.Details = append(s.Details, caseSummary)
s.Success = s.Success && caseSummary.Success
}
func (s *Summary) genHTMLReport() error {
dir, _ := filepath.Split(reportPath)
err := builtin.EnsureFolderExists(dir)
if err != nil {
return err
}
file, err := os.OpenFile(fmt.Sprintf(reportPath, s.Time.StartAt.Unix()), os.O_WRONLY|os.O_CREATE, 0666)
if err != nil {
log.Error().Err(err).Msg("open file failed")
return err
}
defer file.Close()
writer := bufio.NewWriter(file)
tmpl := template.Must(template.New("report").Parse(reportTemplate))
err = tmpl.Execute(writer, s)
if err != nil {
log.Error().Err(err).Msg("execute applies a parsed template to the specified data object failed")
return err
}
err = writer.Flush()
return err
}
//go:embed internal/scaffold/templates/report/template.html
var reportTemplate string
const (
reportPath string = "reports/report-%v.html"
summaryPath string = "reports/summary-%v.json"
)
type Stat struct {
TestCases TestCaseStat `json:"testcases" yaml:"test_cases"`
TestSteps TestStepStat `json:"teststeps" yaml:"test_steps"`
}
type TestCaseStat struct {
Total int `json:"total" yaml:"total"`
Success int `json:"success" yaml:"success"`
Fail int `json:"fail" yaml:"fail"`
}
type TestStepStat struct {
Total int `json:"total" yaml:"total"`
Successes int `json:"successes" yaml:"successes"`
Failures int `json:"failures" yaml:"failures"`
}
type TestCaseTime struct {
StartAt time.Time `json:"start_at,omitempty" yaml:"start_at,omitempty"`
Duration float64 `json:"duration,omitempty" yaml:"duration,omitempty"`
}
type Platform struct {
HttprunnerVersion string `json:"httprunner_version" yaml:"httprunner_version"`
GoVersion string `json:"go_version" yaml:"go_version"`
Platform string `json:"platform" yaml:"platform"`
}
// TestCaseSummary stores tests summary for one testcase
type TestCaseSummary struct {
Name string `json:"name" yaml:"name"`
Success bool `json:"success" yaml:"success"`
CaseId string `json:"case_id,omitempty" yaml:"case_id,omitempty"` // TODO
Stat *TestStepStat `json:"stat" yaml:"stat"`
Time *TestCaseTime `json:"time" yaml:"time"`
InOut *TestCaseInOut `json:"in_out" yaml:"in_out"`
Log string `json:"log,omitempty" yaml:"log,omitempty"` // TODO
Records []*StepResult `json:"records" yaml:"records"`
}
type TestCaseInOut struct {
ConfigVars map[string]interface{} `json:"config_vars" yaml:"config_vars"`
ExportVars map[string]interface{} `json:"export_vars" yaml:"export_vars"`
}
func newSessionData() *SessionData {
return &SessionData{
Success: false,
ReqResps: &ReqResps{},
}
}
type SessionData struct {
Success bool `json:"success" yaml:"success"`
ReqResps *ReqResps `json:"req_resps" yaml:"req_resps"`
Address *Address `json:"address,omitempty" yaml:"address,omitempty"` // TODO
Validators []*ValidationResult `json:"validators,omitempty" yaml:"validators,omitempty"`
}
type ReqResps struct {
Request interface{} `json:"request" yaml:"request"`
Response interface{} `json:"response" yaml:"response"`
}
type Address struct {
ClientIP string `json:"client_ip,omitempty" yaml:"client_ip,omitempty"`
ClientPort string `json:"client_port,omitempty" yaml:"client_port,omitempty"`
ServerIP string `json:"server_ip,omitempty" yaml:"server_ip,omitempty"`
ServerPort string `json:"server_port,omitempty" yaml:"server_port,omitempty"`
}
type ValidationResult struct {
Validator
CheckValue interface{} `json:"check_value" yaml:"check_value"`
CheckResult string `json:"check_result" yaml:"check_result"`
}
func newSummary() *TestCaseSummary {
return &TestCaseSummary{
Success: true,
Stat: &TestStepStat{},
Time: &TestCaseTime{},
InOut: &TestCaseInOut{},
}
}

24
hrp/summary_test.go Normal file
View File

@@ -0,0 +1,24 @@
package hrp
import "testing"
func TestGenHTMLReport(t *testing.T) {
summary := newOutSummary()
caseSummary1 := newSummary()
caseSummary2 := newSummary()
stepResult1 := &StepResult{}
stepResult2 := &StepResult{
Name: "Test",
StepType: stepTypeRequest,
Success: false,
ContentSize: 0,
Attachment: "err",
}
caseSummary1.Records = []*StepResult{stepResult1, stepResult2, nil}
summary.appendCaseSummary(caseSummary1)
summary.appendCaseSummary(caseSummary2)
err := summary.genHTMLReport()
if err != nil {
t.Error(err)
}
}

View File

@@ -2,123 +2,46 @@ package hrp
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/httprunner/httprunner/hrp/internal/builtin"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/hrp/internal/builtin"
)
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)
}
}
return nil
// ITestCase represents interface for testcases,
// includes TestCase and TestCasePath.
type ITestCase interface {
GetPath() string
ToTestCase() (*TestCase, error)
}
func convertCompatTestCase(tc *TCase) (err error) {
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("convert compat testcase error: %v", p)
}
}()
// TestCase is a container for one testcase, which is used for testcase runner.
// TestCase implements ITestCase interface.
type TestCase struct {
Config *TConfig
TestSteps []IStep
}
func (tc *TestCase) GetPath() string {
return tc.Config.Path
}
func (tc *TestCase) ToTestCase() (*TestCase, error) {
return tc, nil
}
func (tc *TestCase) ToTCase() *TCase {
tCase := &TCase{
Config: tc.Config,
}
for _, step := range tc.TestSteps {
// 1. deal with request body compatible with HttpRunner
if step.Request != nil && step.Request.Body == nil {
if step.Request.Json != nil {
step.Request.Headers["Content-Type"] = "application/json; charset=utf-8"
step.Request.Body = step.Request.Json
} else if step.Request.Data != nil {
step.Request.Body = step.Request.Data
}
}
// 2. deal with validators compatible with HttpRunner
err = convertCompatValidator(step.Validators)
if err != nil {
return err
}
tCase.TestSteps = append(tCase.TestSteps, step.Struct())
}
return nil
}
// convertCheckExpr deals with check expression including hyphen
func convertCheckExpr(checkExpr string) string {
if strings.Contains(checkExpr, textExtractorSubRegexp) {
return checkExpr
}
checkItems := strings.Split(checkExpr, ".")
for i, checkItem := range checkItems {
if strings.Contains(checkItem, "-") && !strings.Contains(checkItem, "\"") {
checkItems[i] = fmt.Sprintf("\"%s\"", checkItem)
}
}
return strings.Join(checkItems, ".")
}
func getProjectRootDirPath(path string) (rootDir string, err error) {
pluginPath, err := locatePlugin(path)
if err == nil {
rootDir = filepath.Dir(pluginPath)
return
}
// failed to locate project root dir
// maybe project plugin debugtalk.xx is not exist
// use current dir instead
return os.Getwd()
}
// APIPath implements IAPI interface.
type APIPath string
func (path *APIPath) GetPath() string {
return fmt.Sprintf("%v", *path)
}
func (path *APIPath) ToAPI() (*API, error) {
api := &API{}
apiPath := path.GetPath()
err := builtin.LoadFile(apiPath, api)
if err != nil {
return nil, err
}
err = convertCompatValidator(api.Validators)
return api, err
return tCase
}
// TestCasePath implements ITestCase interface.
@@ -137,7 +60,7 @@ func (path *TestCasePath) ToTestCase() (*TestCase, error) {
return nil, err
}
err = convertCompatTestCase(tc)
err = tc.makeCompat()
if err != nil {
return nil, err
}
@@ -215,3 +138,148 @@ func (path *TestCasePath) ToTestCase() (*TestCase, error) {
}
return testCase, nil
}
// TCase represents testcase data structure.
// Each testcase includes one public config and several sequential teststeps.
type TCase struct {
Config *TConfig `json:"config" yaml:"config"`
TestSteps []*TStep `json:"teststeps" yaml:"teststeps"`
}
// makeCompat converts TCase to compatible testcase
func (tc *TCase) makeCompat() error {
var err error
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("convert compat testcase error: %v", p)
}
}()
for _, step := range tc.TestSteps {
// 1. deal with request body compatible with HttpRunner
if step.Request != nil && step.Request.Body == nil {
if step.Request.Json != nil {
step.Request.Headers["Content-Type"] = "application/json; charset=utf-8"
step.Request.Body = step.Request.Json
} else if step.Request.Data != nil {
step.Request.Body = step.Request.Data
}
}
// 2. deal with validators compatible with HttpRunner
err = convertCompatValidator(step.Validators)
if err != nil {
return err
}
}
return nil
}
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)
}
}
return nil
}
// convertCheckExpr deals with check expression including hyphen
func convertCheckExpr(checkExpr string) string {
if strings.Contains(checkExpr, textExtractorSubRegexp) {
return checkExpr
}
checkItems := strings.Split(checkExpr, ".")
for i, checkItem := range checkItems {
if strings.Contains(checkItem, "-") && !strings.Contains(checkItem, "\"") {
checkItems[i] = fmt.Sprintf("\"%s\"", checkItem)
}
}
return strings.Join(checkItems, ".")
}
func loadTestCases(iTestCases ...ITestCase) ([]*TestCase, error) {
testCases := make([]*TestCase, 0)
for _, iTestCase := range iTestCases {
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
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)
}
// ignore non-testcase files
ext := filepath.Ext(path)
if ext != ".yml" && ext != ".yaml" && ext != ".json" {
return nil
}
// filtered testcases
testCasePath := TestCasePath(path)
tc, err := testCasePath.ToTestCase()
if err != nil {
log.Error().Err(err).Str("path", path).Msg("load testcase failed")
return errors.Wrap(err, "load testcase failed")
}
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
}

View File

@@ -1,223 +0,0 @@
package hrp
import (
"fmt"
)
// StepRequestValidation implements IStep interface.
type StepRequestValidation struct {
step *TStep
}
func (s *StepRequestValidation) Name() string {
if s.step.Name != "" {
return s.step.Name
}
return fmt.Sprintf("%s %s", s.step.Request.Method, s.step.Request.URL)
}
func (s *StepRequestValidation) Type() string {
return fmt.Sprintf("request-%v", s.step.Request.Method)
}
func (s *StepRequestValidation) ToStruct() *TStep {
return s.step
}
func (s *StepRequestValidation) AssertEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "equals",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertGreater(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "greater_than",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertLess(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "less_than",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertGreaterOrEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "greater_or_equals",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertLessOrEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "less_or_equals",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertNotEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "not_equal",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertContains(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "contains",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertTypeMatch(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "type_match",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertRegexp(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "regex_match",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertStartsWith(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "startswith",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertEndsWith(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "endswith",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertLengthEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "length_equals",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertContainedBy(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "contained_by",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertLengthLessThan(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "length_less_than",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertStringEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "string_equals",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertLengthLessOrEquals(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "length_less_or_equals",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertLengthGreaterThan(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "length_greater_than",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepRequestValidation) AssertLengthGreaterOrEquals(jmesPath string, expected interface{}, msg string) *StepRequestValidation {
v := Validator{
Check: jmesPath,
Assert: "length_greater_or_equals",
Expect: expected,
Message: msg,
}
s.step.Validators = append(s.step.Validators, v)
return s
}