diff --git a/CHANGELOG.md b/CHANGELOG.md index 5845d207..c70bfccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This document is formatted according to the principles of [Keep A CHANGELOG](htt ## Unreleased ### Added +- Enhancement: Support for custom types + StepParam - ([682](https://github.com/cucumber/godog/pull/682) - [tigh-latte](https://github.com/tigh-latte)) - Step text is added to "step is undefined" error - ([669](https://github.com/cucumber/godog/pull/669) - [vearutop](https://github.com/vearutop)) ### Changed diff --git a/README.md b/README.md index dceacf16..90efb31e 100644 --- a/README.md +++ b/README.md @@ -528,6 +528,58 @@ Now, the test binary can be compiled with all feature files embedded, and can be **NOTE:** `godog.Options.FS` is as `fs.FS`, so custom filesystem loaders can be used. +### Step Params + +As well as accepting `string`, `int`, etc, as parameters to step functions, godog accepts any type which implements `models.StepParam`: + +```go +type StepParam interface { + LoadParam(ctx context.Context) (string, error) +} +``` + +Let's say we have the file `expected.txt`: +``` +hello world! +``` + +And the type, `TestData`: + +```go +type TestData string + +func (t TestData) LoadParam(ctx context.Context) (string, error) { + data, err := os.ReadFile(string(t)) + if err != nil { + return "", fmt.Errorf("failed to load file: %w", err) + } + + return string(data), nil +} +``` + +And the following step: + +```go +ctx.Step(`^the file "([^"]+)" should contain "([^"]+)"$`, theFileShouldContain) + +// ... + +func theFileShouldContain(ctx context.Context, data TestData, exp string) error { + // assert that `string(data)` == `exp` +} +``` + +Then, once this step is used in a `.feature` file: + +```gherkin +Then the file "expected.txt" should contain "hello world!" +``` + +The `TestFile` param is noticed to implement `models.StepParam`, and `LoadParam` is called. The value of the receive is the value defined in the step function (so, in this case `expected.txt`), and whatever is returned (in this case `hello world!`) is what is ultimately provided to as a parameter to the step's function. + +The param loading occurs _after_ the before scenario and before step hooks are called, so all user defined setup will have already taken place. + ## CLI Mode **NOTE:** The [`godog` CLI has been deprecated](https://github.com/cucumber/godog/discussions/478). It is recommended to use `go test` instead. diff --git a/go.sum b/go.sum index 7f6b31ed..d44256b1 100644 --- a/go.sum +++ b/go.sum @@ -33,7 +33,6 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= diff --git a/internal/models/stepdef.go b/internal/models/stepdef.go index eaf73e7d..2e573301 100644 --- a/internal/models/stepdef.go +++ b/internal/models/stepdef.go @@ -12,7 +12,10 @@ import ( "github.com/cucumber/godog/formatters" ) -var typeOfBytes = reflect.TypeOf([]byte(nil)) +var ( + typeOfBytes = reflect.TypeOf([]byte(nil)) + typeOfTextStepParam = reflect.TypeOf((*StepParam)(nil)).Elem() +) // matchable errors var ( @@ -59,6 +62,16 @@ func (sd *StepDefinition) Run(ctx context.Context) (context.Context, interface{} for i := 0; i < numIn; i++ { param := typ.In(i + ctxOffset) + + m, err := sd.tryStepParam(ctx, param, i) + if err != nil { + return ctx, err + } + if m.IsValid() { + values = append(values, m) + continue + } + switch param.Kind() { case reflect.Int: s, err := sd.shouldBeString(i) @@ -69,7 +82,7 @@ func (sd *StepDefinition) Run(ctx context.Context) (context.Context, interface{} if err != nil { return ctx, fmt.Errorf(`%w %d: "%s" to int: %s`, ErrCannotConvert, i, s, err) } - values = append(values, reflect.ValueOf(int(v))) + values = append(values, reflect.ValueOf(int(v)).Convert(param)) case reflect.Int64: s, err := sd.shouldBeString(i) if err != nil { @@ -79,7 +92,7 @@ func (sd *StepDefinition) Run(ctx context.Context) (context.Context, interface{} if err != nil { return ctx, fmt.Errorf(`%w %d: "%s" to int64: %s`, ErrCannotConvert, i, s, err) } - values = append(values, reflect.ValueOf(v)) + values = append(values, reflect.ValueOf(v).Convert(param)) case reflect.Int32: s, err := sd.shouldBeString(i) if err != nil { @@ -89,7 +102,7 @@ func (sd *StepDefinition) Run(ctx context.Context) (context.Context, interface{} if err != nil { return ctx, fmt.Errorf(`%w %d: "%s" to int32: %s`, ErrCannotConvert, i, s, err) } - values = append(values, reflect.ValueOf(int32(v))) + values = append(values, reflect.ValueOf(int32(v)).Convert(param)) case reflect.Int16: s, err := sd.shouldBeString(i) if err != nil { @@ -99,7 +112,7 @@ func (sd *StepDefinition) Run(ctx context.Context) (context.Context, interface{} if err != nil { return ctx, fmt.Errorf(`%w %d: "%s" to int16: %s`, ErrCannotConvert, i, s, err) } - values = append(values, reflect.ValueOf(int16(v))) + values = append(values, reflect.ValueOf(int16(v)).Convert(param)) case reflect.Int8: s, err := sd.shouldBeString(i) if err != nil { @@ -109,13 +122,13 @@ func (sd *StepDefinition) Run(ctx context.Context) (context.Context, interface{} if err != nil { return ctx, fmt.Errorf(`%w %d: "%s" to int8: %s`, ErrCannotConvert, i, s, err) } - values = append(values, reflect.ValueOf(int8(v))) + values = append(values, reflect.ValueOf(int8(v)).Convert(param)) case reflect.String: s, err := sd.shouldBeString(i) if err != nil { return ctx, err } - values = append(values, reflect.ValueOf(s)) + values = append(values, reflect.ValueOf(s).Convert(param)) case reflect.Float64: s, err := sd.shouldBeString(i) if err != nil { @@ -125,7 +138,7 @@ func (sd *StepDefinition) Run(ctx context.Context) (context.Context, interface{} if err != nil { return ctx, fmt.Errorf(`%w %d: "%s" to float64: %s`, ErrCannotConvert, i, s, err) } - values = append(values, reflect.ValueOf(v)) + values = append(values, reflect.ValueOf(v).Convert(param)) case reflect.Float32: s, err := sd.shouldBeString(i) if err != nil { @@ -135,10 +148,12 @@ func (sd *StepDefinition) Run(ctx context.Context) (context.Context, interface{} if err != nil { return ctx, fmt.Errorf(`%w %d: "%s" to float32: %s`, ErrCannotConvert, i, s, err) } - values = append(values, reflect.ValueOf(float32(v))) + values = append(values, reflect.ValueOf(float32(v)).Convert(param)) case reflect.Ptr: arg := sd.Args[i] - switch param.Elem().String() { + elem := param.Elem() + + switch elem.String() { case "messages.PickleDocString": if v, ok := arg.(*messages.PickleStepArgument); ok { values = append(values, reflect.ValueOf(v.DocString)) @@ -167,6 +182,7 @@ func (sd *StepDefinition) Run(ctx context.Context) (context.Context, interface{} // the error here is that the declared function has an unsupported param type - really this ought to be trapped at registration ti,e return ctx, fmt.Errorf("%w: the data type of parameter %d type *%s is not supported", ErrUnsupportedParameterType, i, param.Elem().String()) } + case reflect.Slice: switch param { case typeOfBytes: @@ -230,6 +246,36 @@ func (sd *StepDefinition) Run(ctx context.Context) (context.Context, interface{} panic(fmt.Errorf("step definition '%v' has return type (context.Context, error), but found %v rather than a context.Context value%s", text, result0, errMsg)) } +func (sd *StepDefinition) tryStepParam(ctx context.Context, param reflect.Type, idx int) (reflect.Value, error) { + var val reflect.Value + if !param.Implements(typeOfTextStepParam) { + return val, nil + } + + s, err := sd.shouldBeString(idx) + if err != nil { + return val, err + } + + if param.Kind() == reflect.Ptr { + val = reflect.ValueOf(&s).Convert(param) + } else { + val = reflect.ValueOf(s).Convert(param) + } + + tm := val.Interface().(StepParam) + + text, err := tm.LoadParam(ctx) + if err != nil { + return val, fmt.Errorf("failed to load param for arg[%d]: %w", idx, err) + } + + if param.Kind() == reflect.Ptr { + return reflect.ValueOf(&text).Convert(param), nil + } + return reflect.ValueOf(text).Convert(param), nil +} + func (sd *StepDefinition) shouldBeString(idx int) (string, error) { arg := sd.Args[idx] switch arg := arg.(type) { diff --git a/internal/models/stepdef_test.go b/internal/models/stepdef_test.go index e66c5337..c1c14ebc 100644 --- a/internal/models/stepdef_test.go +++ b/internal/models/stepdef_test.go @@ -42,7 +42,6 @@ func TestShouldSupportVoidHandlerReturn(t *testing.T) { // ctx is passed thru assert.Equal(t, initialCtx, ctx) assert.Nil(t, err) - } func TestShouldSupportNilContextReturn(t *testing.T) { @@ -149,7 +148,6 @@ func TestShouldSupportErrorReturn(t *testing.T) { } func TestShouldSupportContextAndErrorReturn(t *testing.T) { - ctx := context.WithValue(context.Background(), ctxKey("original"), 123) expectedErr := fmt.Errorf("expected error") @@ -175,7 +173,6 @@ func TestShouldSupportContextAndErrorReturn(t *testing.T) { } func TestShouldSupportContextAndNilErrorReturn(t *testing.T) { - ctx := context.WithValue(context.Background(), ctxKey("original"), 123) fn := func(ctx context.Context) (context.Context, error) { @@ -200,7 +197,6 @@ func TestShouldSupportContextAndNilErrorReturn(t *testing.T) { } func TestShouldRejectNilContextWhenMultiValueReturn(t *testing.T) { - ctx := context.WithValue(context.Background(), ctxKey("original"), 123) fn := func(ctx context.Context) (context.Context, error) { @@ -233,7 +229,6 @@ func TestShouldRejectNilContextWhenMultiValueReturn(t *testing.T) { } func TestArgumentCountChecks(t *testing.T) { - wasCalled := false fn := func(a int, b int) { wasCalled = true @@ -372,7 +367,6 @@ func TestShouldSupportGherkinDocstring(t *testing.T) { } func TestShouldSupportGherkinTable(t *testing.T) { - var actualTable *messages.PickleTable fnTable := func(a *messages.PickleTable) { actualTable = a @@ -426,6 +420,210 @@ func TestShouldSupportOnlyByteSlice(t *testing.T) { assert.True(t, errors.Is(err.(error), models.ErrUnsupportedParameterType)) } +func TestShouldSupportCustomTypes(t *testing.T) { + type customString string + type customInt64 int64 + type customInt32 int32 + type customInt16 int16 + type customInt8 int8 + type customInt int + type customFloat64 float64 + type customFloat32 float32 + + var ( + aCustomString customString + aCustomInt64 customInt64 + aCustomInt32 customInt32 + aCustomInt16 customInt16 + aCustomInt8 customInt8 + aCustomInt customInt + aCustomFloat64 customFloat64 + aCustomFloat32 customFloat32 + ) + + fn := func( + a customString, + b customInt64, + c customInt32, + d customInt16, + e customInt8, + f customInt, + g customFloat64, + h customFloat32, + ) { + aCustomString = a + aCustomInt64 = b + aCustomInt32 = c + aCustomInt16 = d + aCustomInt8 = e + aCustomInt = f + aCustomFloat64 = g + aCustomFloat32 = h + } + + def := &models.StepDefinition{ + StepDefinition: formatters.StepDefinition{ + Handler: fn, + }, + HandlerValue: reflect.ValueOf(fn), + } + + def.Args = []interface{}{"my cool string", "1", "2", "3", "4", "5", "6.7", "8.9"} + _, err := def.Run(context.Background()) + assert.Nil(t, err) + assert.Equal(t, customString("my cool string"), aCustomString) + assert.Equal(t, customInt64(1), aCustomInt64) + assert.Equal(t, customInt32(2), aCustomInt32) + assert.Equal(t, customInt16(3), aCustomInt16) + assert.Equal(t, customInt8(4), aCustomInt8) + assert.Equal(t, customInt(5), aCustomInt) + assert.Equal(t, customFloat64(6.7), aCustomFloat64) + assert.Equal(t, customFloat32(8.9), aCustomFloat32) +} + +func TestShouldSupportCustomTypesWithContext(t *testing.T) { + type customString string + type customInt64 int64 + type customInt32 int32 + type customInt16 int16 + type customInt8 int8 + type customInt int + type customFloat64 float64 + type customFloat32 float32 + + var ( + aCustomString customString + aCustomInt64 customInt64 + aCustomInt32 customInt32 + aCustomInt16 customInt16 + aCustomInt8 customInt8 + aCustomInt customInt + aCustomFloat64 customFloat64 + aCustomFloat32 customFloat32 + ) + + fn := func( + ctx context.Context, + a customString, + b customInt64, + c customInt32, + d customInt16, + e customInt8, + f customInt, + g customFloat64, + h customFloat32, + ) { + aCustomString = a + aCustomInt64 = b + aCustomInt32 = c + aCustomInt16 = d + aCustomInt8 = e + aCustomInt = f + aCustomFloat64 = g + aCustomFloat32 = h + } + + def := &models.StepDefinition{ + StepDefinition: formatters.StepDefinition{ + Handler: fn, + }, + HandlerValue: reflect.ValueOf(fn), + } + + def.Args = []interface{}{"my cool string", "1", "2", "3", "4", "5", "6.7", "8.9"} + _, err := def.Run(context.Background()) + assert.Nil(t, err) + assert.Equal(t, customString("my cool string"), aCustomString) + assert.Equal(t, customInt64(1), aCustomInt64) + assert.Equal(t, customInt32(2), aCustomInt32) + assert.Equal(t, customInt16(3), aCustomInt16) + assert.Equal(t, customInt8(4), aCustomInt8) + assert.Equal(t, customInt(5), aCustomInt) + assert.Equal(t, customFloat64(6.7), aCustomFloat64) + assert.Equal(t, customFloat32(8.9), aCustomFloat32) +} + +type sparam string + +func (s sparam) LoadParam(ctx context.Context) (string, error) { + return string(s) + " world!", nil +} + +func TestShouldSupportStepParam(t *testing.T) { + var sp sparam + + fn := func( + ctx context.Context, + a sparam, + ) { + sp = a + } + + def := &models.StepDefinition{ + StepDefinition: formatters.StepDefinition{ + Handler: fn, + }, + HandlerValue: reflect.ValueOf(fn), + } + + def.Args = []interface{}{"hello"} + _, err := def.Run(context.Background()) + assert.Nil(t, err) + assert.Equal(t, sparam("hello world!"), sp) +} + +type sparamptr string + +func (s *sparamptr) LoadParam(ctx context.Context) (string, error) { + return string(*s) + " world!", nil +} + +func TestShouldSupportStepParamPointerReceiver(t *testing.T) { + var spptr sparamptr + + fn := func( + ctx context.Context, + a *sparamptr, + ) { + spptr = *a + } + + def := &models.StepDefinition{ + StepDefinition: formatters.StepDefinition{ + Handler: fn, + }, + HandlerValue: reflect.ValueOf(fn), + } + + def.Args = []interface{}{"hello"} + _, err := def.Run(context.Background()) + assert.Nil(t, err) + assert.Equal(t, sparamptr("hello world!"), spptr) +} + +type sparamerr string + +func (s sparamerr) LoadParam(context.Context) (string, error) { + return "", errors.New("oh no") +} + +func TestShouldSupportStepParamErrorOnLoad(t *testing.T) { + expectedError := errors.New("failed to load param for arg[0]: oh no") + + fn := func(ctx context.Context, s sparamerr) {} + + def := &models.StepDefinition{ + StepDefinition: formatters.StepDefinition{ + Handler: fn, + }, + HandlerValue: reflect.ValueOf(fn), + } + + def.Args = []interface{}{"hello"} + _, err := def.Run(context.Background()) + assert.Equal(t, expectedError.Error(), err.(error).Error()) +} + // this test is superficial compared to the ones above where the actual error messages the user woudl see are verified func TestStepDefinition_Run_StepArgsShouldBeString(t *testing.T) { test := func(t *testing.T, fn interface{}, expectedError string) { @@ -471,7 +669,6 @@ func TestStepDefinition_Run_StepArgsShouldBeString(t *testing.T) { test(t, func(a *godog.Table) { shouldNotBeCalled() }, `cannot convert argument 0: "12" of type "int" to *messages.PickleTable`) test(t, func(a *godog.DocString) { shouldNotBeCalled() }, `cannot convert argument 0: "12" of type "int" to *messages.PickleDocString`) test(t, func(a []byte) { shouldNotBeCalled() }, toStringError) - } func TestStepDefinition_Run_InvalidHandlerParamConversion(t *testing.T) { @@ -533,7 +730,6 @@ func TestStepDefinition_Run_InvalidHandlerParamConversion(t *testing.T) { // // I cannot use bool test(t, func(a bool) { shouldNotBeCalled() }, "func has unsupported parameter type: the parameter 0 type bool is not supported") - } func TestStepDefinition_Run_StringConversionToFunctionType(t *testing.T) { @@ -583,7 +779,6 @@ func TestStepDefinition_Run_StringConversionToFunctionType(t *testing.T) { // Cannot convert to DocString ? test(t, func(a *godog.DocString) { shouldNotBeCalled() }, []interface{}{"194"}, `cannot convert argument 0: "194" of type "string" to *messages.PickleDocString`) - } // @TODO maybe we should support duration diff --git a/internal/models/stepparam.go b/internal/models/stepparam.go new file mode 100644 index 00000000..2e7c3937 --- /dev/null +++ b/internal/models/stepparam.go @@ -0,0 +1,7 @@ +package models + +import "context" + +type StepParam interface { + LoadParam(ctx context.Context) (string, error) +} diff --git a/suite.go b/suite.go index f9253cf9..a71c273b 100644 --- a/suite.go +++ b/suite.go @@ -101,7 +101,6 @@ func clearAttach(ctx context.Context) context.Context { } func pickleAttachments(ctx context.Context) []models.PickleAttachment { - pickledAttachments := []models.PickleAttachment{} attachments := Attachments(ctx) @@ -568,9 +567,7 @@ func keywordMatches(k formatters.Keyword, stepType messages.PickleStepType) bool } func (s *suite) runSteps(ctx context.Context, pickle *Scenario, steps []*Step) (context.Context, error) { - var ( - stepErr, scenarioErr error - ) + var stepErr, scenarioErr error for i, step := range steps { isLast := i == len(steps)-1