diff --git a/cmd/benchttp/run.go b/cmd/benchttp/run.go index b4a8929..6806bbd 100644 --- a/cmd/benchttp/run.go +++ b/cmd/benchttp/run.go @@ -67,6 +67,10 @@ func (cmd *cmdRun) execute(args []string) error { return err } + if !out.Tests.Pass { + return errors.New("test suite failed") + } + return nil } diff --git a/internal/configparse/error.go b/internal/configparse/error.go index a9e0ab4..899d3ad 100644 --- a/internal/configparse/error.go +++ b/internal/configparse/error.go @@ -2,8 +2,6 @@ package configparse import ( "errors" - "fmt" - "strings" ) var ( @@ -26,13 +24,3 @@ var ( // ErrCircularExtends signals a circular reference in the config file. ErrCircularExtends = errors.New("circular reference detected") ) - -// errWithDetails returns an error wrapping err, appended with a string -// representation of details separated by ": ". -func errWithDetails(err error, details ...interface{}) error { - detailsStr := make([]string, len(details)) - for i := range details { - detailsStr[i] = fmt.Sprint(details[i]) - } - return fmt.Errorf("%w: %s", err, strings.Join(detailsStr, ": ")) -} diff --git a/internal/configparse/parse.go b/internal/configparse/parse.go index 9bcb084..84e324d 100644 --- a/internal/configparse/parse.go +++ b/internal/configparse/parse.go @@ -2,12 +2,15 @@ package configparse import ( "errors" + "fmt" "net/http" "net/url" "os" "path/filepath" + "strconv" "time" + "github.com/benchttp/engine/internal/errorutil" "github.com/benchttp/engine/runner" ) @@ -41,6 +44,13 @@ type UnmarshaledConfig struct { Silent *bool `yaml:"silent" json:"silent"` Template *string `yaml:"template" json:"template"` } `yaml:"output" json:"output"` + + Tests []struct { + Name *string `yaml:"name" json:"name"` + Field *string `yaml:"field" json:"field"` + Predicate *string `yaml:"predicate" json:"predicate"` + Target interface{} `yaml:"target" json:"target"` + } `yaml:"tests" json:"tests"` } // Parse parses a benchttp runner config file into a runner.ConfigGlobal @@ -105,19 +115,19 @@ func parseFile(filename string) (uconf UnmarshaledConfig, err error) { switch { case err == nil: case errors.Is(err, os.ErrNotExist): - return uconf, errWithDetails(ErrFileNotFound, filename) + return uconf, errorutil.WithDetails(ErrFileNotFound, filename) default: - return uconf, errWithDetails(ErrFileRead, filename, err) + return uconf, errorutil.WithDetails(ErrFileRead, filename, err) } ext := extension(filepath.Ext(filename)) parser, err := newParser(ext) if err != nil { - return uconf, errWithDetails(ErrFileExt, ext, err) + return uconf, errorutil.WithDetails(ErrFileExt, ext, err) } if err = parser.parse(b, &uconf); err != nil { - return uconf, errWithDetails(ErrParse, filename, err) + return uconf, errorutil.WithDetails(ErrParse, filename, err) } return uconf, nil @@ -142,7 +152,7 @@ func parseAndMergeConfigs(uconfs []UnmarshaledConfig) (cfg runner.Config, err er uconf := uconfs[i] pconf, err := newParsedConfig(uconf) if err != nil { - return cfg, errWithDetails(ErrParse, err) + return cfg, errorutil.WithDetails(ErrParse, err) } cfg = cfg.Override(pconf.config, pconf.fields...) } @@ -165,12 +175,13 @@ func (pconf *parsedConfig) add(field string) { // newParsedConfig parses an input raw config as a runner.ConfigGlobal and returns // a parsedConfig or the first non-nil error occurring in the process. func newParsedConfig(uconf UnmarshaledConfig) (parsedConfig, error) { //nolint:gocognit // acceptable complexity for a parsing func - const numField = 12 // should match the number of config Fields (not critical) + maxFields := len(runner.ConfigFieldsUsage) + pconf := parsedConfig{fields: make([]string, 0, maxFields)} + cfg := &pconf.config - pconf := parsedConfig{ - fields: make([]string, 0, numField), + abort := func(err error) (parsedConfig, error) { + return parsedConfig{}, err } - cfg := &pconf.config if method := uconf.Request.Method; method != nil { cfg.Request.Method = *method @@ -180,7 +191,7 @@ func newParsedConfig(uconf UnmarshaledConfig) (parsedConfig, error) { //nolint:g if rawURL := uconf.Request.URL; rawURL != nil { parsedURL, err := parseAndBuildURL(*uconf.Request.URL, uconf.Request.QueryParams) if err != nil { - return parsedConfig{}, err + return abort(err) } cfg.Request.URL = parsedURL pconf.add(runner.ConfigFieldURL) @@ -216,7 +227,7 @@ func newParsedConfig(uconf UnmarshaledConfig) (parsedConfig, error) { //nolint:g if interval := uconf.Runner.Interval; interval != nil { parsedInterval, err := parseOptionalDuration(*interval) if err != nil { - return parsedConfig{}, err + return abort(err) } cfg.Runner.Interval = parsedInterval pconf.add(runner.ConfigFieldInterval) @@ -225,7 +236,7 @@ func newParsedConfig(uconf UnmarshaledConfig) (parsedConfig, error) { //nolint:g if requestTimeout := uconf.Runner.RequestTimeout; requestTimeout != nil { parsedTimeout, err := parseOptionalDuration(*requestTimeout) if err != nil { - return parsedConfig{}, err + return abort(err) } cfg.Runner.RequestTimeout = parsedTimeout pconf.add(runner.ConfigFieldRequestTimeout) @@ -234,7 +245,7 @@ func newParsedConfig(uconf UnmarshaledConfig) (parsedConfig, error) { //nolint:g if globalTimeout := uconf.Runner.GlobalTimeout; globalTimeout != nil { parsedGlobalTimeout, err := parseOptionalDuration(*globalTimeout) if err != nil { - return parsedConfig{}, err + return abort(err) } cfg.Runner.GlobalTimeout = parsedGlobalTimeout pconf.add(runner.ConfigFieldGlobalTimeout) @@ -250,6 +261,51 @@ func newParsedConfig(uconf UnmarshaledConfig) (parsedConfig, error) { //nolint:g pconf.add(runner.ConfigFieldTemplate) } + testSuite := uconf.Tests + if len(testSuite) == 0 { + return pconf, nil + } + + cases := make([]runner.TestCase, len(testSuite)) + for i, t := range testSuite { + fieldPath := func(caseField string) string { + return fmt.Sprintf("tests[%d].%s", i, caseField) + } + + if err := requireConfigFields(map[string]interface{}{ + fieldPath("name"): t.Name, + fieldPath("field"): t.Field, + fieldPath("predicate"): t.Predicate, + fieldPath("target"): t.Target, + }); err != nil { + return abort(err) + } + + field := runner.MetricsField(*t.Field) + if err := field.Validate(); err != nil { + return abort(fmt.Errorf("%s: %s", fieldPath("field"), err)) + } + + predicate := runner.TestPredicate(*t.Predicate) + if err := predicate.Validate(); err != nil { + return abort(fmt.Errorf("%s: %s", fieldPath("predicate"), err)) + } + + target, err := parseMetricValue(field, fmt.Sprint(t.Target)) + if err != nil { + return abort(fmt.Errorf("%s: %s", fieldPath("target"), err)) + } + + cases[i] = runner.TestCase{ + Name: *t.Name, + Field: field, + Predicate: predicate, + Target: target, + } + } + cfg.Tests = cases + pconf.add(runner.ConfigFieldTests) + return pconf, nil } @@ -286,3 +342,32 @@ func parseOptionalDuration(raw string) (time.Duration, error) { } return time.ParseDuration(raw) } + +func parseMetricValue(field runner.MetricsField, in string) (runner.MetricsValue, error) { + handleError := func(v interface{}, err error) (runner.MetricsValue, error) { + if err != nil { + return nil, fmt.Errorf( + "value %q is incompatible with field %s (want %s)", + in, field, field.Type(), + ) + } + return v, nil + } + switch typ := field.Type(); typ { + case runner.MetricsTypeInt: + return handleError(strconv.Atoi(in)) + case runner.MetricsTypeDuration: + return handleError(time.ParseDuration(in)) + default: + return nil, fmt.Errorf("unknown field: %v", field) + } +} + +func requireConfigFields(fields map[string]interface{}) error { + for name, value := range fields { + if value == nil { + return fmt.Errorf("%s: missing field", name) + } + } + return nil +} diff --git a/internal/configparse/parse_test.go b/internal/configparse/parse_test.go index 05c9728..f6cc9b3 100644 --- a/internal/configparse/parse_test.go +++ b/internal/configparse/parse_test.go @@ -209,6 +209,26 @@ func newExpConfig() runner.Config { Silent: true, Template: "{{ .Metrics.Avg }}", }, + Tests: []runner.TestCase{ + { + Name: "minimum response time", + Field: "MIN", + Predicate: "GT", + Target: 80 * time.Millisecond, + }, + { + Name: "maximum response time", + Field: "MAX", + Predicate: "LTE", + Target: 120 * time.Millisecond, + }, + { + Name: "100% availability", + Field: "FAILURE_COUNT", + Predicate: "EQ", + Target: 0, + }, + }, } } diff --git a/internal/configparse/testdata/valid/benchttp.json b/internal/configparse/testdata/valid/benchttp.json index 8ce24b1..f68438e 100644 --- a/internal/configparse/testdata/valid/benchttp.json +++ b/internal/configparse/testdata/valid/benchttp.json @@ -24,5 +24,25 @@ "output": { "silent": true, "template": "{{ .Metrics.Avg }}" - } + }, + "tests": [ + { + "name": "minimum response time", + "field": "MIN", + "predicate": "GT", + "target": "80ms" + }, + { + "name": "maximum response time", + "field": "MAX", + "predicate": "LTE", + "target": "120ms" + }, + { + "name": "100% availability", + "field": "FAILURE_COUNT", + "predicate": "EQ", + "target": "0" + } + ] } diff --git a/internal/configparse/testdata/valid/benchttp.yaml b/internal/configparse/testdata/valid/benchttp.yaml index 25541c5..2871ec0 100644 --- a/internal/configparse/testdata/valid/benchttp.yaml +++ b/internal/configparse/testdata/valid/benchttp.yaml @@ -23,3 +23,17 @@ runner: output: silent: true template: "{{ .Metrics.Avg }}" + +tests: + - name: minimum response time + field: MIN + predicate: GT + target: 80ms + - name: maximum response time + field: MAX + predicate: LTE + target: 120ms + - name: 100% availability + field: FAILURE_COUNT + predicate: EQ + target: 0 diff --git a/internal/configparse/testdata/valid/benchttp.yml b/internal/configparse/testdata/valid/benchttp.yml index bef73f7..4968772 100644 --- a/internal/configparse/testdata/valid/benchttp.yml +++ b/internal/configparse/testdata/valid/benchttp.yml @@ -20,3 +20,17 @@ runner: output: silent: true template: "{{ .Metrics.Avg }}" + +tests: + - name: minimum response time + field: MIN + predicate: GT + target: 80ms + - name: maximum response time + field: MAX + predicate: LTE + target: 120ms + - name: 100% availability + field: FAILURE_COUNT + predicate: EQ + target: 0 diff --git a/internal/errorutil/errorutil.go b/internal/errorutil/errorutil.go new file mode 100644 index 0000000..c52fee9 --- /dev/null +++ b/internal/errorutil/errorutil.go @@ -0,0 +1,23 @@ +package errorutil + +import ( + "fmt" + "strings" +) + +// WithDetails returns an error wrapping err, appended with a string +// representation of details separated by ": ". +// +// Example +// var ErrNotFound = errors.New("not found") +// err := WithDetails(ErrNotFound, "abc.jpg", "deleted yesterday") +// +// errors.Is(err, ErrNotFound) == true +// err.Error() == "not found: abc.jpg: deleted yesterday" +func WithDetails(base error, details ...interface{}) error { + detailsStr := make([]string, len(details)) + for i := range details { + detailsStr[i] = fmt.Sprint(details[i]) + } + return fmt.Errorf("%w: %s", base, strings.Join(detailsStr, ": ")) +} diff --git a/runner/internal/config/config.go b/runner/internal/config/config.go index 1b10a90..3cfde53 100644 --- a/runner/internal/config/config.go +++ b/runner/internal/config/config.go @@ -8,6 +8,8 @@ import ( "net/http" "net/url" "time" + + "github.com/benchttp/engine/runner/internal/tests" ) // RequestBody represents a request body associated with a type. @@ -88,6 +90,7 @@ type Global struct { Request Request Runner Runner Output Output + Tests []tests.Case } // String returns an indented JSON representation of Config @@ -125,6 +128,8 @@ func (cfg Global) Override(c Global, fields ...string) Global { cfg.Output.Silent = c.Output.Silent case FieldTemplate: cfg.Output.Template = c.Output.Template + case FieldTests: + cfg.Tests = c.Tests } } return cfg diff --git a/runner/internal/config/field.go b/runner/internal/config/field.go index 4c33f0d..8124264 100644 --- a/runner/internal/config/field.go +++ b/runner/internal/config/field.go @@ -12,6 +12,7 @@ const ( FieldGlobalTimeout = "globalTimeout" FieldSilent = "silent" FieldTemplate = "template" + FieldTests = "tests" ) // FieldsUsage is a record of all available config fields and their usage. @@ -27,6 +28,7 @@ var FieldsUsage = map[string]string{ FieldGlobalTimeout: "Max duration of test", FieldSilent: "Silent mode (no write to stdout)", FieldTemplate: "Output template", + FieldTests: "Test suite", } func IsField(v string) bool { diff --git a/runner/internal/metrics/aggregate.go b/runner/internal/metrics/aggregate.go new file mode 100644 index 0000000..de972c1 --- /dev/null +++ b/runner/internal/metrics/aggregate.go @@ -0,0 +1,50 @@ +package metrics + +import ( + "time" + + "github.com/benchttp/engine/runner/internal/recorder" +) + +// Aggregate is an aggregate of metrics resulting from +// from recorded requests. +type Aggregate struct { + Min, Max, Avg time.Duration + SuccessCount, FailureCount, TotalCount int + // Median, StdDev time.Duration + // Deciles map[int]float64 + // StatusCodeDistribution map[string]int + // RequestEventsDistribution map[recorder.Event]int +} + +// MetricOf returns the Metric for the given field in Aggregate. +// +// It panics if field is not a known field. +func (agg Aggregate) MetricOf(field Field) Metric { + return Metric{Field: field, Value: field.valueIn(agg)} +} + +// Compute computes and aggregates metrics from the given +// requests records. +func Compute(records []recorder.Record) (agg Aggregate) { + if len(records) == 0 { + return + } + + agg.TotalCount = len(records) + agg.Min, agg.Max = records[0].Time, records[0].Time + for _, rec := range records { + if rec.Error != "" { + agg.FailureCount++ + } + if rec.Time < agg.Min { + agg.Min = rec.Time + } + if rec.Time > agg.Max { + agg.Max = rec.Time + } + agg.Avg += rec.Time / time.Duration(len(records)) + } + agg.SuccessCount = agg.TotalCount - agg.FailureCount + return +} diff --git a/runner/internal/metrics/compare.go b/runner/internal/metrics/compare.go new file mode 100644 index 0000000..91e9417 --- /dev/null +++ b/runner/internal/metrics/compare.go @@ -0,0 +1,77 @@ +package metrics + +import ( + "fmt" + "time" +) + +// ComparisonResult is the result of a comparison. +type ComparisonResult int + +const ( + // INF is the result of an inferiority check. + INF ComparisonResult = -1 + // EQ is the result of an equality check. + EQ ComparisonResult = 0 + // INF is the result of superiority check. + SUP ComparisonResult = 1 +) + +// comapreMetrics compares the values of m and n, +// and returns the result from the point of view of n. +// +// It panics if m and n are not of the same type, +// or if their type is not handled. +func compareMetrics(m, n Metric) ComparisonResult { + a, b := m.Value, n.Value + if a, b, isDuration := assertDurations(a, b); isDuration { + return compareDurations(a, b) + } + if a, b, isInt := assertInts(a, b); isInt { + return compareInts(a, b) + } + panic(fmt.Sprintf( + "metrics: unhandled comparison: %v (%T) and %v (%T)", + a, a, b, b, + )) +} + +// compareInts compares a and b and returns a ComparisonResult +// from the point of view of b. +func compareInts(a, b int) ComparisonResult { + if b < a { + return INF + } + if b > a { + return SUP + } + return EQ +} + +// compareInts compares a and b and returns a ComparisonResult +// from the point of view of b. +func compareDurations(a, b time.Duration) ComparisonResult { + return compareInts(int(a), int(b)) +} + +// assertInts returns a, b as ints and true if a and b +// are both ints, else it returns 0, 0, false. +func assertInts(a, b Value) (x, y int, ok bool) { + x, ok = a.(int) + if !ok { + return + } + y, ok = b.(int) + return +} + +// assertInts returns a, b as time.Durations and true if a and b +// are both time.Duration, else it returns 0, 0, false. +func assertDurations(a, b Value) (x, y time.Duration, ok bool) { + x, ok = a.(time.Duration) + if !ok { + return + } + y, ok = b.(time.Duration) + return +} diff --git a/runner/internal/metrics/field.go b/runner/internal/metrics/field.go new file mode 100644 index 0000000..1db2b9d --- /dev/null +++ b/runner/internal/metrics/field.go @@ -0,0 +1,122 @@ +package metrics + +import ( + "errors" + "reflect" + + "github.com/benchttp/engine/internal/errorutil" +) + +var ErrUnknownField = errors.New("metrics: unknown field") + +// Field is the name of an Aggregate field. +// It exposes a method Type that returns its intrisic type. +// It can be used to retrieve a Metric from an Aggregate +// via Aggregate.MetricOf(field). +type Field string + +const ( + ResponseTimeAvg Field = "AVG" + ResponseTimeMin Field = "MIN" + ResponseTimeMax Field = "MAX" + RequestFailCount Field = "FAILURE_COUNT" + RequestSuccessCount Field = "SUCCESS_COUNT" + RequestCount Field = "TOTAL_COUNT" +) + +// fieldDefinition holds the necessary values to identify +// and manipulate a field. +// It consists of an intrinsic type and an accessor that binds +// the field to an Aggregate metric value. +type fieldDefinition struct { + // typ is the intrisic type of the field. + typ Type + // boundValue is an accessor that binds a field + // to the value it represents in an Aggregate. + boundValue func(Aggregate) Value +} + +// fieldDefinitions is a table of truth for fields. +// It maps all Field references to their intrinsic fieldDefinition. +var fieldDefinitions = map[Field]fieldDefinition{ + ResponseTimeAvg: {TypeDuration, func(a Aggregate) Value { return a.Avg }}, + ResponseTimeMin: {TypeDuration, func(a Aggregate) Value { return a.Min }}, + ResponseTimeMax: {TypeDuration, func(a Aggregate) Value { return a.Max }}, + RequestFailCount: {TypeInt, func(a Aggregate) Value { return a.FailureCount }}, + RequestSuccessCount: {TypeInt, func(a Aggregate) Value { return a.SuccessCount }}, + RequestCount: {TypeInt, func(a Aggregate) Value { return a.TotalCount }}, +} + +// Type represents the underlying type of a Value. +type Type uint8 + +const ( + lastGoReflectKind = reflect.UnsafePointer + + // TypeInt corresponds to type int. + TypeInt = Type(reflect.Int) + // TypeDuration corresponds to type time.Duration. + TypeDuration = Type(lastGoReflectKind + iota) +) + +// String returns a human-readable representation of the field. +// +// Example: +// TypeDuration.String() == "time.Duration" +// Type(123).String() == "[unknown type]" +func (typ Type) String() string { + switch typ { + case TypeInt: + return "int" + case TypeDuration: + return "time.Duration" + default: + return "[unknown type]" + } +} + +// Type returns the field's intrisic type. +// It panics if field is not defined in fieldDefinitions. +func (field Field) Type() Type { + return field.mustRetrieve().typ +} + +// Validate returns ErrUnknownField if field is not a know Field, else nil. +func (field Field) Validate() error { + if !field.exists() { + return errorutil.WithDetails(ErrUnknownField, field) + } + return nil +} + +// func (field Field) IsCompatibleWith() + +// valueIn returns the field's bound value in the given Aggregate. +// It panics if field is not defined in fieldDefinitions. +func (field Field) valueIn(agg Aggregate) Value { + return field.mustRetrieve().boundValue(agg) +} + +func (field Field) retrieve() (fieldDefinition, bool) { + fieldData, exists := fieldDefinitions[field] + return fieldData, exists +} + +func (field Field) exists() bool { + _, exists := fieldDefinitions[field] + return exists +} + +// mustRetrieve retrieves the fieldDefinition for the given field +// from fieldDefinitions, or panics if not found. +func (field Field) mustRetrieve() fieldDefinition { + fieldData, exists := field.retrieve() + if !exists { + panic(badField(field)) + } + return fieldData +} + +func badField(field Field) string { + return "metrics: unknown field: " + string(field) +} diff --git a/runner/internal/metrics/metrics.go b/runner/internal/metrics/metrics.go index 97b6594..7b9fca8 100644 --- a/runner/internal/metrics/metrics.go +++ b/runner/internal/metrics/metrics.go @@ -1,43 +1,36 @@ package metrics -import ( - "time" +// Value is a concrete metric value, e.g. 120 or 3 * time.Second. +type Value interface{} - "github.com/benchttp/engine/runner/internal/recorder" -) - -// Aggregate is an aggregate of metrics resulting from -// from recorded requests. -type Aggregate struct { - Min, Max, Avg time.Duration - SuccessCount, FailureCount, TotalCount int - // Median, StdDev time.Duration - // Deciles map[int]float64 - // StatusCodeDistribution map[string]int - // RequestEventsDistribution map[recorder.Event]int +// Metric represents an Aggregate metric. It links together a Field +// and its Value from the Aggregate. +// It exposes a method Compare that compares its Value to another. +type Metric struct { + Field Field + Value Value } -// Compute computes and aggregates metrics from the given -// requests records. -func Compute(records []recorder.Record) (agg Aggregate) { - if len(records) == 0 { - return - } - - agg.TotalCount = len(records) - agg.Min, agg.Max = records[0].Time, records[0].Time - for _, rec := range records { - if rec.Error != "" { - agg.FailureCount++ - } - if rec.Time < agg.Min { - agg.Min = rec.Time - } - if rec.Time > agg.Max { - agg.Max = rec.Time - } - agg.Avg += rec.Time / time.Duration(len(records)) - } - agg.SuccessCount = agg.TotalCount - agg.FailureCount - return +// Compare compares a Metric's value to another. +// It returns a ComparisonResult that indicates the relationship +// between the two values from the receiver's point of view. +// +// It panics if m and n are not of the same type, +// or if their type is not handled. +// +// Examples: +// +// receiver := Metric{Value: 120} +// comparer := Metric{Value: 100} +// receiver.Compare(comparer) == SUP +// +// receiver := Metric{Value: 120 * time.Millisecond} +// comparer := Metric{Value: 100} +// receiver.Compare(comparer) // panics! +// +// receiver := Metric{Value: http.Header{}} +// comparer := Metric{Value: http.Header{}} +// receiver.Compare(comparer) // panics! +func (m Metric) Compare(to Metric) ComparisonResult { + return compareMetrics(to, m) } diff --git a/runner/internal/metrics/metrics_test.go b/runner/internal/metrics/metrics_test.go new file mode 100644 index 0000000..885d176 --- /dev/null +++ b/runner/internal/metrics/metrics_test.go @@ -0,0 +1,87 @@ +package metrics_test + +import ( + "testing" + "time" + + "github.com/benchttp/engine/runner/internal/metrics" +) + +func TestMetric_Compare(t *testing.T) { + const ( + base = 100 + more = base + 1 + less = base - 1 + ) + + testcases := []struct { + label string + baseMetric metrics.Metric + targetMetric metrics.Metric + expResult metrics.ComparisonResult + expPanic bool + }{ + { + label: "base equals target", + baseMetric: metricWithValue(base), + targetMetric: metricWithValue(base), + expResult: metrics.EQ, + expPanic: false, + }, + { + label: "base superior to target", + baseMetric: metricWithValue(base), + targetMetric: metricWithValue(less), + expResult: metrics.SUP, + expPanic: false, + }, + { + label: "base inferior to target", + baseMetric: metricWithValue(base), + targetMetric: metricWithValue(more), + expResult: metrics.INF, + expPanic: false, + }, + { + label: "panics with different type", + baseMetric: metricWithValue(base), + targetMetric: metricWithValue(base * time.Millisecond), + expResult: 0, // irrelevant, should panic + expPanic: true, + }, + { + label: "panics with different type", + baseMetric: metricWithValue(1.23), + targetMetric: metricWithValue(1.23), + expResult: 0, // irrelevant, should panic + expPanic: true, + }, + } + + for _, tc := range testcases { + t.Run(tc.label, func(t *testing.T) { + if tc.expPanic { + defer assertPanic(t) + } + + result := tc.baseMetric.Compare(tc.targetMetric) + + if !tc.expPanic && result != tc.expResult { + t.Errorf( + "\nexp %v.Compare(%v) == %v, got %v", + tc.baseMetric, tc.targetMetric, tc.expResult, result) + } + }) + } +} + +func metricWithValue(v metrics.Value) metrics.Metric { + return metrics.Metric{Value: v} +} + +func assertPanic(t *testing.T) { + t.Helper() + if r := recover(); r == nil { + t.Error("did not panic") + } +} diff --git a/runner/internal/report/report.go b/runner/internal/report/report.go index b4139ed..cc069cd 100644 --- a/runner/internal/report/report.go +++ b/runner/internal/report/report.go @@ -9,14 +9,17 @@ import ( "strings" "time" + "github.com/benchttp/engine/internal/cli/ansi" "github.com/benchttp/engine/runner/internal/config" "github.com/benchttp/engine/runner/internal/metrics" + "github.com/benchttp/engine/runner/internal/tests" ) // Report represents a run result as exported by the runner. type Report struct { Metrics metrics.Aggregate Metadata Metadata + Tests tests.SuiteResult errTemplateFailTriggered error } @@ -29,9 +32,15 @@ type Metadata struct { } // New returns an initialized *Report. -func New(m metrics.Aggregate, cfg config.Global, d time.Duration) *Report { +func New( + m metrics.Aggregate, + cfg config.Global, + d time.Duration, + testResults tests.SuiteResult, +) *Report { return &Report{ Metrics: m, + Tests: testResults, Metadata: Metadata{ Config: cfg, FinishedAt: time.Now(), // TODO: change, unreliable @@ -49,6 +58,7 @@ func (rep *Report) String() string { case err == nil: // template is non-empty and correctly executed, // return its result instead of default summary. + rep.writeTestsResult(&b) return s case errors.Is(err, errTemplateSyntax): // template is non-empty but has syntax errors, @@ -60,6 +70,8 @@ func (rep *Report) String() string { } rep.writeDefaultSummary(&b) + rep.writeTestsResult(&b) + return b.String() } @@ -98,6 +110,8 @@ func (rep *Report) writeDefaultSummary(w io.StringWriter) { cfg = rep.Metadata.Config ) + w.WriteString(ansi.Bold("→ Summary")) + w.WriteString("\n") w.WriteString(line("Endpoint", cfg.Request.URL)) w.WriteString(line("Requests", formatRequests(m.TotalCount, cfg.Runner.Requests))) w.WriteString(line("Errors", m.FailureCount)) @@ -106,3 +120,49 @@ func (rep *Report) writeDefaultSummary(w io.StringWriter) { w.WriteString(line("Mean response time", msString(m.Avg))) w.WriteString(line("Total duration", msString(rep.Metadata.TotalDuration))) } + +func (rep *Report) writeTestsResult(w io.StringWriter) { + sr := rep.Tests + if len(sr.Results) == 0 { + return + } + + w.WriteString("\n") + w.WriteString(ansi.Bold("→ Test suite")) + w.WriteString("\n") + + writeResultString(w, sr.Pass) + w.WriteString("\n") + + for _, tr := range sr.Results { + writeIndent(w, 1) + writeResultString(w, tr.Pass) + w.WriteString(" ") + w.WriteString(tr.Input.Name) + + if !tr.Pass { + w.WriteString("\n ") + writeIndent(w, 3) + w.WriteString(ansi.Bold("→ ")) + w.WriteString(tr.Summary) + } + + w.WriteString("\n") + } +} + +func writeResultString(w io.StringWriter, pass bool) { + if pass { + w.WriteString(ansi.Green("PASS")) + } else { + w.WriteString(ansi.Red("FAIL")) + } +} + +func writeIndent(w io.StringWriter, count int) { + if count <= 0 { + return + } + const baseIndent = " " + w.WriteString(strings.Repeat(baseIndent, count)) +} diff --git a/runner/internal/report/report_test.go b/runner/internal/report/report_test.go index dcc8800..a02800c 100644 --- a/runner/internal/report/report_test.go +++ b/runner/internal/report/report_test.go @@ -7,9 +7,11 @@ import ( "testing" "time" + "github.com/benchttp/engine/internal/cli/ansi" "github.com/benchttp/engine/runner/internal/config" "github.com/benchttp/engine/runner/internal/metrics" "github.com/benchttp/engine/runner/internal/report" + "github.com/benchttp/engine/runner/internal/tests" ) func TestReport_String(t *testing.T) { @@ -18,7 +20,7 @@ func TestReport_String(t *testing.T) { t.Run("return default summary if template is empty", func(t *testing.T) { const tpl = "" - rep := report.New(newMetrics(), newConfigWithTemplate(tpl), d) + rep := report.New(newMetrics(), newConfigWithTemplate(tpl), d, tests.SuiteResult{}) checkSummary(t, rep.String()) }) @@ -26,7 +28,7 @@ func TestReport_String(t *testing.T) { const tpl = "{{ .Metrics.TotalCount }}" m := newMetrics() - rep := report.New(m, newConfigWithTemplate(tpl), d) + rep := report.New(m, newConfigWithTemplate(tpl), d, tests.SuiteResult{}) if got, exp := rep.String(), strconv.Itoa(m.TotalCount); got != exp { t.Errorf("\nunexpected output\nexp %s\ngot %s", exp, got) @@ -36,7 +38,7 @@ func TestReport_String(t *testing.T) { t.Run("fallback to default summary if template is invalid", func(t *testing.T) { const tpl = "{{ .Marcel.Patulacci }}" - rep := report.New(newMetrics(), newConfigWithTemplate(tpl), d) + rep := report.New(newMetrics(), newConfigWithTemplate(tpl), d, tests.SuiteResult{}) got := rep.String() split := strings.Split(got, "Falling back to default summary:\n") @@ -78,7 +80,7 @@ func newConfigWithTemplate(tpl string) config.Global { func checkSummary(t *testing.T, summary string) { t.Helper() - expSummary := ` + expSummary := ansi.Bold("→ Summary") + ` Endpoint https://a.b.com Requests 3/∞ Errors 1 @@ -86,7 +88,7 @@ Min response time 4000ms Max response time 6000ms Mean response time 5000ms Total duration 15000ms -`[1:] +` if summary != expSummary { t.Errorf("\nexp summary:\n%q\ngot summary:\n%q", expSummary, summary) diff --git a/runner/internal/tests/predicate.go b/runner/internal/tests/predicate.go new file mode 100644 index 0000000..d8146d0 --- /dev/null +++ b/runner/internal/tests/predicate.go @@ -0,0 +1,69 @@ +package tests + +import ( + "errors" + + "github.com/benchttp/engine/internal/errorutil" + "github.com/benchttp/engine/runner/internal/metrics" +) + +var ErrUnknownPredicate = errors.New("tests: unknown predicate") + +// Predicate represents a comparison operator. +type Predicate string + +const ( + EQ Predicate = "EQ" + NEQ Predicate = "NEQ" + GT Predicate = "GT" + GTE Predicate = "GTE" + LT Predicate = "LT" + LTE Predicate = "LTE" +) + +// Validate returns ErrUnknownPredicate if p is not a know Predicate, else nil. +func (p Predicate) Validate() error { + if _, ok := predicateSymbols[p]; !ok { + return errorutil.WithDetails(ErrUnknownPredicate, p) + } + return nil +} + +func (p Predicate) match(comparisonResult metrics.ComparisonResult) bool { + sup := comparisonResult == metrics.SUP + inf := comparisonResult == metrics.INF + + switch p { + case EQ: + return !sup && !inf + case NEQ: + return sup || inf + case GT: + return sup + case GTE: + return !inf + case LT: + return inf + case LTE: + return !sup + default: + panic("tests: unknown predicate: " + string(p)) + } +} + +var predicateSymbols = map[Predicate]string{ + EQ: "==", + NEQ: "!=", + GT: ">", + GTE: ">=", + LT: "<", + LTE: "<=", +} + +func (p Predicate) symbol() string { + s, ok := predicateSymbols[p] + if !ok { + return "unknown predicate" + } + return s +} diff --git a/runner/internal/tests/predicate_test.go b/runner/internal/tests/predicate_test.go new file mode 100644 index 0000000..cf3dae8 --- /dev/null +++ b/runner/internal/tests/predicate_test.go @@ -0,0 +1,110 @@ +package tests_test + +import ( + "testing" + + "github.com/benchttp/engine/runner/internal/metrics" + "github.com/benchttp/engine/runner/internal/tests" +) + +func TestPredicate(t *testing.T) { + const ( + base = 100 + more = base + 1 + less = base - 1 + ) + + testcases := []struct { + Predicate tests.Predicate + PassValues []int + FailValues []int + }{ + { + Predicate: tests.EQ, + PassValues: []int{base}, + FailValues: []int{more, less}, + }, + { + Predicate: tests.NEQ, + PassValues: []int{less, more}, + FailValues: []int{base}, + }, + { + Predicate: tests.LT, + PassValues: []int{more}, + FailValues: []int{base, less}, + }, + { + Predicate: tests.LTE, + PassValues: []int{more, base}, + FailValues: []int{less}, + }, + { + Predicate: tests.GT, + PassValues: []int{less}, + FailValues: []int{base, more}, + }, + { + Predicate: tests.GTE, + PassValues: []int{less, base}, + FailValues: []int{more}, + }, + } + + for _, tc := range testcases { + t.Run(string(tc.Predicate)+":pass", func(t *testing.T) { + for _, passValue := range tc.PassValues { + expectPredicatePass(t, tc.Predicate, base, passValue) + } + }) + t.Run(string(tc.Predicate+":fail"), func(t *testing.T) { + for _, failValue := range tc.FailValues { + expectPredicateFail(t, tc.Predicate, base, failValue) + } + }) + } +} + +func expectPredicatePass( + t *testing.T, + p tests.Predicate, + src, tar int, +) { + t.Helper() + expectPredicateResult(t, p, src, tar, true) +} + +func expectPredicateFail( + t *testing.T, + p tests.Predicate, + src, tar int, +) { + t.Helper() + expectPredicateResult(t, p, src, tar, false) +} + +func expectPredicateResult( + t *testing.T, + p tests.Predicate, + src, tar int, + expPass bool, +) { + t.Helper() + + agg := metrics.Aggregate{ + TotalCount: src, + } + + result := tests.Run(agg, []tests.Case{{ + Predicate: p, + Field: metrics.RequestCount, + Target: tar, + }}) + + if pass := result.Pass; pass != expPass { + t.Errorf( + "exp %v %d %d -> %v, got %v", + p, src, tar, expPass, pass, + ) + } +} diff --git a/runner/internal/tests/tests.go b/runner/internal/tests/tests.go new file mode 100644 index 0000000..431a42e --- /dev/null +++ b/runner/internal/tests/tests.go @@ -0,0 +1,56 @@ +package tests + +import ( + "fmt" + + "github.com/benchttp/engine/runner/internal/metrics" +) + +type Case struct { + Name string + Field metrics.Field + Predicate Predicate + Target metrics.Value +} + +type SuiteResult struct { + Pass bool + Results []CaseResult +} + +type CaseResult struct { + Input Case + Pass bool + Summary string +} + +func Run(agg metrics.Aggregate, cases []Case) SuiteResult { + allpass := true + results := make([]CaseResult, len(cases)) + for i, input := range cases { + currentResult := runTestCase(agg, input) + results[i] = currentResult + if !currentResult.Pass { + allpass = false + } + } + return SuiteResult{ + Pass: allpass, + Results: results, + } +} + +func runTestCase(agg metrics.Aggregate, c Case) CaseResult { + gotMetric := agg.MetricOf(c.Field) + tarMetric := metrics.Metric{Field: c.Field, Value: c.Target} + comparisonResult := gotMetric.Compare(tarMetric) + + return CaseResult{ + Input: c, + Pass: c.Predicate.match(comparisonResult), + Summary: fmt.Sprintf( + "want %s %s %v, got %v", + c.Field, c.Predicate.symbol(), c.Target, gotMetric.Value, + ), + } +} diff --git a/runner/internal/tests/tests_test.go b/runner/internal/tests/tests_test.go new file mode 100644 index 0000000..f49a7dd --- /dev/null +++ b/runner/internal/tests/tests_test.go @@ -0,0 +1,114 @@ +package tests_test + +import ( + "fmt" + "testing" + "time" + + "github.com/benchttp/engine/runner/internal/metrics" + "github.com/benchttp/engine/runner/internal/tests" +) + +func TestRun(t *testing.T) { + testcases := []struct { + label string + inputAgg metrics.Aggregate + inputCases []tests.Case + expGlobalPass bool + expCaseResults []tests.CaseResult + }{ + { + label: "pass if all cases pass", + inputAgg: metrics.Aggregate{Avg: 100 * time.Millisecond}, + inputCases: []tests.Case{ + { + Name: "average response time below 120ms (pass)", + Predicate: tests.LT, + Field: metrics.ResponseTimeAvg, + Target: 120 * time.Millisecond, + }, + { + Name: "average response time is above 80ms (pass)", + Predicate: tests.GT, + Field: metrics.ResponseTimeAvg, + Target: 80 * time.Millisecond, + }, + }, + expGlobalPass: true, + expCaseResults: []tests.CaseResult{ + {Pass: true, Summary: "want AVG < 120ms, got 100ms"}, + {Pass: true, Summary: "want AVG > 80ms, got 100ms"}, + }, + }, + { + label: "fail if at least one case fails", + inputAgg: metrics.Aggregate{Avg: 200 * time.Millisecond}, + inputCases: []tests.Case{ + { + Name: "average response time below 120ms (fail)", + Predicate: tests.LT, + Field: metrics.ResponseTimeAvg, + Target: 120 * time.Millisecond, + }, + { + Name: "average response time is above 80ms (pass)", + Predicate: tests.GT, + Field: metrics.ResponseTimeAvg, + Target: 80 * time.Millisecond, + }, + }, + expGlobalPass: false, + expCaseResults: []tests.CaseResult{ + {Pass: false, Summary: "want AVG < 120ms, got 200ms"}, + {Pass: true, Summary: "want AVG > 80ms, got 200ms"}, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.label, func(t *testing.T) { + suiteResult := tests.Run(tc.inputAgg, tc.inputCases) + + if gotGlobalPass := suiteResult.Pass; gotGlobalPass != tc.expGlobalPass { + t.Errorf( + "exp global pass == %v, got %v", + gotGlobalPass, tc.expGlobalPass, + ) + } + + assertEqualCaseResults(t, tc.expCaseResults, suiteResult.Results) + }) + } +} + +func assertEqualCaseResults(t *testing.T, exp, got []tests.CaseResult) { + t.Helper() + + if gotLen, expLen := len(got), len(exp); gotLen != expLen { + t.Errorf("exp %d case results, got %d", expLen, gotLen) + } + + for i, expResult := range exp { + gotResult := got[i] + caseDesc := fmt.Sprintf("case #%d (%q)", i, gotResult.Input.Name) + + t.Run(fmt.Sprintf("cases[%d].Pass", i), func(t *testing.T) { + if gotResult.Pass != expResult.Pass { + t.Errorf( + "\n%s:\nexp %v, got %v", + caseDesc, expResult.Pass, gotResult.Pass, + ) + } + }) + + t.Run(fmt.Sprintf("cases[%d].Summary", i), func(t *testing.T) { + if gotResult.Summary != expResult.Summary { + t.Errorf( + "\n%s:\nexp %q\ngot %q", + caseDesc, expResult.Summary, gotResult.Summary, + ) + } + }) + + } +} diff --git a/runner/runner.go b/runner/runner.go index 31e2cb0..096e8a4 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -8,6 +8,7 @@ import ( "github.com/benchttp/engine/runner/internal/metrics" "github.com/benchttp/engine/runner/internal/recorder" "github.com/benchttp/engine/runner/internal/report" + "github.com/benchttp/engine/runner/internal/tests" ) type ( @@ -21,6 +22,13 @@ type ( RecordingStatus = recorder.Status Report = report.Report + + MetricsField = metrics.Field + MetricsValue = metrics.Value + MetricsType = metrics.Type + + TestCase = tests.Case + TestPredicate = tests.Predicate ) const ( @@ -40,6 +48,7 @@ const ( ConfigFieldGlobalTimeout = config.FieldGlobalTimeout ConfigFieldSilent = config.FieldSilent ConfigFieldTemplate = config.FieldTemplate + ConfigFieldTests = config.FieldTests ) var ( @@ -47,6 +56,9 @@ var ( ConfigFieldsUsage = config.FieldsUsage NewRequestBody = config.NewRequestBody IsConfigField = config.IsField + + MetricsTypeInt = metrics.TypeInt + MetricsTypeDuration = metrics.TypeDuration ) type Runner struct { @@ -85,7 +97,9 @@ func (r *Runner) Run(ctx context.Context, cfg config.Global) (*Report, error) { duration := time.Since(startTime) - return report.New(agg, cfg, duration), nil + testResults := tests.Run(agg, cfg.Tests) + + return report.New(agg, cfg, duration, testResults), nil } // Progress returns the current progress of the recording.