Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
76387d2
feat: implement test runner
GregoryAlbouy Jul 27, 2022
cfba02e
feat: parse input test configuration
GregoryAlbouy Jul 27, 2022
2dbe0ad
feat: run test suite from config
GregoryAlbouy Jul 27, 2022
1587db7
feat: report test results
GregoryAlbouy Jul 27, 2022
2568b59
test(configparse): update unit tests
GregoryAlbouy Jul 27, 2022
aa639e6
test(report): update unit test for Report.String
GregoryAlbouy Jul 27, 2022
d7ffbbc
refactor(metrics,tests): simplify testing workflow
GregoryAlbouy Jul 30, 2022
cdae0dc
feat(metrics,configparse): allow any metric types for tests
GregoryAlbouy Jul 31, 2022
3391b4e
fix(tests): use accurate format for value display
GregoryAlbouy Jul 31, 2022
8538c1a
fix(metrics): set TypeDuration to last reflect.Kind + 1
GregoryAlbouy Jul 31, 2022
6ce2447
test(tests): implement TestRun
GregoryAlbouy Jul 31, 2022
4fe0e37
test(tests): fix and refactor TestPredicate
GregoryAlbouy Jul 31, 2022
9c209a4
fix(metrics): reverse comparison order for Metric.Compare
GregoryAlbouy Jul 31, 2022
6df8217
docs(metrics): add doc comments
GregoryAlbouy Jul 31, 2022
cd471c6
test(metrics): implement TestMetric_Compare
GregoryAlbouy Jul 31, 2022
e6196c0
refactor(metrics): rename Source -> Field
GregoryAlbouy Jul 31, 2022
05a2514
refactor(metrics): use fieldRecord as single source of truth
GregoryAlbouy Jul 31, 2022
fb5147f
refactor(metrics): reorganize files
GregoryAlbouy Jul 31, 2022
3f5a104
refactor(metrics): improve field understandability
GregoryAlbouy Aug 1, 2022
b0402d0
refactor(configparse): Ultimate Once And For All Solution
GregoryAlbouy Aug 1, 2022
360fa20
feat(configparse): error handling
GregoryAlbouy Aug 1, 2022
24b2b4c
refactor: create package errorutil
GregoryAlbouy Aug 1, 2022
76c397d
feat(cli): exit 1 if the test suite fails
GregoryAlbouy Aug 1, 2022
7aa80df
ui(cli): improve test suite results display
GregoryAlbouy Aug 1, 2022
54791be
fix(configparse): accept any value when unmarshaling tests.target
GregoryAlbouy Aug 1, 2022
7c9c4ce
refactor(configparse): remove unnecessary helper
GregoryAlbouy Aug 2, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/benchttp/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
12 changes: 0 additions & 12 deletions internal/configparse/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package configparse

import (
"errors"
"fmt"
"strings"
)

var (
Expand All @@ -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, ": "))
}
111 changes: 98 additions & 13 deletions internal/configparse/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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...)
}
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
}

Expand Down Expand Up @@ -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
}
20 changes: 20 additions & 0 deletions internal/configparse/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
}
}

Expand Down
22 changes: 21 additions & 1 deletion internal/configparse/testdata/valid/benchttp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
14 changes: 14 additions & 0 deletions internal/configparse/testdata/valid/benchttp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 14 additions & 0 deletions internal/configparse/testdata/valid/benchttp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 23 additions & 0 deletions internal/errorutil/errorutil.go
Original file line number Diff line number Diff line change
@@ -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, ": "))
}
5 changes: 5 additions & 0 deletions runner/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -88,6 +90,7 @@ type Global struct {
Request Request
Runner Runner
Output Output
Tests []tests.Case
}

// String returns an indented JSON representation of Config
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions runner/internal/config/field.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down
Loading