Files
httprunner/runner.go
2021-12-28 18:12:27 +08:00

514 lines
14 KiB
Go

package hrp
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strings"
"testing"
"time"
"github.com/jinzhu/copier"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/hrp/internal/ga"
)
// Run starts to run API test with default configs.
func Run(testcases ...ITestCase) error {
t := &testing.T{}
return NewRunner(t).SetDebug(true).Run(testcases...)
}
// NewRunner constructs a new runner instance.
func NewRunner(t *testing.T) *hrpRunner {
if t == nil {
t = &testing.T{}
}
return &hrpRunner{
t: t,
failfast: true, // default to failfast
debug: false, // default to turn off debug
client: &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
Timeout: 30 * time.Second,
},
}
}
type hrpRunner struct {
t *testing.T
failfast bool
debug bool
client *http.Client
}
// SetFailfast configures whether to stop running when one step fails.
func (r *hrpRunner) SetFailfast(failfast bool) *hrpRunner {
log.Info().Bool("failfast", failfast).Msg("[init] SetFailfast")
r.failfast = failfast
return r
}
// SetDebug configures whether to log HTTP request and response content.
func (r *hrpRunner) SetDebug(debug bool) *hrpRunner {
log.Info().Bool("debug", debug).Msg("[init] SetDebug")
r.debug = debug
return r
}
// SetProxyUrl configures the proxy URL, which is usually used to capture HTTP packets for debugging.
func (r *hrpRunner) SetProxyUrl(proxyUrl string) *hrpRunner {
log.Info().Str("proxyUrl", proxyUrl).Msg("[init] SetProxyUrl")
p, err := url.Parse(proxyUrl)
if err != nil {
log.Error().Err(err).Str("proxyUrl", proxyUrl).Msg("[init] invalid proxyUrl")
return r
}
r.client.Transport = &http.Transport{
Proxy: http.ProxyURL(p),
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
return r
}
// Run starts to execute one or multiple testcases.
func (r *hrpRunner) Run(testcases ...ITestCase) error {
event := ga.EventTracking{
Category: "RunAPITests",
Action: "hrp run",
}
// report start event
go ga.SendEvent(event)
// report execution timing event
defer ga.SendEvent(event.StartTiming("execution"))
for _, iTestCase := range testcases {
testcase, err := iTestCase.ToTestCase()
if err != nil {
log.Error().Err(err).Msg("[Run] convert ITestCase interface to TestCase struct failed")
return err
}
if err := r.newCaseRunner(testcase).run(); err != nil {
log.Error().Err(err).Msg("[Run] run testcase failed")
return err
}
}
return nil
}
func (r *hrpRunner) newCaseRunner(testcase *TestCase) *caseRunner {
caseRunner := &caseRunner{
TestCase: testcase,
hrpRunner: r,
}
caseRunner.reset()
return caseRunner
}
// caseRunner is used to run testcase and its steps.
// each testcase has its own caseRunner instance and share session variables.
type caseRunner struct {
*TestCase
hrpRunner *hrpRunner
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
}
// reset clears runner session variables.
func (r *caseRunner) reset() *caseRunner {
log.Info().Msg("[init] Reset session variables")
r.sessionVariables = make(map[string]interface{})
r.transactions = make(map[string]map[transactionType]time.Time)
r.startTime = time.Now()
return r
}
func (r *caseRunner) run() error {
config := r.TestCase.Config
if err := r.parseConfig(config); err != nil {
return err
}
log.Info().Str("testcase", config.Name()).Msg("run testcase start")
r.startTime = time.Now()
for index := range r.TestCase.TestSteps {
_, err := r.runStep(index)
if err != nil {
if r.hrpRunner.failfast {
log.Error().Err(err).Msg("abort running due to failfast setting")
return err
}
log.Warn().Err(err).Msg("run step failed, continue next step")
}
}
log.Info().Str("testcase", config.Name()).Msg("run testcase end")
return nil
}
func (r *caseRunner) runStep(index int) (stepResult *stepData, err error) {
config := r.TestCase.Config
step := r.TestCase.TestSteps[index]
// step type priority order: transaction > rendezvous > testcase > request
if stepTran, ok := step.(*StepTransaction); ok {
// transaction step
return r.runStepTransaction(stepTran.step.Transaction)
} else if stepRend, ok := step.(*StepRendezvous); ok {
// rendezvous step
return r.runStepRendezvous(stepRend.step.Rendezvous)
}
log.Info().Str("step", step.Name()).Msg("run step start")
// copy step and config to avoid data racing
copiedStep := &TStep{}
if err = copier.Copy(copiedStep, step.ToStruct()); err != nil {
log.Error().Err(err).Msg("copy step data failed")
return nil, err
}
copiedConfig := &TConfig{}
if err = copier.Copy(copiedConfig, config.ToStruct()); err != nil {
log.Error().Err(err).Msg("copy config data failed")
return nil, err
}
stepVariables := copiedStep.Variables
// override variables
// step variables > session variables (extracted variables from previous steps)
stepVariables = mergeVariables(stepVariables, r.sessionVariables)
// step variables > testcase config variables
stepVariables = mergeVariables(stepVariables, copiedConfig.Variables)
// parse step variables
parsedVariables, err := parseVariables(stepVariables)
if err != nil {
log.Error().Interface("variables", copiedConfig.Variables).Err(err).Msg("parse step variables failed")
return nil, err
}
copiedStep.Variables = parsedVariables // avoid data racing
// step type priority order: testcase > request
if _, ok := step.(*StepTestCaseWithOptionalArgs); ok {
// run referenced testcase
log.Info().Str("testcase", copiedStep.Name).Msg("run referenced testcase")
// TODO: override testcase config
stepResult, err = r.runStepTestCase(copiedStep)
if err != nil {
log.Error().Err(err).Msg("run referenced testcase step failed")
return
}
} else {
// run request
copiedStep.Request.URL = buildURL(copiedConfig.BaseURL, copiedStep.Request.URL) // avoid data racing
stepResult, err = r.runStepRequest(copiedStep)
if err != nil {
log.Error().Err(err).Msg("run request step failed")
return
}
}
// update extracted variables
for k, v := range stepResult.exportVars {
r.sessionVariables[k] = v
}
log.Info().
Str("step", step.Name()).
Bool("success", stepResult.success).
Interface("exportVars", stepResult.exportVars).
Msg("run step end")
return stepResult, nil
}
func (r *caseRunner) runStepTransaction(transaction *Transaction) (stepResult *stepData, err error) {
log.Info().
Str("name", transaction.Name).
Str("type", string(transaction.Type)).
Msg("transaction")
stepResult = &stepData{
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
}
func (r *caseRunner) runStepRendezvous(rend *Rendezvous) (stepResult *stepData, err error) {
log.Info().
Str("name", rend.Name).
Float32("percent", rend.Percent).
Int64("number", rend.Number).
Int64("timeout", rend.Timeout).
Msg("rendezvous")
stepResult = &stepData{
name: rend.Name,
stepType: stepTypeRendezvous,
success: true,
}
return stepResult, nil
}
func (r *caseRunner) runStepRequest(step *TStep) (stepResult *stepData, err error) {
stepResult = &stepData{
name: step.Name,
stepType: stepTypeRequest,
success: false,
contentSize: 0,
}
rawUrl := step.Request.URL
method := step.Request.Method
req := &http.Request{
Method: string(method),
Header: make(http.Header),
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
}
// prepare request headers
if len(step.Request.Headers) > 0 {
headers, err := parseHeaders(step.Request.Headers, step.Variables)
if err != nil {
return nil, errors.Wrap(err, "parse headers failed")
}
for key, value := range headers {
req.Header.Add(key, value)
}
}
if length := req.Header.Get("Content-Length"); length != "" {
if l, err := strconv.ParseInt(length, 10, 64); err == nil {
req.ContentLength = l
}
}
// prepare request params
var queryParams url.Values
if len(step.Request.Params) > 0 {
params, err := parseData(step.Request.Params, step.Variables)
if err != nil {
return nil, errors.Wrap(err, "parse data failed")
}
parsedParams := params.(map[string]interface{})
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 request cookies
for cookieName, cookieValue := range step.Request.Cookies {
req.AddCookie(&http.Cookie{
Name: cookieName,
Value: cookieValue,
})
}
// prepare request body
if step.Request.Body != nil {
data, err := parseData(step.Request.Body, step.Variables)
if err != nil {
return nil, err
}
var dataBytes []byte
switch vv := data.(type) {
case map[string]interface{}:
contentType := 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 nil, err
}
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 nil, errors.New("unexpected request body type")
}
setBodyBytes(req, dataBytes)
}
// prepare url
u, err := url.Parse(rawUrl)
if err != nil {
return nil, errors.Wrap(err, "parse url failed")
}
req.URL = u
req.Host = u.Host
// log & print request
if r.hrpRunner.debug {
reqDump, err := httputil.DumpRequest(req, true)
if err != nil {
return nil, errors.Wrap(err, "dump request failed")
}
fmt.Println("-------------------- request --------------------")
fmt.Println(string(reqDump))
}
// do request action
start := time.Now()
resp, err := r.hrpRunner.client.Do(req)
stepResult.elapsed = time.Since(start).Milliseconds()
if err != nil {
return nil, errors.Wrap(err, "do request failed")
}
defer resp.Body.Close()
// log & print response
if r.hrpRunner.debug {
fmt.Println("==================== response ===================")
respDump, err := httputil.DumpResponse(resp, true)
if err != nil {
return nil, errors.Wrap(err, "dump response failed")
}
fmt.Println(string(respDump))
fmt.Println("--------------------------------------------------")
}
// new response object
respObj, err := newResponseObject(r.hrpRunner.t, resp)
if err != nil {
err = errors.Wrap(err, "init ResponseObject error")
return
}
// extract variables from response
extractors := step.Extract
extractMapping := respObj.Extract(extractors)
stepResult.exportVars = extractMapping
// override step variables with extracted variables
stepVariables := mergeVariables(step.Variables, extractMapping)
// validate response
err = respObj.Validate(step.Validators, stepVariables)
if err != nil {
return
}
stepResult.success = true
stepResult.contentSize = resp.ContentLength
return stepResult, nil
}
func (r *caseRunner) runStepTestCase(step *TStep) (stepResult *stepData, err error) {
stepResult = &stepData{
name: step.Name,
stepType: stepTypeTestCase,
success: false,
}
testcase := step.TestCase
start := time.Now()
err = r.hrpRunner.newCaseRunner(testcase).run()
stepResult.elapsed = time.Since(start).Milliseconds()
if err != nil {
return stepResult, err
}
stepResult.success = true
return stepResult, nil
}
func (r *caseRunner) parseConfig(config IConfig) error {
cfg := config.ToStruct()
// parse config variables
parsedVariables, err := 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 := parseString(cfg.Name, cfg.Variables)
if err != nil {
return err
}
cfg.Name = convertString(parsedName)
// parse config base url
parsedBaseURL, err := parseString(cfg.BaseURL, cfg.Variables)
if err != nil {
return err
}
cfg.BaseURL = convertString(parsedBaseURL)
return nil
}
func (r *caseRunner) getSummary() *testCaseSummary {
return &testCaseSummary{}
}
func setBodyBytes(req *http.Request, data []byte) {
req.Body = ioutil.NopCloser(bytes.NewReader(data))
req.ContentLength = int64(len(data))
}