From 6968c1f6ddafe77f03c12413702bf463dad2572b Mon Sep 17 00:00:00 2001 From: Tronje Krop Date: Wed, 16 Oct 2024 03:35:57 +0200 Subject: [PATCH] refactor: basic test concepts to introduce test timeout (#93) Signed-off-by: Tronje Krop --- go.mod | 3 + go.sum | 9 + internal/mock/common_test.go | 20 +- internal/mock/generate_test.go | 12 +- internal/mock/loader_test.go | 4 +- internal/mock/mock_iface_test.gox | 4 +- internal/mock/parser.go | 25 +- internal/mock/parser_test.go | 59 ++- internal/mock/test/iface.go | 2 +- internal/mock/testx/iface.go | 2 +- internal/reflect/reflect.go | 59 --- internal/reflect/reflect_test.go | 167 -------- test/README.md | 27 +- test/caller_test.go | 4 +- test/common.go | 57 +++ test/common_test.go | 243 ++++++++++++ test/context.go | 400 +++++++++++++++++++ test/context_test.go | 124 ++++++ test/gomock.go | 6 +- test/gomock_test.go | 4 +- test/pattern.go | 2 +- test/reflect.go | 123 ++++-- test/reflect_test.go | 229 ++++++++--- test/runner.go | 256 +++++++++++++ test/runner_test.go | 213 ++++++++++ test/testing.go | 618 ------------------------------ test/testing_test.go | 449 ---------------------- 27 files changed, 1670 insertions(+), 1451 deletions(-) create mode 100644 test/common.go create mode 100644 test/common_test.go create mode 100644 test/context.go create mode 100644 test/context_test.go create mode 100644 test/runner.go create mode 100644 test/runner_test.go delete mode 100644 test/testing.go delete mode 100644 test/testing_test.go diff --git a/go.mod b/go.mod index 2a85ce6..639c812 100644 --- a/go.mod +++ b/go.mod @@ -16,8 +16,11 @@ require ( github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + go.uber.org/mock v0.4.0 // indirect golang.org/x/mod v0.21.0 // indirect golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.26.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index bd77b4b..0813471 100644 --- a/go.sum +++ b/go.sum @@ -26,11 +26,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -48,6 +53,9 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -65,5 +73,6 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/mock/common_test.go b/internal/mock/common_test.go index f9ab2bf..5c0d7b8 100644 --- a/internal/mock/common_test.go +++ b/internal/mock/common_test.go @@ -26,10 +26,10 @@ const ( dirTop = "../../.." dirMock = "mock" - dirTest = "./test" + dirSubTest = "./test" dirOther = "../other" dirUnknown = "../unknown" - dirTesting = "../../test" + dirTest = "../../test" pathMock = "github.com/tkrop/go-testing/internal/mock" pathTest = "github.com/tkrop/go-testing/internal/mock/test" @@ -43,7 +43,7 @@ const ( fileOther = "mock_other_test.go" fileTemplate = "mock_template_test.go" fileUnknown = "unnkown_test.go" - fileTesting = "testing.go" + fileContext = "context.go" aliasMock = "mock_" + pkgTest aliasInt = "internal_" + aliasMock @@ -60,6 +60,8 @@ const ( var ( errAny = errors.New("any error") + absUnknown, _ = filepath.Abs(dirUnknown) + nameIFace = &Type{Name: iface} nameIFaceMock = &Type{Name: ifaceMock} @@ -126,7 +128,7 @@ func methodsMockIFaceFunc(mocktest, test, mock string) []*Method { }, { Name: "CallC", Params: []*Param{{ - Name: "test", Type: aliasType(test, "Tester"), + Name: "test", Type: aliasType(test, "Context"), }}, Results: []*Param{}, Variadic: false, @@ -136,7 +138,7 @@ func methodsMockIFaceFunc(mocktest, test, mock string) []*Method { var ( // Use two different singleton loaders. loaderMock = NewLoader(DirDefault) - loaderTest = NewLoader(dirTest) + loaderTest = NewLoader(dirSubTest) loaderFail = NewLoader(dirUnknown) // Use singleton template for testing. @@ -170,6 +172,14 @@ var ( pathTest, pathTesting, pathMock) methodsTestTest = []*Method{{ + Name: "Deadline", + Params: []*Param{}, + Results: []*Param{ + {Name: "deadline", Type: "time.Time"}, + {Name: "ok", Type: "bool"}, + }, + Variadic: false, + }, { Name: "Errorf", Params: []*Param{ {Name: "format", Type: "string"}, diff --git a/internal/mock/generate_test.go b/internal/mock/generate_test.go index 3867b85..3b32119 100644 --- a/internal/mock/generate_test.go +++ b/internal/mock/generate_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/tools/go/packages" "github.com/tkrop/go-testing/test" @@ -36,7 +37,7 @@ var ( return dir }() - fileFailure = filepath.Join(testDirGenerate, dirTest, fileUnknown) + fileFailure = filepath.Join(testDirGenerate, dirSubTest, fileUnknown) ) var testGenerateParams = map[string]GenerateParams{ @@ -49,8 +50,13 @@ var testGenerateParams = map[string]GenerateParams{ "failure parsing": { file: filepath.Join(testDirGenerate, MockFileDefault), args: []string{pathUnknown}, - expectStderr: "argument invalid [pos: 3, arg: " + pathUnknown + - "]: not found\n", + expectStderr: NewErrArgFailure(3, ".", + NewErrPackageParsing(pathUnknown, []*packages.Package{ + {Errors: []packages.Error{{ + Msg: "no required module provides package " + pathUnknown + + "; to add it:\n\tgo get " + pathUnknown, + }}}, + })).Error() + "\n", expectCode: 1, }, diff --git a/internal/mock/loader_test.go b/internal/mock/loader_test.go index 6299f8b..286d5fa 100644 --- a/internal/mock/loader_test.go +++ b/internal/mock/loader_test.go @@ -215,7 +215,7 @@ var testLoaderLoadParams = map[string]LoaderLoadParams{ "failure loading": { loader: loaderFail, source: targetTest.With(&Type{ - File: filepath.Join(dirUp, dirMock, dirTest, fileIFace), + File: filepath.Join(dirUp, dirMock, dirSubTest, fileIFace), }), expectError: NewErrLoading(pathTest, fmt.Errorf( "err: chdir %s: no such file or directory: stderr: ", @@ -315,7 +315,7 @@ var testLoaderIFacesParams = map[string]LoaderIFacesParams{ "failure loading": { loader: loaderFail, source: targetTest.With(&Type{ - File: filepath.Join(dirUp, dirMock, dirTest, fileIFace), + File: filepath.Join(dirUp, dirMock, dirSubTest, fileIFace), }), expectError: NewErrLoading(pathTest, fmt.Errorf( "err: chdir %s: no such file or directory: stderr: ", diff --git a/internal/mock/mock_iface_test.gox b/internal/mock/mock_iface_test.gox index 78ba46f..94982e8 100644 --- a/internal/mock/mock_iface_test.gox +++ b/internal/mock/mock_iface_test.gox @@ -74,13 +74,13 @@ func (mr *MockIFaceRecorder) CallB() *gomock.Call { } // CallC is the mock method to capture a coresponding call. -func (m *MockIFace) CallC(test testing_test.Tester) { +func (m *MockIFace) CallC(test testing_test.Context) { m.ctrl.T.Helper() m.ctrl.Call(m, "CallC", test) } // CallC is the recorder method to indicates an expected call. -func (mr *MockIFaceRecorder) CallC(test testing_test.Tester) *gomock.Call { +func (mr *MockIFaceRecorder) CallC(test testing_test.Context) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CallC", reflect.TypeOf((*MockIFace)(nil).CallC), test) diff --git a/internal/mock/parser.go b/internal/mock/parser.go index 568f39c..597cb5a 100644 --- a/internal/mock/parser.go +++ b/internal/mock/parser.go @@ -125,6 +125,9 @@ func (parser *Parser) Parse(args ...string) ([]*Mock, []error) { // value and returns the argument type with the remaining argument value. func (parser *Parser) argType(arg string) (argType, string) { if strings.Index(arg, "--") == 0 { + if !strings.Contains(arg, "=") { + return argTypeUnknown, arg + } return parser.argTypeParse(arg) } return parser.argTypeGuess(arg) @@ -217,26 +220,14 @@ func (parser *Parser) argTypeGuess(arg string) (argType, string) { } } - // TODO: Reconsider the early failure handling approach here. - // - // This early failure handing may not be necessary and undesired. It has - // the drawback to prevents generating mocks for broken code, as well as - // for partially loaded packages defined by incomplete sets of files. - // - // It looks not to complicated to drop the early failure handling and - // change the four effected tests. The alternative solution to drop support - // for partial package loading via single file loading or automatically - // load the whole package is not very desirable and complicates code. - // - // pkgs, _ := parser.loader.Load(arg).Get() - // if len(pkgs) > 0 { - pkgs, err := parser.loader.Load(arg).Get() - if len(pkgs) > 0 && err == nil { + pkgs, _ := parser.loader.Load(arg).Get() + if len(pkgs) > 0 { if pkgs[0].PkgPath == ReadFromFile { return argTypeSourceFile, arg } return argTypeSourcePath, arg } + // #no-cover: impossible to reach this code? return argTypeNotFound, arg } @@ -270,7 +261,7 @@ func (state *ParseState) ensureSource() *ParseState { // interfaces in the source package. func (state *ParseState) ensureIFace(pos int) { if state.source.IsPartial() { - state.creatMocks(pos, "") + state.creatMocks(pos, ".") } } @@ -282,7 +273,7 @@ func (state *ParseState) ensureState(arg string) { state.source.Update(state.loader) state.target.Update(state.loader) - if arg == "" { + if arg == "." { state.source.Name = MatchPatternDefault state.target.Name = MockPatternDefault return diff --git a/internal/mock/parser_test.go b/internal/mock/parser_test.go index 4cd371d..dc0c52d 100644 --- a/internal/mock/parser_test.go +++ b/internal/mock/parser_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" . "github.com/tkrop/go-testing/internal/mock" "github.com/tkrop/go-testing/test" + "golang.org/x/tools/go/packages" ) var ( @@ -34,11 +35,17 @@ var testParseParams = map[string]ParseParams{ Methods: methodsLoadIFace, }}, }, - "invalid argument": { + "invalid argument flag": { + loader: loaderTest, + args: []string{"--test"}, + expectError: []error{NewErrArgInvalid(0, "--test")}, + }, + "invalid argument unknown": { loader: loaderTest, args: []string{"--unknown=any"}, expectError: []error{NewErrArgInvalid(0, "--unknown=any")}, }, + // TODO: add test case for invalid argument for guessed type not found. "default file": { loader: loaderTest, @@ -140,13 +147,19 @@ var testParseParams = map[string]ParseParams{ loader: loaderMock, args: []string{pathUnknown}, expectError: []error{ - NewErrArgNotFound(0, pathUnknown), + NewErrArgFailure(0, ".", + NewErrPackageParsing(absUnknown, []*packages.Package{{ + Errors: []packages.Error{{ + Msg: "stat " + absUnknown + ": directory not found", + }}, + }}), + ), }, }, "source file explicit": { loader: loaderMock, - args: []string{"--source-file=" + dirTest + "/" + fileIFace}, + args: []string{"--source-file=" + dirSubTest + "/" + fileIFace}, expectMocks: []*Mock{{ Source: sourceIFaceAny, Target: targetMockTestIFace, @@ -155,7 +168,7 @@ var testParseParams = map[string]ParseParams{ }, "source file derived": { loader: loaderMock, - args: []string{"--source=" + dirTest + "/" + fileIFace}, + args: []string{"--source=" + dirSubTest + "/" + fileIFace}, expectMocks: []*Mock{{ Source: sourceIFaceAny, Target: targetMockTestIFace, @@ -174,7 +187,7 @@ var testParseParams = map[string]ParseParams{ }, "source file guessed": { loader: loaderMock, - args: []string{dirTest + "/" + fileIFace}, + args: []string{dirSubTest + "/" + fileIFace}, expectMocks: []*Mock{{ Source: sourceIFaceAny, Target: targetMockTestIFace, @@ -185,13 +198,20 @@ var testParseParams = map[string]ParseParams{ loader: loaderMock, args: []string{fileUnknown}, expectError: []error{ - NewErrArgNotFound(0, fileUnknown), + NewErrArgFailure(0, ".", + NewErrPackageParsing(fileUnknown, []*packages.Package{{ + Errors: []packages.Error{{ + Msg: "no required module provides package " + fileUnknown + + "; to add it:\n\tgo get " + fileUnknown, + }}, + }}), + ), }, }, "source directory explicit": { loader: loaderMock, - args: []string{"--source-file=" + dirTest}, + args: []string{"--source-file=" + dirSubTest}, expectMocks: []*Mock{{ Source: sourceIFaceAny, Target: targetMockTestIFace, @@ -200,7 +220,7 @@ var testParseParams = map[string]ParseParams{ }, "source directory derived": { loader: loaderMock, - args: []string{"--source=" + dirTest}, + args: []string{"--source=" + dirSubTest}, expectMocks: []*Mock{{ Source: sourceIFaceAny, Target: targetMockTestIFace, @@ -209,7 +229,7 @@ var testParseParams = map[string]ParseParams{ }, "source directory guessed": { loader: loaderMock, - args: []string{dirTest}, + args: []string{dirSubTest}, expectMocks: []*Mock{{ Source: sourceIFaceAny, Target: targetMockTestIFace, @@ -220,7 +240,13 @@ var testParseParams = map[string]ParseParams{ loader: loaderMock, args: []string{dirUnknown}, expectError: []error{ - NewErrArgNotFound(0, dirUnknown), + NewErrArgFailure(0, ".", + NewErrPackageParsing(dirUnknown, []*packages.Package{{ + Errors: []packages.Error{{ + Msg: "stat " + absUnknown + ": directory not found", + }}, + }}), + ), }, }, @@ -457,10 +483,10 @@ var testParseParams = map[string]ParseParams{ "failure loading": { loader: loaderFail, args: []string{ - "--source-file=" + filepath.Join(dirUp, dirMock, dirTest, fileIFace), + "--source-file=" + filepath.Join(dirUp, dirMock, dirSubTest, fileIFace), }, expectError: []error{ - NewErrArgFailure(0, "", NewErrLoading("", fmt.Errorf( + NewErrArgFailure(0, ".", NewErrLoading("", fmt.Errorf( "err: chdir %s: no such file or directory: stderr: ", dirUnknown))), }, @@ -488,7 +514,7 @@ var testParseAddParams = map[string]ParseParams{ "package test path": { loader: loaderMock, args: []string{ - dirTesting, "Test", + dirTest, "Test", "--target=" + pkgMock, "Reporter=Reporter", }, expectMocks: []*Mock{{ @@ -505,7 +531,7 @@ var testParseAddParams = map[string]ParseParams{ "package test file": { loader: loaderMock, args: []string{ - dirTesting + "/" + fileTesting, "Test", + dirTest + "/" + fileContext, "Test", "--target=" + pkgMock, "Reporter=Reporter", }, expectMocks: []*Mock{{ @@ -569,8 +595,5 @@ func TestParseMain(t *testing.T) { } func TestParseAdd(t *testing.T) { - test.Map(t, testParseAddParams). - // TODO: removed when Find feature migration implemented. - // Filter("package-test-file", true). - Run(testParse) + test.Map(t, testParseAddParams).Run(testParse) } diff --git a/internal/mock/test/iface.go b/internal/mock/test/iface.go index b790d68..e59333b 100644 --- a/internal/mock/test/iface.go +++ b/internal/mock/test/iface.go @@ -16,7 +16,7 @@ type IFace interface { CallA(value *Struct, args ...*reflect.Value) ([]any, error) //revive:disable-next-line:use-any // needed for testing CallB() (fn func([]*mock.File) []interface{}, err error) - CallC(test test.Tester) + CallC(test test.Context) } // Struct is a non-interface for testing. diff --git a/internal/mock/testx/iface.go b/internal/mock/testx/iface.go index 5893191..17efbf7 100644 --- a/internal/mock/testx/iface.go +++ b/internal/mock/testx/iface.go @@ -16,7 +16,7 @@ type IFace interface { CallA(value *Struct, args ...*reflect.Value) ([]any, error) //revive:disable-next-line:use-any // needed for testing CallB() (fn func([]*mock.File) []interface{}, err error) - CallC(test test.Tester) + CallC(test test.Context) } // Struct is a non-interface for testing. diff --git a/internal/reflect/reflect.go b/internal/reflect/reflect.go index 5acb386..9f1f6b0 100644 --- a/internal/reflect/reflect.go +++ b/internal/reflect/reflect.go @@ -7,8 +7,6 @@ import ( "errors" "fmt" "reflect" - "slices" - "unsafe" ) // Aliases for types. @@ -33,63 +31,6 @@ var ( ValueOf = reflect.ValueOf ) -// FindArgOf find the first argument with one of the given field names matching -// the type matching the default argument type. -// -// TODO: This function does not belong here and is tested insufficiently. -// TODO: This function may fail on pointer types. -func FindArgOf(param any, deflt any, names ...string) any { - t := reflect.TypeOf(param) - dt := reflect.TypeOf(deflt) - if t.Kind() != reflect.Struct { - if t.Kind() == dt.Kind() { - return param - } - return deflt - } - - v := reflect.ValueOf(param) - - found := false - for i := 0; i < t.NumField(); i++ { - fv := v.Field(i) - if fv.Type().Kind() == dt.Kind() { - if slices.Contains(names, t.Field(i).Name) { - return FieldArgOf(v, i) - } - if !found { - deflt = FieldArgOf(v, i) - found = true - } - } - } - return deflt -} - -// FieldArgOf returns the argument of the `i`th field of the given value. -// -// TODO: This function does not belong here and is tested insufficiently. -func FieldArgOf(v reflect.Value, i int) any { - vf := v.Field(i) - if vf.CanInterface() { - return ArgOf(vf) - } - - // Make a copy to circumvent access restrictions. - vr := reflect.New(v.Type()).Elem() - vr.Set(v) - - // Get the field value from the copy. - vf = vr.Field(i) - // #nosec G103 G115 -- is necessary. - rf := reflect.NewAt(vf.Type(), - unsafe.Pointer(vf.UnsafeAddr())).Elem() - // Create a new variable of the type P. - var value any - reflect.ValueOf(&value).Elem().Set(rf) - return value -} - // ArgOf returns the argument of the given value. func ArgOf(v reflect.Value) any { if !v.IsValid() { diff --git a/internal/reflect/reflect_test.go b/internal/reflect/reflect_test.go index 51bd141..880e804 100644 --- a/internal/reflect/reflect_test.go +++ b/internal/reflect/reflect_test.go @@ -30,176 +30,9 @@ func FuncTest() {} type ( BoolAlias bool StringAlias string - BoolParam struct{ value bool } - IntParam struct{ value int } - StringParam struct{ value string } ExportParam struct{ Value string } - StructParam struct{ value BoolParam } ) -type FindArgOfParams struct { - name string - param any - deflt any - expect any -} - -var testFindArgOfParams = map[string]FindArgOfParams{ - "no struct": { - name: "value", - param: "string", - deflt: true, - expect: true, - }, - - // Test bool type. - "bool value": { - name: "any", - param: true, - deflt: false, - expect: true, - }, - "bool found": { - name: "value", - param: BoolParam{value: true}, - deflt: false, - expect: true, - }, - "bool fallback": { - name: "fallback", - param: BoolParam{value: true}, - deflt: true, - expect: true, - }, - "bool not-found": { - name: "not-found", - param: StringParam{}, - deflt: true, - expect: true, - }, - - // Test int type. - "int value": { - name: "any", - param: 2, - deflt: 1, - expect: 2, - }, - "int found": { - name: "value", - param: IntParam{value: 2}, - deflt: 1, - expect: 2, - }, - "int fallback": { - name: "fallback", - param: IntParam{value: 2}, - deflt: 1, - expect: 2, - }, - "int not-found": { - name: "not-found", - param: StringParam{}, - deflt: 1, - expect: 1, - }, - - // Test string type. - "string value": { - name: "any", - param: "value", - deflt: "default", - expect: "value", - }, - "string found": { - name: "value", - param: StringParam{value: "value"}, - deflt: "default", - expect: "value", - }, - "string fallback": { - name: "fallback", - param: StringParam{value: "fallback"}, - deflt: "default", - expect: "fallback", - }, - "string not-found": { - name: "not-found", - param: BoolParam{}, - deflt: "default", - expect: "default", - }, - - // Test exported field. - "export found": { - name: "value", - param: ExportParam{Value: "value"}, - deflt: "default", - expect: "value", - }, - "export fallback": { - name: "fallback", - param: ExportParam{Value: "fallback"}, - deflt: "default", - expect: "fallback", - }, - "export not-found": { - name: "notfound", - param: BoolParam{}, - deflt: "default", - expect: "default", - }, - - // Test struct type. - "struct found": { - name: "value", - param: StructParam{ - value: BoolParam{value: true}, - }, - deflt: BoolParam{value: false}, - expect: BoolParam{value: true}, - }, - "struct fallback": { - name: "fallback", - param: StructParam{ - value: BoolParam{value: true}, - }, - deflt: BoolParam{value: false}, - expect: BoolParam{value: true}, - }, - "struct not-found": { - name: "not-found", - param: StringParam{}, - deflt: BoolParam{value: false}, - expect: BoolParam{value: false}, - }, - - // Test alias type - not working as intended. - "alias string": { - name: "value", - param: StringParam{value: "value"}, - deflt: StringAlias("default"), - expect: string("value"), - }, - "alias bool": { - name: "value", - param: BoolParam{value: true}, - deflt: BoolAlias(false), - expect: bool(true), - }, -} - -func TestFindArgOf(t *testing.T) { - test.Map(t, testFindArgOfParams). - Run(func(t test.Test, param FindArgOfParams) { - // When - value := reflect.FindArgOf(param.param, param.deflt, param.name) - - // Then - assert.Equal(t, param.expect, value) - }) -} - type ArgOfParams struct { value reflect.Value expect any diff --git a/test/README.md b/test/README.md index 80626fa..93b3c6a 100644 --- a/test/README.md +++ b/test/README.md @@ -43,7 +43,7 @@ started by `Run` that accepts a simple test function as input, using a ```go func TestUnit(t *testing.T) { - test.New|Slice|Map(t, testParams). + test.Set|Slice|Map(t, testParams). /* Filter("test-case-name", false|true). */ Run|RunSeq(func(t test.Test, param UnitParams){ // Given @@ -103,7 +103,7 @@ func TestUnit(t *testing.T) { If the above pattern is not sufficient, you can create your own customized parameterized, parallel, isolated test wrapper using the basic abstraction -`test.Run(test.Success|Failure, func (t test.Test) {})`: +`test.Run|RunSeq(test.Success|Failure, func (t test.Test) {})`: ```go func TestUnit(t *testing.T) { @@ -124,13 +124,14 @@ func TestUnit(t *testing.T) { } ``` -Or the interface of the underlying `test.Tester`: +Or directly use the more flexible `test.Context` providing the underlying +features including the `test.Test` interface abstraction: ```go func TestUnit(t *testing.T) { t.Parallel() - test.NewTester(t, test.Success).Run(func(t test.Test){ + test.New(t, test.Success).Run(func(t test.Test){ // Given // When @@ -140,7 +141,7 @@ func TestUnit(t *testing.T) { } ``` -But this should usually be unnecessary. +Feel free to explore. ## Isolated failure/panic validation @@ -174,7 +175,7 @@ func TestUnit(t *testing.T) { ``` **Note:** To enable panic testing, the isolated test environment is recovering -from all panics by default and converting it in a fatal error message. This is +from all panics by default and converting them in fatal error messages. This is often most usable and sufficient to fix the issue. If you need to discover the source of the panic, you need to spawn a new unrecovered go-routine. @@ -184,8 +185,8 @@ hard to recreate. Do not try it. ## Out-of-the-box test patterns -Currently, the package supports the following _out-of-the-box_ test pattern for -testing of `main`-methods of commands. +Currently, the package supports only one _out-of-the-box_ test pattern to test +the `main`-methods of commands. ```go testMainParams := map[string]test.MainParams{ @@ -201,13 +202,13 @@ func TestMain(t *testing.T) { } ``` -The pattern executes a the `main`-method in a separate process that allows to -setup the command line arguments (`Args`) as well as to modify the environment -variables (`Env`) and to capture and compare the exit code. +The pattern executes the `main`-method in a separate process that setting up +the command line arguments (`Args`) and modifying the environment variables +(`Env`) and to capture and compare the exit code of the program execution. **Note:** the general approach can be used to test any code calling `os.Exit`, -however, it is focused on testing methods without arguments parsing command -line arguments, i.e. in particular `func main() { ... }`. +however, it is focused on testing the `main`-methods with and without parsing +command line arguments. [gomock]: diff --git a/test/caller_test.go b/test/caller_test.go index 57c7376..f1af72d 100644 --- a/test/caller_test.go +++ b/test/caller_test.go @@ -52,7 +52,7 @@ func (c *Caller) Panic(_ any) { // getCaller implements the capturing logic for the callers file and line // number for the given call. func getCaller(call func(t test.Reporter)) string { - t := test.NewTester(&testing.T{}, test.Failure) + t := test.New(&testing.T{}, test.Failure) mocks := mock.NewMocks(t) caller := mock.Get(mocks, func(*gomock.Controller) *Caller { @@ -94,7 +94,7 @@ var ( }() // CallerTestErrorf provides the file with the line number of the `Errorf` // call in testing. - CallerTestErrorf = path.Join(SourceDir, "testing.go:238") + CallerTestErrorf = path.Join(SourceDir, "context.go:229") // CallerGomockErrorf provides the file with the line number of the // `Errorf` call in gomock. CallerGomockErrorf = path.Join(SourceDir, "gomock.go:61") diff --git a/test/common.go b/test/common.go new file mode 100644 index 0000000..0ee880a --- /dev/null +++ b/test/common.go @@ -0,0 +1,57 @@ +package test + +type ( + // Expect the expectation whether a test will succeed or fail. + Expect bool + // Name represents a test case name. + Name string +) + +// Constants to express test expectations. +const ( + // Success used to express that a test is supposed to succeed. + Success Expect = true + // Failure used to express that a test is supposed to fail. + Failure Expect = false + + // unknown default unknown test case name. + unknown Name = "unknown" + + // Flag to run test by default sequential instead of parallel. + Parallel = true +) + +// TODO: consider following convenience methods: +// +// // Result is a convenience method that returns the first argument ans swollows +// // all others assuming that the first argument contains the important result to +// // focus the test at. +// func Result[T any](result T, swollowed any) T { +// return result +// } + +// // Check is a convenience method that returns the second argument and swollows +// // the first used to focus a test on the second. +// func Check[T any](swollowed any, check T) T { +// return check +// } + +// // NoError is a convenience method to check whether the second error argument +// // is providing and actual error while extracting the first argument only. If +// // the error argument is an error, the method panics providing the error. +// func NoError[T any](result T, err error) T { +// if err != nil { +// panic(err) +// } +// return result +// } + +// // Ok is a convenience method to check whether the second boolean argument is +// // `true` while returning the first argument. If the boolean argument is +// // `false`, the method panics. +// func Ok[T any](result T, ok bool) T { +// if !ok { +// panic("bool not okay") +// } +// return result +// } diff --git a/test/common_test.go b/test/common_test.go new file mode 100644 index 0000000..26605cc --- /dev/null +++ b/test/common_test.go @@ -0,0 +1,243 @@ +package test_test + +import ( + "regexp" + "testing" + "time" + + "github.com/tkrop/go-testing/internal/sync" + "github.com/tkrop/go-testing/mock" + "github.com/tkrop/go-testing/test" +) + +// ParamParam is a test parameter type for the test runner to test evaluation +// of default test parameter names from the test parameter set. +type ParamParam struct { + name string + expect bool +} + +// TestParam is a generic test parameter type for testing the test context as +// well as the test runner using the same parameter sets. +type TestParam struct { + name test.Name + setup mock.SetupFunc + test func(test.Test) + expect test.Expect + consumed bool +} + +// TestParamMap is a map of test parameters for testing the test context as +// well as the test runner. +type TestParamMap map[string]TestParam + +// FilterBy filters the test parameters by the given pattern to test the +// filtering of the test runner. +func (m TestParamMap) FilterBy(pattern string) TestParamMap { + filter := regexp.MustCompile(pattern) + params := TestParamMap{} + for key, value := range m { + if filter.MatchString(key) { + params[key] = value + } + } + return params +} + +// GetSlice returns the test parameters as a slice of test parameters sets. +func (m TestParamMap) GetSlice() []TestParam { + params := make([]TestParam, 0, len(m)) + for name, param := range m { + params = append(params, TestParam{ + name: test.Name(name), + test: param.test, + expect: param.expect, + }) + } + return params +} + +// testParams is the generic map of test parameters for testing the test +// context as well as the test runner. +var testParams = TestParamMap{ + "base nothing": { + test: func(test.Test) {}, + expect: test.Success, + }, + "base errorf": { + test: func(t test.Test) { t.Errorf("fail") }, + expect: test.Failure, + }, + "base fatalf": { + test: func(t test.Test) { t.Fatalf("fail") }, + expect: test.Failure, + consumed: true, + }, + "base failnow": { + test: func(t test.Test) { t.FailNow() }, + expect: test.Failure, + consumed: true, + }, + "base panic": { + test: func(test.Test) { panic("fail") }, + expect: test.Failure, + consumed: true, + }, + + "inrun success": { + test: test.InRun(test.Success, + func(test.Test) {}), + expect: test.Success, + }, + "inrun success with errorf": { + test: test.InRun(test.Success, + func(t test.Test) { t.Errorf("fail") }), + expect: test.Failure, + }, + "inrun success with fatalf": { + test: test.InRun(test.Success, + func(t test.Test) { t.Fatalf("fail") }), + expect: test.Failure, + consumed: true, + }, + "inrun success with failnow": { + test: test.InRun(test.Success, + func(t test.Test) { t.FailNow() }), + expect: test.Failure, + consumed: true, + }, + "inrun success with panic": { + test: test.InRun(test.Success, + func(test.Test) { panic("fail") }), + expect: test.Failure, + consumed: true, + }, + + "inrun failure": { + test: test.InRun(test.Failure, + func(test.Test) {}), + expect: test.Failure, + }, + "inrun failure with errorf": { + test: test.InRun(test.Failure, + func(t test.Test) { t.Errorf("fail") }), + expect: test.Success, + }, + "inrun failure with fatalf": { + test: test.InRun(test.Failure, + func(t test.Test) { t.Fatalf("fail") }), + expect: test.Success, + consumed: true, + }, + "inrun failure with failnow": { + test: test.InRun(test.Failure, + func(t test.Test) { t.FailNow() }), + expect: test.Success, + consumed: true, + }, + "inrun failure with panic": { + test: test.InRun(test.Failure, + func(test.Test) { panic("fail") }), + expect: test.Success, + consumed: true, + }, +} + +// ExecTest is the generic function to execute a test with the given test +// parameters. +func ExecTest(t test.Test, param TestParam) { + // Given + if param.setup != nil { + mock.NewMocks(t).Expect(param.setup) + } + + wg := sync.NewLenientWaitGroup() + t.(*test.Context).WaitGroup(wg) + if param.consumed { + wg.Add(1) + } + + // When + param.test(t) + + // Then + wg.Wait() +} + +type TestDeadlineParam struct { + time, early, sleep time.Duration + expect mock.SetupFunc + finish bool +} + +// TODO: fix unit test for deadline!! +var TestDeadlineParams = map[string]TestDeadlineParam{ + "failed": { + finish: true, + time: 0, + early: 0, + sleep: time.Millisecond, + expect: test.Fatalf("finished regularly"), + }, + // "timeout": { + // time: time.Millisecond, + // early: 0, + // sleep: time.Second, + // expect: test.Fatalf("stopped by deadline"), + // }, + // "early": { + // time: 5 * time.Millisecond, + // early: 4 * time.Millisecond, + // sleep: 4 * time.Millisecond, + // expect: test.Fatalf("stopped by deadline"), + // }, + "to-late": { + finish: true, + time: 5 * time.Millisecond, + early: 1 * time.Millisecond, + sleep: 1 * time.Millisecond, + expect: test.Fatalf("finished regularly"), + }, + // TODO: test the parent runner with deadline. + // "parent": { + // time: 0, + // early: 0, + // sleep: 12 * time.Millisecond, + // expect: test.Fatalf("stopped by deadline"), + // }, +} + +func TestDeadline(t *testing.T) { + t.Parallel() + + defer func() { recover() }() + + test.Map(t, TestDeadlineParams). + Timeout(200 * time.Millisecond).StopEarly(0). + Run(func(t test.Test, param TestDeadlineParam) { + // Given + if !param.finish { + mock.NewMocks(t).Expect(param.expect) + } + + test.New(t, test.Success). + Timeout(param.time).StopEarly(param.early). + Run(func(t test.Test) { + // Given + if param.finish { + mock.NewMocks(t).Expect(param.expect) + } + + // When + time.Sleep(param.sleep) + + // Then + if param.finish { + t.Fatalf("finished regularly") + } + }, !test.Parallel) + time.Sleep(20 * time.Millisecond) + }) + + time.Sleep(200 * time.Millisecond) +} diff --git a/test/context.go b/test/context.go new file mode 100644 index 0000000..cd7259a --- /dev/null +++ b/test/context.go @@ -0,0 +1,400 @@ +package test + +import ( + "math" + "runtime" + "runtime/debug" + "strings" + gosync "sync" + "sync/atomic" + "testing" + "time" + + "github.com/tkrop/go-testing/internal/slices" + "github.com/tkrop/go-testing/internal/sync" +) + +// Test is a minimal interface for abstracting test methods that are needed to +// setup an isolated test environment for GoMock and Testify. +type Test interface { + // Name provides the test name. + Name() string + // Helper declares a test helper function. + Helper() + // Parallel declares that the test is to be run in parallel with (and only + // with) other parallel tests. + Parallel() + // Deadline returns the deadline of the test and a flag indicating whether + // the deadline is set. + Deadline() (deadline time.Time, ok bool) + // TempDir creates a new temporary directory for the test. + TempDir() string + // Errorf handles a failure messages when a test is supposed to continue. + Errorf(format string, args ...any) + // Fatalf handles a fatal failure message that immediate aborts of the test + // execution. + Fatalf(format string, args ...any) + // FailNow handles fatal failure notifications without log output that + // aborts test execution immediately. + FailNow() + // Setenv sets an environment variable for the test. + Setenv(key, value string) +} + +// Reporter is a minimal interface for abstracting test report methods that are +// needed to setup an isolated test environment for GoMock and Testify. +type Reporter interface { + // Panic reports a panic. + Panic(arg any) + // Errorf reports a failure messages when a test is supposed to continue. + Errorf(format string, args ...any) + // Fatalf reports a fatal failure message that immediate aborts of the test + // execution. + Fatalf(format string, args ...any) + // FailNow reports fatal failure notifications without log output that + // aborts test execution immediately. + FailNow() +} + +// Cleanuper defines an interface to add a custom mehtod that is called after +// the test execution to cleanup the test environment. +type Cleanuper interface { + Cleanup(cleanup func()) +} + +// Run creates an isolated (by default) parallel test context running the given +// test function with given expectation. If the expectation is not met, a test +// failure is created in the parent test context. +func Run(expect Expect, test func(Test)) func(*testing.T) { + return func(t *testing.T) { + t.Helper() + + New(t, expect).Run(test, Parallel) + } +} + +// RunSeq creates an isolated, test context for the given test function with +// given expectation. If the expectation is not met, a test failure is created +// in the parent test context. +func RunSeq(expect Expect, test func(Test)) func(*testing.T) { + return func(t *testing.T) { + t.Helper() + + New(t, expect).Run(test, !Parallel) + } +} + +// InRun creates an isolated, (by default) sequential test context for the +// given test function with given expectation. If the expectation is not met, a +// test failure is created in the parent test context. +func InRun(expect Expect, test func(Test)) func(Test) { + return func(t Test) { + t.Helper() + + New(t, expect).Run(test, !Parallel) + } +} + +// Context is a test isolation environment based on the `Test` abstraction. It +// can be used as a drop in replacement for `testing.T` in various libraries +// to check for expected test failures. +type Context struct { + sync.Synchronizer + t Test + wg sync.WaitGroup + mu gosync.Mutex + failed atomic.Bool + deadline time.Time + reporter Reporter + cleanups []func() + expect Expect +} + +// New creates a new minimal isolated test context based on the given test +// context with the given expectation. The parent test context is used to +// delegate methods calls to the parent context to propagate test results. +func New(t Test, expect Expect) *Context { + if tx, ok := t.(*Context); ok { + return &Context{ + t: tx, wg: tx.wg, + expect: expect, + deadline: tx.deadline, + } + } + + return &Context{ + t: t, expect: expect, + deadline: func(t Test) time.Time { + defer func() { _ = recover() }() + deadline, _ := t.Deadline() + return deadline + }(t), + } +} + +// Timeout sets up an individual timeout for the test. This does not affect the +// global test timeout or a pending parent timeout that may abort the test, if +// the given duration is exceeding the timeout. A negative or zero duration is +// ignored and will not change the timeout. +func (t *Context) Timeout(timeout time.Duration) *Context { + if timeout > 0 { + t.deadline = time.Now().Add(timeout) + } + return t +} + +// StopEarly stops the test by the given duration ahead of the individual or +// global test deadline, to ensure that a cleanup function has sufficient time +// to finish before a global deadline exceeds. The method is not able to extend +// the test deadline. A negative or zero duration is ignored. +// +// Warning: calling this method multiple times will also reduce the deadline +// step by step. +func (t *Context) StopEarly(time time.Duration) *Context { + if !t.deadline.IsZero() && time > 0 { + t.deadline = t.deadline.Add(-time) + } + return t +} + +// WaitGroup adds wait group to unlock in case of a failure. +// +//revive:disable-next-line:waitgroup-by-value // own wrapper interface +func (t *Context) WaitGroup(wg sync.WaitGroup) { + t.wg = wg +} + +// Reporter sets up a test failure reporter. This can be used to validate the +// reported failures in a test environment. +func (t *Context) Reporter(reporter Reporter) { + t.reporter = reporter +} + +// Cleanup is a function called to setup test cleanup after execution. This +// method is allowing `gomock` to register its `finish` method that reports the +// missing mock calls. +func (t *Context) Cleanup(cleanup func()) { + t.mu.Lock() + defer t.mu.Unlock() + t.cleanups = append(t.cleanups, cleanup) +} + +// Name delegates the request to the parent test context. +func (t *Context) Name() string { + return t.t.Name() +} + +// Helper delegates request to the parent test context. +func (t *Context) Helper() { + t.t.Helper() +} + +// Parallel robustly delegates request to the parent context. It can be called +// multiple times, since it is swallowing the panic that is raised when calling +// `t.Parallel()` multiple times. +func (t *Context) Parallel() { + defer t.recover() + t.t.Parallel() +} + +// Deadline delegates request to the parent context. It returns the deadline of +// the test and a flag indicating whether the deadline is set. +func (t *Context) Deadline() (time.Time, bool) { + if !t.deadline.IsZero() { + return t.deadline, true + } + return t.t.Deadline() +} + +// Setenv delegates request to the parent context, if it is of type +// `*testing.T`. Else it is swallowing the request silently. +func (t *Context) Setenv(key, value string) { + t.t.Setenv(key, value) +} + +// TempDir delegates the request to the parent test context. +func (t *Context) TempDir() string { + return t.t.TempDir() +} + +// Errorf handles failure messages where the test is supposed to continue. On +// an expected success, the failure is also delegated to the parent test +// context. Else it delegates the request to the test reporter if available. +func (t *Context) Errorf(format string, args ...any) { + t.Helper() + t.failed.Store(true) + if t.expect == Success { + t.t.Errorf(format, args...) + } else if t.reporter != nil { + t.reporter.Errorf(format, args...) + } +} + +// Fatalf handles a fatal failure message that immediate aborts of the test +// execution. On an expected success, the failure handling is also delegated +// to the parent test context. Else it delegates the request to the test +// reporter if available. +func (t *Context) Fatalf(format string, args ...any) { + t.Helper() + t.failed.Store(true) + defer t.unlock() + if t.expect == Success { + t.t.Fatalf(format, args...) + } else if t.reporter != nil { + t.reporter.Fatalf(format, args...) + } + runtime.Goexit() +} + +// FailNow handles fatal failure notifications without log output that aborts +// test execution immediately. On an expected success, it the failure handling +// is also delegated to the parent test context. Else it delegates the request +// to the test reporter if available. +func (t *Context) FailNow() { + t.Helper() + t.failed.Store(true) + defer t.unlock() + if t.expect == Success { + t.t.FailNow() + } else if t.reporter != nil { + t.reporter.FailNow() + } + runtime.Goexit() +} + +// Offset fr original stack in case of panic handling. +const panicOriginStackOffset = 10 + +// Panic handles failure notifications of panics that also abort the test +// execution immediately. +func (t *Context) Panic(arg any) { + t.Helper() + t.failed.Store(true) + defer t.unlock() + if t.expect == Success { + stack := strings.SplitN(string(debug.Stack()), "\n", + panicOriginStackOffset) + t.Fatalf("panic: %v\n%s\n%s", arg, stack[0], + stack[panicOriginStackOffset-1]) + } else if t.reporter != nil { + t.reporter.Panic(arg) + } + runtime.Goexit() +} + +// Run executes the test function in a safe detached environment and check +// the failure state after the test function has finished. If the test result +// is not according to expectation, a failure is created in the parent test +// context. +func (t *Context) Run(test func(Test), parallel bool) Test { + t.Helper() + if parallel { + t.Parallel() + } + + // register cleanup handlers. + t.register() + + // setup shorter deadline to run test. + done := make(chan any, 1) + wait := time.Duration(math.MaxInt64) + if deadline, ok := t.Deadline(); ok { + wait = time.Until(deadline) + } + + // execute test function. + go t.run(test, done) + // wait for test to finish or deadline to expire. + select { + case <-time.After(wait): + // TODO: activate? + // t.t.Fatalf("stopped by deadline") + case <-done: + } + + return t +} + +// run executes the test function in a safe, detached test environment. The +// function reports execution failure to the parent test context and unlocks +// the waiting test context. +// +// The function is supposed to be called in a goroutine. +func (t *Context) run(test func(Test), done chan any) { + t.Helper() + defer func() { + t.Helper() + if arg := recover(); arg != nil { + done <- arg + t.Panic(arg) + } else { + done <- nil + } + }() + test(t) +} + +// register registers the clean up handlers with the parent test context. +func (t *Context) register() { + t.Helper() + + if c, ok := t.t.(Cleanuper); ok { + c.Cleanup(func() { + t.Helper() + t.cleanup() + }) + } + + t.Cleanup(func() { + t.Helper() + t.finish() + }) +} + +// cleanup runs the cleanup methods registered on the isolated test environment. +func (t *Context) cleanup() { + t.mu.Lock() + cleanups := slices.Reverse(t.cleanups) + t.mu.Unlock() + + for _, cleanup := range cleanups { + cleanup() + } +} + +// finish evaluates the final result of the test function in relation to the +// provided expectation. +func (t *Context) finish() { + t.mu.Lock() + defer t.mu.Unlock() + + switch t.expect { + case Success: + if t.failed.Load() { + t.t.Errorf("Expected test to succeed but it failed: %s", t.t.Name()) + } + case Failure: + if !t.failed.Load() { + t.t.Errorf("Expected test to fail but it succeeded: %s", t.t.Name()) + } + } +} + +// recover recovers from panics when calling `t.Parallel()` multiple times. +func (t *Context) recover() { + t.Helper() + + //revive:disable-next-line:defer // only used inside a deferred call. + if v := recover(); v != nil && + v != "testing: t.Parallel called multiple times" { + t.Panic(v) + } +} + +// unlock unlocks the wait group of the test by consuming the wait group +// counter completely. +func (t *Context) unlock() { + if t.wg != nil { + t.wg.Add(math.MinInt) + } +} diff --git a/test/context_test.go b/test/context_test.go new file mode 100644 index 0000000..2727ab5 --- /dev/null +++ b/test/context_test.go @@ -0,0 +1,124 @@ +package test_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/tkrop/go-testing/mock" + "github.com/tkrop/go-testing/test" +) + +// TestRun is testing the test context with single test cases running in +// parallel. +func TestRun(t *testing.T) { + t.Parallel() + + for name, param := range testParams { + name, param := name, param + t.Run(name, test.Run(param.expect, func(t test.Test) { + ExecTest(t, param) + })) + } +} + +// TestRunSeq is testing the test context with single test cases running in +// sequence. +func TestRunSeq(t *testing.T) { + t.Parallel() + + for name, param := range testParams { + name, param := name, param + t.Run(name, test.RunSeq(param.expect, func(t test.Test) { + ExecTest(t, param) + })) + } +} + +// TestTempDir is testing the test context creating temporary directory. +func TestTempDir(t *testing.T) { + t.Parallel() + + t.Run("create", test.Run(test.Success, func(t test.Test) { + assert.NotEmpty(t, t.TempDir()) + })) +} + +// PanicParam is a test parameter type for testing the test context with panic +// cases. +type PanicParam struct { + parallel bool + before func(test.Test) + during func(test.Test) + expect mock.SetupFunc +} + +// testPanicParams is a map of test parameters for testing the test context with +// panic cases. +var testPanicParams = map[string]PanicParam{ + "setenv in run without parallel": { + during: func(t test.Test) { + t.Setenv("TESTING", "during") + assert.Equal(t, "during", os.Getenv("TESTING")) + }, + }, + + "setenv in run with parallel": { + parallel: true, + during: func(t test.Test) { + t.Setenv("TESTING", "during") + assert.Equal(t, "during", os.Getenv("TESTING")) + }, + expect: test.Panic("testing: t.Setenv called after t.Parallel;" + + " cannot set environment variables in parallel tests"), + }, + + "setenv before run without parallel": { + before: func(t test.Test) { + t.Setenv("TESTING", "before") + assert.Equal(t, "before", os.Getenv("TESTING")) + }, + during: func(t test.Test) { + t.Setenv("TESTING", "during") + assert.Equal(t, "during", os.Getenv("TESTING")) + }, + }, + + "setenv before run with parallel": { + parallel: true, + before: func(t test.Test) { + t.Setenv("TESTING", "before") + assert.Equal(t, "before", os.Getenv("TESTING")) + }, + expect: test.Panic("testing: t.Parallel called after t.Setenv;" + + " cannot set environment variables in parallel tests"), + }, + + "swallow multiple parallel calls": { + during: func(t test.Test) { + t.Parallel() + t.Parallel() + }, + }, +} + +// TestContextPanic is testing the test context with panic cases. +func TestContextPanic(t *testing.T) { + for name, param := range testPanicParams { + name, param := name, param + t.Run(name, test.RunSeq(test.Success, func(t test.Test) { + // Given + if param.before != nil { + mock.NewMocks(t).Expect(param.expect) + param.before(t) + } + + // When + test.New(t, test.Success).Run(func(t test.Test) { + mock.NewMocks(t).Expect(param.expect) + param.during(t) + }, param.parallel) + })) + } +} diff --git a/test/gomock.go b/test/gomock.go index ef56419..87c627c 100644 --- a/test/gomock.go +++ b/test/gomock.go @@ -25,10 +25,10 @@ type Recorder struct { func NewValidator(ctrl *gomock.Controller) *Validator { validator := &Validator{ctrl: ctrl} validator.recorder = &Recorder{validator: validator} - if t, ok := ctrl.T.(*Tester); ok { + if t, ok := ctrl.T.(*Context); ok { // We need to install a second isolated test environment to break the // reporter cycle on the failure issued by the mock controller. - ctrl.T = NewTester(t.t, t.expect) + ctrl.T = New(t.t, t.expect) t.expect = Failure t.Reporter(validator) } @@ -162,7 +162,7 @@ func MissingCalls( // Creates a new mock controller and test environment to isolate the // validator used for sub-call creation/registration from the validator // used for execution. - mocks := mock.NewMocks(NewTester(t, false)) + mocks := mock.NewMocks(New(t, false)) calls := make([]func(*mock.Mocks) any, 0, len(setups)) for _, setup := range setups { calls = append(calls, diff --git a/test/gomock_test.go b/test/gomock_test.go index 2f353b7..5a0225d 100644 --- a/test/gomock_test.go +++ b/test/gomock_test.go @@ -147,8 +147,8 @@ func evalCall(arg any, mocks *mock.Mocks) any { func TestCallMatcher(t *testing.T) { test.Map(t, testCallMatcherParams). Run(func(t test.Test, param MatcherParams) { - // Given - send mock calls to unchecked tester. - mocks := mock.NewMocks(test.NewTester(t, test.Success)) + // Given - send mock calls to unchecked test context. + mocks := mock.NewMocks(test.New(t, test.Success)) matcher := param.matcher(evalCall(param.base, mocks)) // When diff --git a/test/pattern.go b/test/pattern.go index d0f1505..b5bca0d 100644 --- a/test/pattern.go +++ b/test/pattern.go @@ -53,7 +53,7 @@ func TestMain(main func()) func(t Test, param MainParams) { // Call the main function in a separate process to prevent capture // regular process exit behavior. // #nosec G204 -- secured by calling only the test instance. - cmd := exec.Command(os.Args[0], "-test.run="+t.(*Tester).t.Name()) + cmd := exec.Command(os.Args[0], "-test.run="+t.(*Context).t.Name()) cmd.Env = append(append(os.Environ(), "TEST="+t.Name()), param.Env...) if err := cmd.Run(); err != nil || param.ExitCode != 0 { errExit := &exec.ExitError{} diff --git a/test/reflect.go b/test/reflect.go index 6287e9b..7bef001 100644 --- a/test/reflect.go +++ b/test/reflect.go @@ -6,24 +6,48 @@ import ( "unsafe" ) -// Builder is a generic interface that allows you to access and modify -// unexported fields of a (pointer) struct by field name. -type Builder[T any] interface { - // Set sets the value of the field with the given name. If the name is empty, - // and of the same type the stored target instance is replaced by the given - // value. - Set(name string, value any) Builder[T] +// Getter is a generic interface that allows you to access unexported fields +// of a (pointer) struct by field name. +type Getter[T any] interface { // Get returns the value of the field with the given name. If the name is // empty, the stored target instance is returned. Get(name string) any +} + +// Setter is a generic fluent interface that allows you to modify unexported +// fields of a (pointer) struct by field name. +type Setter[T any] interface { + // Set sets the value of the field with the given name. If the name is empty, + // and of the same type the stored target instance is replaced by the given + // value. + Set(name string, value any) Setter[T] + // Build returns the created or modified target instance of the builder. + Build() T +} + +// Finder is a generic interface that allows you to access unexported fields +// of a (pointer) struct by field name. +type Finder[T any] interface { // Find returns the first value of a field from the given list of field // names with a type matching the default value type. If the name list is // empty or contains a star (`*`), the first matching field in order of the // struct declaration is returned as fallback. If no matching field is // found, the default value is returned. Find(dflt any, names ...string) any - // Build returns the created/modified target instance of the builder. - Build() T +} + +// Builder is a generic, partially fluent interface that allows you to access +// and modify unexported fields of a (pointer) struct by field name. +type Builder[T any] interface { + // Getter is a generic interface that allows you to access unexported fields + // of a (pointer) struct by field name. + Getter[T] + // Finder is a generic interface that allows you to access unexported fields + // of a (pointer) struct by field name. + Finder[T] + // Setter is a generic fluent interface that allows you to modify unexported + // fields of a (pointer) struct by field name. + Setter[T] } // Find returns the first value of a parameter field from the given list of @@ -40,18 +64,24 @@ func Find[P, T any](param P, deflt T, names ...string) T { if pt.Kind() == dt.Kind() { return reflect.ValueOf(param).Interface().(T) } else if pt.Kind() == reflect.Struct { - // TODO: This is currently not working as expected and creates panics. - return NewAccessor[*P](¶m).Find(deflt, names...).(T) + return NewAccessor[P](param).Find(deflt, names...).(T) } else if pt.Kind() == reflect.Ptr && pt.Elem().Kind() == reflect.Struct { return NewAccessor[P](param).Find(deflt, names...).(T) } return deflt } -// Builder allows you to access and modify unexported fields of a struct. +// Builder is used for accessing and modifying unexported fields in a struct +// or a struct pointer. type builder[T any] struct { - target any - rtype reflect.Type + // target is the struct reflection value of the struct or struct pointer + // instance to be accessed and modified. + target any + // rtype is the targets reflection type of the struct or struct pointer + // instance to be accessed and modified. + rtype reflect.Type + // wrapped is true if the target is a struct that is actually wrapped in + // a pointer instance. wrapped bool } @@ -63,14 +93,35 @@ func NewBuilder[T any]() Builder[T] { return NewAccessor[T](target) } +// NewGetter creates a generic getter for a target struct type. The getter +// allows you to access unexported fields of the struct by field name. +func NewGetter[T any](target T) Getter[T] { + return NewAccessor[T](target) +} + +// NewSetter creates a generic setter for a target struct type. The setter +// allows you to modify unexported fields of the struct by field name. +func NewSetter[T any](target T) Setter[T] { + return NewAccessor[T](target) +} + +// NewFinder creates a generic finder for a target struct type. The finder +// allows you to access unexported fields of the struct by field name. +func NewFinder[T any](target T) Finder[T] { + return NewAccessor[T](target) +} + // NewAccessor creates a generic builder/accessor for a given target struct. // The builder allows you to access and modify unexported fields of the struct // by field name. // // If the target is a pointer to a struct (template), the pointer is stored -// and the instance is modified directly. If the target is a struct, it is -// ignored and a new pointer struct is created for modification, since a struct -// cannot be modified directly by reflection. +// and the instance is modified directly. If the pointer is nil a new instance +// is created and stored for modification. +// +// If the target is a struct, it cannot be modified directly and a new pointer +// struct is created to circumvent the access restrictions on private fields. +// The pointer struct is stored for modification. func NewAccessor[T any](target T) Builder[T] { value := reflect.ValueOf(target) @@ -83,13 +134,15 @@ func NewAccessor[T any](target T) Builder[T] { if value.Elem().Kind() == reflect.Struct { return &builder[T]{ - target: target, - rtype: value.Elem().Type(), + target: target, + rtype: value.Elem().Type(), + wrapped: false, } } } else if value.Kind() == reflect.Struct { // Create a new pointer instance for modification. value = reflect.New(value.Type()) + value.Elem().Set(reflect.ValueOf(target)) return &builder[T]{ target: value.Interface(), rtype: value.Elem().Type(), @@ -115,7 +168,7 @@ func typeOf(target any) string { // value. If the value is nil, the field is set to the zero value of the field // type. If the field is not found or the value is not assignable to it, a // panic is raised. -func (b *builder[T]) Set(name string, value any) Builder[T] { +func (b *builder[T]) Set(name string, value any) Setter[T] { if name != "" { b.set(name, value) } else if value == nil && b.rtype.Kind() == reflect.Struct { @@ -132,21 +185,23 @@ func (b *builder[T]) Set(name string, value any) Builder[T] { // Get returns the value of the field with the given name. If the name is // empty, the stored target instance is returned. func (b *builder[T]) Get(name string) any { - if name != "" { - target := b.targetValueOf() - if !target.IsValid() { - if field, ok := b.rtype.FieldByName(name); ok { - return reflect.New(field.Type).Elem().Interface() - } - panic("target field not found [" + name + "]") - } - field := target.FieldByName(name) - if !field.IsValid() { - panic("target field not found [" + name + "]") + if name == "" { + return b.Build() + } + + target := b.targetValueOf() + if !target.IsValid() { + if field, ok := b.rtype.FieldByName(name); ok { + return reflect.New(field.Type).Elem().Interface() } - return b.valuePtr(field).Elem().Interface() + panic("target field not found [" + name + "]") + } + + field := target.FieldByName(name) + if !field.IsValid() { + panic("target field not found [" + name + "]") } - return b.Build() + return b.valuePtr(field).Elem().Interface() } // Find returns the first value of a field from the given list of field names @@ -219,7 +274,7 @@ func (builder[T]) canBeAssigned(field reflect.Type, value reflect.Type) bool { } else if field.Kind() == reflect.Interface { return value.Implements(field) } else { - return field.AssignableTo(value) + return value.AssignableTo(field) } } diff --git a/test/reflect_test.go b/test/reflect_test.go index 5f3c668..effb082 100644 --- a/test/reflect_test.go +++ b/test/reflect_test.go @@ -34,18 +34,18 @@ type testBuilderStructParam struct { } var testBuilderStructParams = map[string]testBuilderStructParam{ - "struct get init - empty - no copy possible": { + "struct get init": { target: structInit, check: func(t test.Test, b test.Builder[Struct]) { - assert.Equal(t, "", b.Get("s")) - assert.Equal(t, nil, b.Get("a")) - assert.Equal(t, "", b.Find("default", "s")) - assert.Equal(t, nil, b.Find("default", "a")) - assert.Equal(t, "", b.Find("default")) - assert.Equal(t, "", b.Find("default", "*")) + assert.Equal(t, "init", b.Get("s")) + assert.Equal(t, "init", b.Get("a")) + assert.Equal(t, "init", b.Find("default", "s")) + assert.Equal(t, "default", b.Find("default", "a")) + assert.Equal(t, "init", b.Find("default")) + assert.Equal(t, "init", b.Find("default", "*")) assert.Equal(t, "default", b.Find("default", "x")) - assert.Equal(t, structEmpty, b.Get("")) - assert.Equal(t, structEmpty, b.Build()) + assert.Equal(t, structInit, b.Get("")) + assert.Equal(t, structInit, b.Build()) }, }, @@ -210,7 +210,7 @@ var testBuilderPtrStructParams = map[string]testBuilderPtrStructParam{ assert.Equal(t, "", b.Get("s")) assert.Equal(t, nil, b.Get("a")) assert.Equal(t, "", b.Find("default", "s")) - assert.Equal(t, nil, b.Find("default", "a")) + assert.Equal(t, "default", b.Find("default", "a")) assert.Equal(t, "", b.Find("default")) assert.Equal(t, "", b.Find("default", "*")) assert.Equal(t, "default", b.Find("default", "x")) @@ -309,7 +309,8 @@ var testBuilderPtrStructParams = map[string]testBuilderPtrStructParam{ "nil any reset nil invalid": { target: nil, setup: func(b test.Builder[*Struct]) { - b.Set("", (*Struct)(nil)).Get("invalid") + b.Set("", (*Struct)(nil)) + b.Get("invalid") }, expect: test.Panic("target field not found [invalid]"), }, @@ -321,7 +322,7 @@ var testBuilderPtrStructParams = map[string]testBuilderPtrStructParam{ assert.Equal(t, "", b.Get("s")) assert.Equal(t, nil, b.Get("a")) assert.Equal(t, "", b.Find("default", "s")) - assert.Equal(t, nil, b.Find("default", "a")) + assert.Equal(t, "default", b.Find("default", "a")) assert.Equal(t, "", b.Find("default")) assert.Equal(t, "", b.Find("default", "*")) assert.Equal(t, "default", b.Find("default", "x")) @@ -413,7 +414,7 @@ var testBuilderPtrStructParams = map[string]testBuilderPtrStructParam{ assert.Equal(t, "init", b.Get("s")) assert.Equal(t, "init", b.Get("a")) assert.Equal(t, "init", b.Find("default", "s")) - assert.Equal(t, "init", b.Find("default", "a")) + assert.Equal(t, "default", b.Find("default", "a")) assert.Equal(t, "init", b.Find("default")) assert.Equal(t, "init", b.Find("default", "*")) assert.Equal(t, "default", b.Find("default", "x")) @@ -526,18 +527,18 @@ var testBuilderAnyParams = map[string]testBuilderAnyParam{ }, // Test cases for struct instance. - "struct get init - empty - no copy possible": { + "struct get init": { target: structInit, check: func(t test.Test, b test.Builder[any]) { - assert.Equal(t, "", b.Get("s")) - assert.Equal(t, nil, b.Get("a")) - assert.Equal(t, "", b.Find("default", "s")) - assert.Equal(t, nil, b.Find("default", "a")) - assert.Equal(t, "", b.Find("default")) - assert.Equal(t, "", b.Find("default", "*")) + assert.Equal(t, "init", b.Get("s")) + assert.Equal(t, "init", b.Get("a")) + assert.Equal(t, "init", b.Find("default", "s")) + assert.Equal(t, "default", b.Find("default", "a")) + assert.Equal(t, "init", b.Find("default")) + assert.Equal(t, "init", b.Find("default", "*")) assert.Equal(t, "default", b.Find("default", "x")) - assert.Equal(t, structEmpty, b.Get("")) - assert.Equal(t, structEmpty, b.Build()) + assert.Equal(t, structInit, b.Get("")) + assert.Equal(t, structInit, b.Build()) }, }, @@ -634,7 +635,7 @@ var testBuilderAnyParams = map[string]testBuilderAnyParam{ assert.Equal(t, "init", b.Get("s")) assert.Equal(t, "init", b.Get("a")) assert.Equal(t, "init", b.Find("default", "s")) - assert.Equal(t, "init", b.Find("default", "a")) + assert.Equal(t, "default", b.Find("default", "a")) assert.Equal(t, "init", b.Find("default")) assert.Equal(t, "init", b.Find("default", "*")) assert.Equal(t, "default", b.Find("default", "x")) @@ -716,7 +717,7 @@ var testBuilderAnyParams = map[string]testBuilderAnyParam{ assert.Equal(t, "", b.Get("s")) assert.Equal(t, nil, b.Get("a")) assert.Equal(t, "", b.Find("default", "s")) - assert.Equal(t, nil, b.Find("default", "a")) + assert.Equal(t, "default", b.Find("default", "a")) assert.Equal(t, "", b.Find("default")) assert.Equal(t, "", b.Find("default", "*")) assert.Equal(t, "default", b.Find("default", "x")) @@ -809,34 +810,6 @@ func TestBuilderAny(t *testing.T) { }) } -func TestNewBuilderStruct(t *testing.T) { - // Given - builder := test.NewBuilder[Struct]() - - // When - builder.Set("s", "set final").Set("a", "set final") - - // Then - assert.Equal(t, "set final", builder.Get("s")) - assert.Equal(t, "set final", builder.Get("a")) - assert.Equal(t, structFinal, builder.Get("")) - assert.Equal(t, structFinal, builder.Build()) -} - -func TestNewBuilderPtrStruct(t *testing.T) { - // Given - builder := test.NewBuilder[*Struct]() - - // When - builder.Set("s", "set final").Set("a", "set final") - - // Then - assert.Equal(t, "set final", builder.Get("s")) - assert.Equal(t, "set final", builder.Get("a")) - assert.Equal(t, structPtrFinal, builder.Get("")) - assert.Equal(t, structPtrFinal, builder.Build()) -} - type testFindParam struct { param any deflt any @@ -920,8 +893,6 @@ var testFindParams = map[string]testFindParam{ func TestFind(t *testing.T) { test.Map(t, testFindParams). - // TODO: advance find to support basic structs. - Filter("^struct", false). Run(func(t test.Test, param testFindParam) { // When expect := test.Find(param.param, param.deflt, param.names...) @@ -930,3 +901,153 @@ func TestFind(t *testing.T) { assert.Equal(t, param.expect, expect) }) } + +func TestNewBuilder(t *testing.T) { + t.Parallel() + + t.Run("builder-struct", func(t *testing.T) { + t.Parallel() + // Given + b := test.NewBuilder[Struct]() + + // When + b.Set("s", "set final").Set("a", "set final") + + // Then + assert.Equal(t, "set final", b.Get("s")) + assert.Equal(t, "set final", b.Get("a")) + assert.Equal(t, structFinal, b.Get("")) + assert.Equal(t, structFinal, b.Build()) + }) + + t.Run("builder-ptr", func(t *testing.T) { + t.Parallel() + // Given + b := test.NewBuilder[*Struct]() + + // When + b.Set("s", "set final").Set("a", "set final") + + // Then + assert.Equal(t, "set final", b.Get("s")) + assert.Equal(t, "set final", b.Get("a")) + assert.Equal(t, structPtrFinal, b.Get("")) + assert.Equal(t, structPtrFinal, b.Build()) + }) + + t.Run("setter-nil", func(t *testing.T) { + t.Parallel() + + // Given + s := test.NewSetter((*Struct)(nil)) + + // When + s.Set("s", "set final").Set("a", "set final") + + // ThenstructEmpty + assert.Equal(t, structPtrFinal, s.Build()) + }) + + t.Run("setter-struct", func(t *testing.T) { + t.Parallel() + // Given + s := test.NewSetter(NewStruct("init", "init")) + + // When + s.Set("s", "set final").Set("a", "set final") + + // Then + assert.Equal(t, structFinal, s.Build()) + }) + + t.Run("setter-ptr", func(t *testing.T) { + t.Parallel() + + // Given + s := test.NewSetter(NewPtrStruct("init", "init")) + + // When + s.Set("s", "set final").Set("a", "set final") + + // Then + assert.Equal(t, structPtrFinal, s.Build()) + }) + + t.Run("getter-nil", func(t *testing.T) { + t.Parallel() + + // Given + g := test.NewGetter((*Struct)(nil)) + + // Then + assert.Equal(t, "", g.Get("s")) + assert.Equal(t, nil, g.Get("a")) + assert.Equal(t, structPtrEmpty, g.Get("")) + }) + + t.Run("getter-struct", func(t *testing.T) { + t.Parallel() + + // Given + g := test.NewGetter(structFinal) + + // Then + assert.Equal(t, "set final", g.Get("s")) + assert.Equal(t, "set final", g.Get("a")) + assert.Equal(t, structFinal, g.Get("")) + }) + + t.Run("getter-ptr", func(t *testing.T) { + t.Parallel() + + // Given + g := test.NewGetter(structPtrFinal) + + // Then + assert.Equal(t, "set final", g.Get("s")) + assert.Equal(t, "set final", g.Get("a")) + assert.Equal(t, structPtrFinal, g.Get("")) + }) + + t.Run("finder-nil", func(t *testing.T) { + t.Parallel() + + // Given + f := test.NewFinder((*Struct)(nil)) + + // Then + assert.Equal(t, "", f.Find("default", "s")) + assert.Equal(t, "default", f.Find("default", "a")) + assert.Equal(t, "", f.Find("default")) + assert.Equal(t, "", f.Find("default", "*")) + assert.Equal(t, "default", f.Find("default", "x")) + }) + + t.Run("finder-struct", func(t *testing.T) { + t.Parallel() + + // Given + f := test.NewFinder(structFinal) + + // Then + assert.Equal(t, "set final", f.Find("default", "s")) + assert.Equal(t, "default", f.Find("default", "a")) + assert.Equal(t, "set final", f.Find("default")) + assert.Equal(t, "set final", f.Find("default", "*")) + assert.Equal(t, "default", f.Find("default", "x")) + }) + + t.Run("finder-ptr", func(t *testing.T) { + t.Parallel() + + // Given + f := test.NewFinder(structPtrFinal) + + // Then + assert.Equal(t, "set final", f.Find("default", "s")) + assert.Equal(t, "default", f.Find("default", "a")) + assert.Equal(t, "set final", f.Find("default")) + assert.Equal(t, "set final", f.Find("default", "*")) + assert.Equal(t, "default", f.Find("default", "x")) + }) +} diff --git a/test/runner.go b/test/runner.go new file mode 100644 index 0000000..d8f4949 --- /dev/null +++ b/test/runner.go @@ -0,0 +1,256 @@ +package test + +import ( + "errors" + "fmt" + "reflect" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "github.com/tkrop/go-testing/internal/maps" + "github.com/tkrop/go-testing/internal/sync" +) + +// ErrInvalidType is an error for invalid types. +var ErrInvalidType = errors.New("invalid type") + +// NewErrInvalidType creates a new invalid type error. +func NewErrInvalidType(value any) error { + return fmt.Errorf("%w [type: %v]", + ErrInvalidType, reflect.ValueOf(value).Type()) +} + +// TestName returns the normalized test case name for the given name and given +// parameter set. If the name is empty, the name is resolved from the parameter +// set using the `name` parameter. The resolved name is normalized before being +// returned. +func TestName[P any](name string, param P) string { + if name != "" { + return strings.ReplaceAll(name, " ", "-") + } else if name := Find(param, unknown, "name", "*"); name != "" { + return strings.ReplaceAll(string(name), " ", "-") + } + return string(unknown) +} + +// Runner is a generic test runner interface. +type Runner[P any] interface { + // Filter sets up a filter for the test cases using the given pattern and + // match flag. The pattern is a regular expression that is matched against + // the test case name. The match flag is used to include or exclude the + // test cases that match the pattern. + Filter(pattern string, match bool) Runner[P] + // Timeout sets up a timeout for the test cases executed by the test runner. + // Setting a timeout is useful to prevent the test execution from waiting + // too long in case of deadlocks. The timeout is not affecting the global + // test timeout that may only abort a test earlier. If the given duration is + // zero or negative, the timeout is ignored. + Timeout(timeout time.Duration) Runner[P] + // StopEarly stops the test by the given duration ahead of an individual or + // global test deadline. This is useful to ensure that resources can be + // cleaned up before the global deadline is exceeded. + StopEarly(time time.Duration) Runner[P] + // Run runs all test parameter sets in parallel. If the test parameter sets + // are provided as a map, the test case name is used as the test name. If + // the test parameter sets are provided as a slice, the test case name is + // created by appending the index to the test name. If the test parameter + // sets are provided as a single parameter set, the test case name is used + // as the test name. The test case name is normalized before being used. + Run(call func(t Test, param P)) Runner[P] + // RunSeq runs the test parameter sets in a sequence. If the test parameter + // sets are provided as a map, the test case name is used as the test name. + // If the test parameter sets are provided as a slice, the test case name is + // created by appending the index to the test name. If the test parameter + // sets are provided as a single parameter set, the test case name is used + // as the test name. The test case name is normalized before being used. + RunSeq(call func(t Test, param P)) Runner[P] + // Cleanup register a function to be called to cleanup after all tests have + // finished to remove the shared resources. + Cleanup(call func()) +} + +// runner is a generic parameterized test runner struct. +type runner[P any] struct { + // The testing context to run the tests in. + t *testing.T + // A wait group to synchronize the test execution. + wg sync.WaitGroup + // The test parameter sets to run. + params any + // A filter to include or exclude test cases. + filter func(string) bool + // A timeout after which the test execution is stopped to prevent waiting + // to long in case of deadlocks. + timeout time.Duration + // A time reserved for cleaning up resources before reaching the deadline. + early time.Duration +} + +// Any creates a new parallel test runner with given parameter set(s). The set +// can be a single test parameter set, a slice of test parameter sets, or a map +// of named test parameter sets. The test runner is looking into the parameter +// set to determine a suitable test case name, e.g. by using a `name` parameter. +func Any[P any](t *testing.T, params any) Runner[P] { + t.Helper() + + return &runner[P]{ + t: t, + wg: sync.NewWaitGroup(), + params: params, + } +} + +// Map creates a new parallel test runner with given test parameter sets +// provided as a test case name to parameter sets mapping. +func Map[P any](t *testing.T, params ...map[string]P) Runner[P] { + t.Helper() + + return Any[P](t, maps.Add(maps.Copy(params[0]), params[1:]...)) +} + +// Slice creates a new parallel test runner with given test parameter sets +// provided as a slice. The test runner is looking into the parameter set to +// find a suitable test case name. +func Slice[P any](t *testing.T, params []P) Runner[P] { + t.Helper() + + return Any[P](t, params) +} + +// Filter filters the test cases by the given pattern and match flag. The +// pattern is a regular expression that is matched against the test case name. +// The match flag is used to include or exclude the test cases that match the +// pattern. +func (r *runner[P]) Filter( + pattern string, match bool, +) Runner[P] { + regexp := regexp.MustCompile(pattern) + r.filter = func(name string) bool { + return regexp.MatchString(name) == match + } + return r +} + +// Timeout can be used to set up a timeout for the test cases executed by the +// test runner. Setting a timeout is useful to prevent the test execution from +// waiting too long in case of deadlocks. The timeout is not affecting the +// global test timeout that may only abort a test earlier. If the given +// duration is zero or negative, the timeout is ignored. +func (r *runner[P]) Timeout(timeout time.Duration) Runner[P] { + r.timeout = timeout + return r +} + +// StopEarly can be used to stop the test by the given duration ahead of an +// individual or global test deadline. This is useful to ensure that resources +// can be cleaned up before the global deadline is exceeded. +func (r *runner[P]) StopEarly(early time.Duration) Runner[P] { + r.early = early + return r +} + +// Run runs the test parameter sets (by default) parallel. +func (r *runner[P]) Run(call func(t Test, param P)) Runner[P] { + return r.run(call, Parallel) +} + +// RunSeq runs the test parameter sets in a sequence. +func (r *runner[P]) RunSeq(call func(t Test, param P)) Runner[P] { + return r.run(call, !Parallel) +} + +// Cleanup register a function to be called for cleanup after all tests have +// been finished. +func (r *runner[P]) Cleanup(call func()) { + r.t.Cleanup(func() { + r.t.Helper() + r.wg.Wait() + call() + }) +} + +// Parallel ensures that the test runner runs the test parameter sets in +// parallel. +func (r *runner[P]) parallel(parallel bool) { + if parallel { + defer r.recoverParallel() + r.t.Parallel() + } +} + +// RecoverParallel recovers from panics when calling `t.Parallel()` multiple +// times. +func (*runner[P]) recoverParallel() { + //revive:disable-next-line:defer // only used inside a deferred call. + if v := recover(); v != nil && + v != "testing: t.Parallel called multiple times" { + panic(v) + } +} + +// Run runs the test parameter sets either parallel or in sequence. +func (r *runner[P]) run( + call func(t Test, param P), parallel bool, +) Runner[P] { + switch params := r.params.(type) { + case map[string]P: + r.parallel(parallel) + for name, param := range params { + name := TestName(name, param) + if r.filter != nil && !r.filter(name) { + continue + } + r.wg.Add(1) + r.t.Run(name, r.test(param, call, parallel)) + } + + case []P: + r.parallel(parallel) + for index, param := range params { + name := TestName("", param) + "[" + strconv.Itoa(index) + "]" + if r.filter != nil && !r.filter(name) { + continue + } + r.wg.Add(1) + r.t.Run(name, r.test(param, call, parallel)) + } + + case P: + name := TestName("", params) + if r.filter != nil && !r.filter(name) { + return r + } + r.wg.Add(1) + if name != string(unknown) { + r.t.Run(name, r.test(params, call, parallel)) + } else { + r.test(params, call, parallel)(r.t) + } + + default: + panic(NewErrInvalidType(r.params)) + } + return r +} + +// test creates the wrapper method executing eventually the test. +func (r *runner[P]) test( + param P, call func(t Test, param P), parallel bool, +) func(*testing.T) { + return func(t *testing.T) { + t.Helper() + + New(t, Find(param, Success, "expect", "*")). + Timeout(Find(param, r.timeout, "timeout")). + StopEarly(Find(param, r.early, "early")). + Run(func(t Test) { + t.Helper() + + defer r.wg.Done() + call(t, param) + }, parallel) + } +} diff --git a/test/runner_test.go b/test/runner_test.go new file mode 100644 index 0000000..3db52b0 --- /dev/null +++ b/test/runner_test.go @@ -0,0 +1,213 @@ +package test_test + +import ( + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/tkrop/go-testing/test" +) + +// TestAnyRun is testing the test runner with single test cases. +func TestAnyRun(t *testing.T) { + finished := false + test.Any[TestParam](t, TestParam{ + test: func(t test.Test) { t.FailNow() }, + expect: test.Failure, + }).Run(func(t test.Test, param TestParam) { + defer func() { finished = true }() + ExecTest(t, param) + }).Cleanup(func() { + assert.True(t, finished) + }) +} + +// TestAnyRunSeq is testing the test runner with single test cases running in +// sequence. +func TestAnyRunSeq(t *testing.T) { + t.Parallel() + + for _, param := range testParams { + finished := false + test.Any[TestParam](t, TestParam{ + test: param.test, + expect: param.expect, + }).RunSeq(func(t test.Test, param TestParam) { + defer func() { finished = true }() + ExecTest(t, param) + }).Cleanup(func() { + assert.True(t, finished) + }) + } +} + +// TestAnyRunNamed is testing the test runner with single named test cases. +func TestAnyRunNamed(t *testing.T) { + t.Parallel() + + for name, param := range testParams { + finished := false + tname := t.Name() + "/" + test.TestName(name, param) + test.Any[TestParam](t, TestParam{ + name: test.Name(name), + test: param.test, + expect: param.expect, + }).Run(func(t test.Test, param TestParam) { + defer func() { finished = true }() + assert.Equal(t, tname, t.Name()) + ExecTest(t, param) + }).Cleanup(func() { + assert.True(t, finished, tname) + }) + } +} + +// TestAnyRunSeqNamed is testing the test runner with single named test cases +// running in sequence. +func TestAnyRunSeqNamed(t *testing.T) { + t.Parallel() + + for name, param := range testParams { + finished := false + tname := t.Name() + "/" + test.TestName(name, param) + test.Any[TestParam](t, TestParam{ + name: test.Name(name), + test: param.test, + expect: param.expect, + }).RunSeq(func(t test.Test, param TestParam) { + defer func() { finished = true }() + assert.Equal(t, tname, t.Name()) + ExecTest(t, param) + }).Cleanup(func() { + assert.True(t, finished, tname) + }) + } +} + +// TestAnyRunFiltered is testing the test runner with single named test cases +// using run while applying a filter. +func TestAnyRunFiltered(t *testing.T) { + t.Parallel() + + for name, param := range testParams { + pattern, finished := "base", false + tname := t.Name() + "/" + test.TestName(name, param) + test.Any[TestParam](t, TestParam{ + name: test.Name(name), + test: param.test, + expect: param.expect, + }).Filter(pattern, true).Run(func(t test.Test, param TestParam) { + defer func() { finished = true }() + assert.Equal(t, tname, t.Name()) + assert.Contains(t, t.Name(), pattern) + ExecTest(t, param) + }).Cleanup(func() { + if strings.Contains(tname, pattern) { + assert.True(t, finished, tname) + } + }) + } +} + +// TestMapRun is testing the test runner with maps. +func TestMapRun(t *testing.T) { + count := atomic.Int32{} + + test.Map(t, testParams). + Run(func(t test.Test, param TestParam) { + defer count.Add(1) + ExecTest(t, param) + }). + Cleanup(func() { + assert.Equal(t, len(testParams), int(count.Load())) + }) +} + +// TestMapRunFiltered is testing the test runner with maps while applying a +// filter. +func TestMapRunFiltered(t *testing.T) { + pattern, count := "base", atomic.Int32{} + expect := testParams.FilterBy(pattern) + + test.Map(t, testParams).Filter(pattern, true). + Run(func(t test.Test, param TestParam) { + defer count.Add(1) + assert.Contains(t, t.Name(), pattern) + // assert.Contains(t, expect, param) + ExecTest(t, param) + }). + Cleanup(func() { + assert.Equal(t, len(expect), int(count.Load())) + }) +} + +// TestSliceRun is testing the test runner with slices. +func TestSliceRun(t *testing.T) { + count := atomic.Int32{} + + test.Slice(t, testParams.GetSlice()). + Run(func(t test.Test, param TestParam) { + defer count.Add(1) + ExecTest(t, param) + }). + Cleanup(func() { + assert.Equal(t, len(testParams), int(count.Load())) + }) +} + +// TestSliceRunFiltered is testing the test runner with slices while applying +// a filter. +func TestSliceRunFiltered(t *testing.T) { + pattern, count := "inrun", atomic.Int32{} + expect := testParams.FilterBy(pattern) + + test.Slice(t, testParams.GetSlice()).Filter(pattern, true). + Run(func(t test.Test, param TestParam) { + defer count.Add(1) + assert.Contains(t, t.Name(), pattern) + // assert.Contains(t, expect, param) + ExecTest(t, param) + }). + Cleanup(func() { + assert.Equal(t, len(expect), int(count.Load())) + }) +} + +// This test is checking the runner for recovering from panics in parallel +// tests. Currently, I have no idea hot to integrate the test using the above +// simplified test pattern that only works on `test.Test` and not `testing.T“. +func TestRunnerPanic(t *testing.T) { + defer func() { + assert.Equal(t, "testing: t.Parallel called after t.Setenv;"+ + " cannot set environment variables in parallel tests", recover()) + }() + t.Setenv("TESTING", "before") + + test.Any[ParamParam](t, []ParamParam{{expect: true}}). + Run(func(test.Test, ParamParam) {}) +} + +// This test is checking the runner for recovering from panics in parallel +// tests. Currently, I have no idea hot to integrate the test using the above +// simplified test pattern that only works on `test.Test` and not `testing.T“. +func TestInvalidTypePanic(t *testing.T) { + defer func() { + assert.Equal(t, test.NewErrInvalidType(ParamParam{}), recover()) + }() + + test.Any[TestParam](t, ParamParam{expect: false}). + Run(func(test.Test, TestParam) {}) +} + +func TestNameCastFallback(t *testing.T) { + test.Any[ParamParam](t, ParamParam{name: "value"}). + Run(func(t test.Test, _ ParamParam) { + assert.Equal(t, t.Name(), "TestNameCastFallback") + }) +} + +func TestExpectCastFallback(t *testing.T) { + test.Any[ParamParam](t, ParamParam{expect: false}). + Run(func(test.Test, ParamParam) {}) +} diff --git a/test/testing.go b/test/testing.go deleted file mode 100644 index 9358c73..0000000 --- a/test/testing.go +++ /dev/null @@ -1,618 +0,0 @@ -package test - -import ( - "errors" - "fmt" - "math" - "regexp" - "runtime" - "runtime/debug" - "strconv" - "strings" - gosync "sync" - "sync/atomic" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/tkrop/go-testing/internal/maps" - "github.com/tkrop/go-testing/internal/reflect" - "github.com/tkrop/go-testing/internal/slices" - "github.com/tkrop/go-testing/internal/sync" -) - -type ( - // Expect the expectation whether a test will succeed or fail. - Expect bool - // Name represents a test case name. - Name string -) - -// Constants to express test expectations. -const ( - // Success used to express that a test is supposed to succeed. - Success Expect = true - // Failure used to express that a test is supposed to fail. - Failure Expect = false - - // unknown default unknown test case name. - unknown Name = "unknown" - - // Flag to run test by default sequential instead of parallel. - Parallel = true -) - -// TODO: consider following convenience methods: -// -// // Result is a convenience method that returns the first argument ans swollows -// // all others assuming that the first argument contains the important result to -// // focus the test at. -// func Result[T any](result T, swollowed any) T { -// return result -// } - -// // Check is a convenience method that returns the second argument and swollows -// // the first used to focus a test on the second. -// func Check[T any](swollowed any, check T) T { -// return check -// } - -// // NoError is a convenience method to check whether the second error argument -// // is providing and actual error while extracting the first argument only. If -// // the error argument is an error, the method panics providing the error. -// func NoError[T any](result T, err error) T { -// if err != nil { -// panic(err) -// } -// return result -// } - -// // Ok is a convenience method to check whether the second boolean argument is -// // `true` while returning the first argument. If the boolean argument is -// // `false`, the method panics. -// func Ok[T any](result T, ok bool) T { -// if !ok { -// panic("bool not okay") -// } -// return result -// } - -// TestName returns the normalized test case name for the given name and given -// parameter set. If the name is empty, the name is resolved from the parameter -// set using the `name` parameter. The resolved name is normalized before being -// returned. -func TestName[P any](name string, param P) string { - if name != "" { - return strings.ReplaceAll(name, " ", "-") - // TODO: replace reflect.FindArgOf with Find - needs better structs support! - // } else if name := Find(param, unknown, "name"); name != "" { - // return strings.ReplaceAll(string(name), " ", "-") - } - found := reflect.FindArgOf(param, unknown, "name") - if name, ok := found.(Name); ok && name != "" { - return strings.ReplaceAll(string(name), " ", "-") - } - return string(unknown) -} - -// TestExpect resolves the test case expectation from the parameter set. If no -// expectation is found, the default expectation `Success` is returned. -func TestExpect[P any](param P) Expect { - // TODO: replace reflect.FindArgOf with Find - needs better struct support! - // return Find(param, Success, "expect") - expect := reflect.FindArgOf(param, Success, "expect") - if expect, ok := expect.(Expect); ok { - return expect - } - return Success -} - -// Reporter is a minimal interface for abstracting test report methods that are -// needed to setup an isolated test environment for GoMock and Testify. -type Reporter interface { - // Panic reports a panic. - Panic(arg any) - // Errorf reports a failure messages when a test is supposed to continue. - Errorf(format string, args ...any) - // Fatalf reports a fatal failure message that immediate aborts of the test - // execution. - Fatalf(format string, args ...any) - // FailNow reports fatal failure notifications without log output that - // aborts test execution immediately. - FailNow() -} - -// Test is a minimal interface for abstracting test methods that are needed to -// setup an isolated test environment for GoMock and Testify. -type Test interface { - // Name provides the test name. - Name() string - // Helper declares a test helper function. - Helper() - // Parallel declares that the test is to be run in parallel with (and only - // with) other parallel tests. - Parallel() - // TempDir creates a new temporary directory for the test. - TempDir() string - // Errorf handles a failure messages when a test is supposed to continue. - Errorf(format string, args ...any) - // Fatalf handles a fatal failure message that immediate aborts of the test - // execution. - Fatalf(format string, args ...any) - // FailNow handles fatal failure notifications without log output that - // aborts test execution immediately. - FailNow() - // Setenv sets an environment variable for the test. - Setenv(key, value string) -} - -// Cleanuper defines an interface to add a custom mehtod that is called after -// the test execution to cleanup the test environment. -type Cleanuper interface { - Cleanup(cleanup func()) -} - -// Tester is a test isolation environment based on the `Test` abstraction. It -// can be used as a drop in replacement for `testing.T` in various libraries -// to check for expected test failures. -type Tester struct { - sync.Synchronizer - t Test - wg sync.WaitGroup - mu gosync.Mutex - failed atomic.Bool - reporter Reporter - cleanups []func() - expect Expect -} - -// NewTester creates a new minimal test context based on the given `go-test` -// context. -func NewTester(t Test, expect Expect) *Tester { - if tx, ok := t.(*Tester); ok { - return (&Tester{t: tx, wg: tx.wg, expect: expect}) - } - return (&Tester{t: t, expect: expect}) -} - -// WaitGroup adds wait group to unlock in case of a failure. -// -//revive:disable-next-line:waitgroup-by-value // own wrapper interface -func (t *Tester) WaitGroup(wg sync.WaitGroup) { - t.wg = wg -} - -// Reporter sets up a test failure reporter. This can be used to validate the -// reported failures in a test environment. -func (t *Tester) Reporter(reporter Reporter) { - t.reporter = reporter -} - -// Cleanup is a function called to setup test cleanup after execution. This -// method is allowing `gomock` to register its `finish` method that reports the -// missing mock calls. -func (t *Tester) Cleanup(cleanup func()) { - t.mu.Lock() - defer t.mu.Unlock() - t.cleanups = append(t.cleanups, cleanup) -} - -// Name delegates the request to the parent test context. -func (t *Tester) Name() string { - return t.t.Name() -} - -// Helper delegates request to the parent test context. -func (t *Tester) Helper() { - t.t.Helper() -} - -// Parallel robustly delegates request to the parent context. It can be called -// multiple times, since it is swallowing the panic that is raised when calling -// `t.Parallel()` multiple times. -func (t *Tester) Parallel() { - defer t.recoverParallel() - t.t.Parallel() -} - -// Setenv delegates request to the parent context, if it is of type -// `*testing.T`. Else it is swallowing the request silently. -func (t *Tester) Setenv(key, value string) { - t.t.Setenv(key, value) -} - -// TempDir delegates the request to the parent test context. -func (t *Tester) TempDir() string { - return t.t.TempDir() -} - -// Errorf handles failure messages where the test is supposed to continue. On -// an expected success, the failure is also delegated to the parent test -// context. Else it delegates the request to the test reporter if available. -func (t *Tester) Errorf(format string, args ...any) { - t.Helper() - t.failed.Store(true) - if t.expect == Success { - t.t.Errorf(format, args...) - } else if t.reporter != nil { - t.reporter.Errorf(format, args...) - } -} - -// Fatalf handles a fatal failure message that immediate aborts of the test -// execution. On an expected success, the failure handling is also delegated -// to the parent test context. Else it delegates the request to the test -// reporter if available. -func (t *Tester) Fatalf(format string, args ...any) { - t.Helper() - t.failed.Store(true) - defer t.unlock() - if t.expect == Success { - t.t.Fatalf(format, args...) - } else if t.reporter != nil { - t.reporter.Fatalf(format, args...) - } - runtime.Goexit() -} - -// FailNow handles fatal failure notifications without log output that aborts -// test execution immediately. On an expected success, it the failure handling -// is also delegated to the parent test context. Else it delegates the request -// to the test reporter if available. -func (t *Tester) FailNow() { - t.Helper() - t.failed.Store(true) - defer t.unlock() - if t.expect == Success { - t.t.FailNow() - } else if t.reporter != nil { - t.reporter.FailNow() - } - runtime.Goexit() -} - -// Offset fr original stack in case of panic handling. -const panicOriginStackOffset = 10 - -// Panic handles failure notifications of panics that also abort the test -// execution immediately. -func (t *Tester) Panic(arg any) { - t.Helper() - t.failed.Store(true) - defer t.unlock() - if t.expect == Success { - stack := strings.SplitN(string(debug.Stack()), "\n", - panicOriginStackOffset) - t.Fatalf("panic: %v\n%s\n%s", arg, stack[0], - stack[panicOriginStackOffset-1]) - } else if t.reporter != nil { - t.reporter.Panic(arg) - } - runtime.Goexit() -} - -// Run executes the test function in a safe detached environment and check -// the failure state after the test function has finished. If the test result -// is not according to expectation, a failure is created in the parent test -// context. -func (t *Tester) Run(test func(Test), parallel bool) Test { - t.Helper() - if parallel { - t.Parallel() - } - - // register cleanup handlers. - t.register() - - // execute test function. - wg := sync.NewWaitGroup() - wg.Add(1) - go func() { - t.Helper() - defer wg.Done() - defer t.recover() - test(t) - }() - wg.Wait() - - return t -} - -// register registers the clean up handlers with the parent test context. -func (t *Tester) register() { - t.Helper() - - if c, ok := t.t.(Cleanuper); ok { - c.Cleanup(func() { - t.Helper() - t.cleanup() - }) - } - - t.Cleanup(func() { - t.Helper() - t.finish() - }) -} - -// cleanup runs the cleanup methods registered on the isolated test environment. -func (t *Tester) cleanup() { - t.mu.Lock() - cleanups := slices.Reverse(t.cleanups) - t.mu.Unlock() - - for _, cleanup := range cleanups { - cleanup() - } -} - -// finish evaluates the final result of the test function in relation to the -// provided expectation. -func (t *Tester) finish() { - t.mu.Lock() - defer t.mu.Unlock() - - switch t.expect { - case Success: - if t.failed.Load() { - t.t.Errorf("Expected test to succeed but it failed: %s", t.t.Name()) - } - case Failure: - if !t.failed.Load() { - t.t.Errorf("Expected test to fail but it succeeded: %s", t.t.Name()) - } - } -} - -// recover recovers from panics and generate test failure. -func (t *Tester) recover() { - t.Helper() - - //revive:disable-next-line:defer // only used inside a deferred call. - if arg := recover(); arg != nil { - t.Panic(arg) - } -} - -// recoverParallel recovers from panics when calling `t.Parallel()` multiple -// times. -func (t *Tester) recoverParallel() { - t.Helper() - - //revive:disable-next-line:defer // only used inside a deferred call. - if v := recover(); v != nil && - v != "testing: t.Parallel called multiple times" { - t.Panic(v) - } -} - -// unlock unlocks the wait group of the test by consuming the wait group -// counter completely. -func (t *Tester) unlock() { - if t.wg != nil { - t.wg.Add(math.MinInt) - } -} - -// Runner is a generic test runner interface. -type Runner[P any] interface { - // Filter sets up a filter for the test cases using the given pattern and - // match flag. The pattern is a regular expression that is matched against - // the test case name. The match flag is used to include or exclude the - // test cases that match the pattern. - Filter(pattern string, match bool) Runner[P] - // Run runs all test parameter sets in parallel. If the test parameter sets - // are provided as a map, the test case name is used as the test name. If - // the test parameter sets are provided as a slice, the test case name is - // created by appending the index to the test name. If the test parameter - // sets are provided as a single parameter set, the test case name is used - // as the test name. The test case name is normalized before being used. - Run(call func(t Test, param P)) Runner[P] - // RunSeq runs the test parameter sets in a sequence. If the test parameter - // sets are provided as a map, the test case name is used as the test name. - // If the test parameter sets are provided as a slice, the test case name is - // created by appending the index to the test name. If the test parameter - // sets are provided as a single parameter set, the test case name is used - // as the test name. The test case name is normalized before being used. - RunSeq(call func(t Test, param P)) Runner[P] - // Cleanup register a function to be called to cleanup after all tests have - // finished to remove the shared resources. - Cleanup(call func()) -} - -// runner is a generic parameterized test runner struct. -type runner[P any] struct { - t *testing.T - wg sync.WaitGroup - filter func(string) bool - params any -} - -// New creates a new parallel test runner with given parameter sets, i.e. a -// single test parameter set, a slice of test parameter sets, or a test case -// name to test parameter set map. If necessary, the test runner is looking -// into the parameter set for a suitable test case name. -func New[P any](t *testing.T, params any) Runner[P] { - t.Helper() - - return &runner[P]{ - t: t, - wg: sync.NewWaitGroup(), - params: params, - } -} - -// Map creates a new parallel test runner with given test parameter sets -// provided as a test case name to parameter sets mapping. -func Map[P any](t *testing.T, params ...map[string]P) Runner[P] { - t.Helper() - - return New[P](t, maps.Add(maps.Copy(params[0]), params[1:]...)) -} - -// Slice creates a new parallel test runner with given test parameter sets -// provided as a slice. The test runner is looking into the parameter set to -// find a suitable test case name. -func Slice[P any](t *testing.T, params []P) Runner[P] { - t.Helper() - - return New[P](t, params) -} - -// Filter filters the test cases by the given pattern and match flag. The -// pattern is a regular expression that is matched against the test case name. -// The match flag is used to include or exclude the test cases that match the -// pattern. -func (r *runner[P]) Filter( - pattern string, match bool, -) Runner[P] { - regexp := regexp.MustCompile(pattern) - r.filter = func(name string) bool { - return regexp.MatchString(name) == match - } - return r -} - -// Run runs the test parameter sets (by default) parallel. -func (r *runner[P]) Run(call func(t Test, param P)) Runner[P] { - return r.run(call, Parallel) -} - -// RunSeq runs the test parameter sets in a sequence. -func (r *runner[P]) RunSeq(call func(t Test, param P)) Runner[P] { - return r.run(call, !Parallel) -} - -// Cleanup register a function to be called for cleanup after all tests have -// been finished. -func (r *runner[P]) Cleanup(call func()) { - r.t.Cleanup(func() { - r.t.Helper() - r.wg.Wait() - call() - }) -} - -// Parallel ensures that the test runner runs the test parameter sets in -// parallel. -func (r *runner[P]) parallel(parallel bool) { - if parallel { - defer r.recoverParallel() - r.t.Parallel() - } -} - -// RecoverParallel recovers from panics when calling `t.Parallel()` multiple -// times. -func (*runner[P]) recoverParallel() { - //revive:disable-next-line:defer // only used inside a deferred call. - if v := recover(); v != nil && - v != "testing: t.Parallel called multiple times" { - panic(v) - } -} - -// Run runs the test parameter sets either parallel or in sequence. -func (r *runner[P]) run( - call func(t Test, param P), parallel bool, -) Runner[P] { - switch params := r.params.(type) { - case map[string]P: - r.parallel(parallel) - for name, param := range params { - name := TestName(name, param) - if r.filter != nil && !r.filter(name) { - continue - } - r.wg.Add(1) - r.t.Run(name, r.wrap(name, param, call, parallel)) - } - - case []P: - r.parallel(parallel) - for index, param := range params { - name := TestName("", param) + "[" + strconv.Itoa(index) + "]" - if r.filter != nil && !r.filter(name) { - continue - } - r.wg.Add(1) - r.t.Run(name, r.wrap(name, param, call, parallel)) - } - - case P: - name := TestName("", params) - if r.filter != nil && !r.filter(name) { - return r - } - r.wg.Add(1) - if name != string(unknown) { - r.t.Run(name, r.wrap(name, params, call, parallel)) - } else { - r.wrap(name, params, call, parallel)(r.t) - } - - default: - panic(NewErrUnknownParameterType(r.params)) - } - return r -} - -// Wrap creates the test wrapper method executing the test. -func (r *runner[P]) wrap( - name string, param P, call func(t Test, param P), parallel bool, -) func(*testing.T) { - return run(TestExpect(param), func(t Test) { - t.Helper() - - // Helpful for debugging to see the test case. - require.NotEmpty(t, name) - - defer r.wg.Done() - call(t, param) - }, parallel) -} - -// Run creates an isolated (by default) parallel test environment running the -// given test function with given expectation. When executed via `t.Run()` it -// checks whether the result is matching the expectation. -func Run(expect Expect, test func(Test)) func(*testing.T) { - return run(expect, test, Parallel) -} - -// RunSeq creates an isolated, test environment for the given test function -// with given expectation. When executed via `t.Run()` it checks whether the -// result is matching the expectation. -func RunSeq(expect Expect, test func(Test)) func(*testing.T) { - return run(expect, test, !Parallel) -} - -// Run creates an isolated parallel or sequential test environment running the -// given test function with given expectation. When executed via `t.Run()` it -// checks whether the result is matching the expectation. -func run(expect Expect, test func(Test), parallel bool) func(*testing.T) { - return func(t *testing.T) { - t.Helper() - - NewTester(t, expect).Run(test, parallel) - } -} - -// InRun creates an isolated test environment for the given test function with -// given expectation. When executed via `t.Run()` it checks whether the result -// is matching the expectation. -func InRun(expect Expect, test func(Test)) func(Test) { - return func(t Test) { - t.Helper() - - NewTester(t, expect).Run(test, !Parallel) - } -} - -// ErrUnknownParameterType is an error for unknown parameter types. -var ErrUnknownParameterType = errors.New("unknown parameter type") - -// NewErrUnknownParameterType creates a new unknown parameter type error. -func NewErrUnknownParameterType(value any) error { - return fmt.Errorf("%w [type: %v]", - ErrUnknownParameterType, reflect.ValueOf(value).Type()) -} diff --git a/test/testing_test.go b/test/testing_test.go deleted file mode 100644 index 8937a5e..0000000 --- a/test/testing_test.go +++ /dev/null @@ -1,449 +0,0 @@ -package test_test - -import ( - "os" - "regexp" - "strings" - "sync/atomic" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/tkrop/go-testing/internal/sync" - - "github.com/tkrop/go-testing/mock" - "github.com/tkrop/go-testing/test" -) - -type TestParam struct { - name test.Name - setup mock.SetupFunc - test func(test.Test) - expect test.Expect - consumed bool -} - -type TestParamMap map[string]TestParam - -func (m TestParamMap) FilterBy(pattern string) TestParamMap { - filter := regexp.MustCompile(pattern) - params := TestParamMap{} - for key, value := range m { - if filter.MatchString(key) { - params[key] = value - } - } - return params -} - -func (m TestParamMap) GetSlice() []TestParam { - params := make([]TestParam, 0, len(m)) - for name, param := range m { - params = append(params, TestParam{ - name: test.Name(name), - test: param.test, - expect: param.expect, - }) - } - return params -} - -var testParams = TestParamMap{ - "base nothing": { - test: func(test.Test) {}, - expect: test.Success, - }, - "base errorf": { - test: func(t test.Test) { t.Errorf("fail") }, - expect: test.Failure, - }, - "base fatalf": { - test: func(t test.Test) { t.Fatalf("fail") }, - expect: test.Failure, - consumed: true, - }, - "base failnow": { - test: func(t test.Test) { t.FailNow() }, - expect: test.Failure, - consumed: true, - }, - "base panic": { - test: func(test.Test) { panic("fail") }, - expect: test.Failure, - consumed: true, - }, - - "inrun success": { - test: test.InRun(test.Success, - func(test.Test) {}), - expect: test.Success, - }, - "inrun success with errorf": { - test: test.InRun(test.Success, - func(t test.Test) { t.Errorf("fail") }), - expect: test.Failure, - }, - "inrun success with fatalf": { - test: test.InRun(test.Success, - func(t test.Test) { t.Fatalf("fail") }), - expect: test.Failure, - consumed: true, - }, - "inrun success with failnow": { - test: test.InRun(test.Success, - func(t test.Test) { t.FailNow() }), - expect: test.Failure, - consumed: true, - }, - "inrun success with panic": { - test: test.InRun(test.Success, - func(test.Test) { panic("fail") }), - expect: test.Failure, - consumed: true, - }, - - "inrun failure": { - test: test.InRun(test.Failure, - func(test.Test) {}), - expect: test.Failure, - }, - "inrun failure with errorf": { - test: test.InRun(test.Failure, - func(t test.Test) { t.Errorf("fail") }), - expect: test.Success, - }, - "inrun failure with fatalf": { - test: test.InRun(test.Failure, - func(t test.Test) { t.Fatalf("fail") }), - expect: test.Success, - consumed: true, - }, - "inrun failure with failnow": { - test: test.InRun(test.Failure, - func(t test.Test) { t.FailNow() }), - expect: test.Success, - consumed: true, - }, - "inrun failure with panic": { - test: test.InRun(test.Failure, - func(test.Test) { panic("fail") }), - expect: test.Success, - consumed: true, - }, -} - -func testFailures(t test.Test, param TestParam) { - // Given - if param.setup != nil { - mock.NewMocks(t).Expect(param.setup) - } - - wg := sync.NewLenientWaitGroup() - t.(*test.Tester).WaitGroup(wg) - if param.consumed { - wg.Add(1) - } - - // When - param.test(t) - - // Then - wg.Wait() -} - -func TestRun(t *testing.T) { - t.Parallel() - - for name, param := range testParams { - name, param := name, param - t.Run(name, test.Run(param.expect, func(t test.Test) { - testFailures(t, param) - })) - } -} - -func TestRunSeq(t *testing.T) { - t.Parallel() - - for name, param := range testParams { - name, param := name, param - t.Run(name, test.RunSeq(param.expect, func(t test.Test) { - testFailures(t, param) - })) - } -} - -func TestNewRun(t *testing.T) { - finished := false - test.New[TestParam](t, TestParam{ - test: func(t test.Test) { t.FailNow() }, - expect: test.Failure, - }).Run(func(t test.Test, param TestParam) { - defer func() { finished = true }() - testFailures(t, param) - }).Cleanup(func() { - assert.True(t, finished) - }) -} - -func TestNewRunSeq(t *testing.T) { - t.Parallel() - - for _, param := range testParams { - finished := false - test.New[TestParam](t, TestParam{ - test: param.test, - expect: param.expect, - }).RunSeq(func(t test.Test, param TestParam) { - defer func() { finished = true }() - testFailures(t, param) - }).Cleanup(func() { - assert.True(t, finished) - }) - } -} - -func TestNewRunNamed(t *testing.T) { - t.Parallel() - - for name, param := range testParams { - finished := false - tname := t.Name() + "/" + test.TestName(name, param) - test.New[TestParam](t, TestParam{ - name: test.Name(name), - test: param.test, - expect: param.expect, - }).Run(func(t test.Test, param TestParam) { - defer func() { finished = true }() - assert.Equal(t, tname, t.Name()) - testFailures(t, param) - }).Cleanup(func() { - assert.True(t, finished, tname) - }) - } -} - -func TestNewRunSeqNamed(t *testing.T) { - t.Parallel() - - for name, param := range testParams { - finished := false - tname := t.Name() + "/" + test.TestName(name, param) - test.New[TestParam](t, TestParam{ - name: test.Name(name), - test: param.test, - expect: param.expect, - }).RunSeq(func(t test.Test, param TestParam) { - defer func() { finished = true }() - assert.Equal(t, tname, t.Name()) - testFailures(t, param) - }).Cleanup(func() { - assert.True(t, finished, tname) - }) - } -} - -func TestNewRunFiltered(t *testing.T) { - t.Parallel() - - for name, param := range testParams { - pattern, finished := "base", false - tname := t.Name() + "/" + test.TestName(name, param) - test.New[TestParam](t, TestParam{ - name: test.Name(name), - test: param.test, - expect: param.expect, - }).Filter(pattern, true).Run(func(t test.Test, param TestParam) { - defer func() { finished = true }() - assert.Equal(t, tname, t.Name()) - assert.Contains(t, t.Name(), pattern) - testFailures(t, param) - }).Cleanup(func() { - if strings.Contains(tname, pattern) { - assert.True(t, finished, tname) - } - }) - } -} - -func TestMapRun(t *testing.T) { - count := atomic.Int32{} - - test.Map(t, testParams). - Run(func(t test.Test, param TestParam) { - defer count.Add(1) - testFailures(t, param) - }). - Cleanup(func() { - assert.Equal(t, len(testParams), int(count.Load())) - }) -} - -func TestMapRunFiltered(t *testing.T) { - pattern, count := "base", atomic.Int32{} - expect := testParams.FilterBy(pattern) - - test.Map(t, testParams).Filter(pattern, true). - Run(func(t test.Test, param TestParam) { - defer count.Add(1) - assert.Contains(t, t.Name(), pattern) - testFailures(t, param) - }). - Cleanup(func() { - assert.Equal(t, len(expect), int(count.Load())) - }) -} - -func TestSliceRun(t *testing.T) { - count := atomic.Int32{} - - test.Slice(t, testParams.GetSlice()). - Run(func(t test.Test, param TestParam) { - defer count.Add(1) - testFailures(t, param) - }). - Cleanup(func() { - assert.Equal(t, len(testParams), int(count.Load())) - }) -} - -func TestSliceRunFiltered(t *testing.T) { - pattern, count := "inrun", atomic.Int32{} - expect := testParams.FilterBy(pattern) - - test.Slice(t, testParams.GetSlice()).Filter(pattern, true). - Run(func(t test.Test, param TestParam) { - defer count.Add(1) - assert.Contains(t, t.Name(), pattern) - testFailures(t, param) - }). - Cleanup(func() { - assert.Equal(t, len(expect), int(count.Load())) - }) -} - -type ParamParam struct { - name string - expect bool -} - -func TestTempDir(t *testing.T) { - test.New[ParamParam](t, ParamParam{expect: true}). - Run(func(t test.Test, _ ParamParam) { - assert.NotEmpty(t, t.TempDir()) - }) -} - -func TestNameCastFallback(t *testing.T) { - test.New[ParamParam](t, ParamParam{name: "value"}). - Run(func(t test.Test, _ ParamParam) { - assert.Equal(t, t.Name(), "TestNameCastFallback") - }) -} - -func TestExpectCastFallback(t *testing.T) { - test.New[ParamParam](t, ParamParam{expect: false}). - Run(func(test.Test, ParamParam) {}) -} - -func TestTypePanic(t *testing.T) { - defer func() { - if err := recover(); err == nil { - assert.Fail(t, "not paniced") - } - }() - test.New[TestParam](t, ParamParam{expect: false}). - Run(func(test.Test, TestParam) {}) -} - -type PanicParam struct { - parallel bool - before func(test.Test) - during func(test.Test) - expect mock.SetupFunc -} - -var testPanicParams = map[string]PanicParam{ - "setenv in run without parallel": { - during: func(t test.Test) { - t.Setenv("TESTING", "during") - assert.Equal(t, "during", os.Getenv("TESTING")) - }, - }, - - "setenv in run with parallel": { - parallel: true, - during: func(t test.Test) { - t.Setenv("TESTING", "during") - assert.Equal(t, "during", os.Getenv("TESTING")) - }, - expect: test.Panic("testing: t.Setenv called after t.Parallel;" + - " cannot set environment variables in parallel tests"), - }, - - "setenv before run without parallel": { - before: func(t test.Test) { - t.Setenv("TESTING", "before") - assert.Equal(t, "before", os.Getenv("TESTING")) - }, - during: func(t test.Test) { - t.Setenv("TESTING", "during") - assert.Equal(t, "during", os.Getenv("TESTING")) - }, - }, - - "setenv before run with parallel": { - parallel: true, - before: func(t test.Test) { - t.Setenv("TESTING", "before") - assert.Equal(t, "before", os.Getenv("TESTING")) - }, - expect: test.Panic("testing: t.Parallel called after t.Setenv;" + - " cannot set environment variables in parallel tests"), - }, - - "swallow multiple parallel calls": { - during: func(t test.Test) { - t.Parallel() - t.Parallel() - }, - }, -} - -func TestTesterPanic(t *testing.T) { - for name, param := range testPanicParams { - name, param := name, param - t.Run(name, test.RunSeq(test.Success, func(t test.Test) { - // Given - if param.before != nil { - mock.NewMocks(t).Expect(param.expect) - param.before(t) - } - - // When - test.NewTester(t, test.Success).Run(func(t test.Test) { - mock.NewMocks(t).Expect(param.expect) - param.during(t) - }, param.parallel) - })) - } -} - -// This test is checking the runner for recovering from panics in parallel -// tests. Currently, I have no idea hot to integrate the test using the above -// simplified test pattern that only works on `test.Test` and not `testing.T“. -func TestRunnerPanic(t *testing.T) { - defer func() { - v := recover() - if v == nil { - assert.Fail(t, "not paniced") - } else if v.(string) != "testing: t.Parallel called after t.Setenv;"+ - " cannot set environment variables in parallel tests" { - assert.Fail(t, "unexpected panic: %v", v) - } - }() - t.Setenv("TESTING", "before") - - test.New[ParamParam](t, []ParamParam{{expect: true}}). - Run(func(_ test.Test, _ ParamParam) {}) -}