diff --git a/Makefile b/Makefile index b7b1e95..33115c2 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ export GOPATH ?= $(shell $(GO) env GOPATH) export GOBIN ?= $(GOPATH)/bin # Setup go-make version to use desired build and config scripts. -GOMAKE_DEP ?= github.com/tkrop/go-make@v0.0.105 +GOMAKE_DEP ?= github.com/tkrop/go-make@v0.0.106 INSTALL_FLAGS ?= -mod=readonly -buildvcs=auto # Request targets from go-make targets target. TARGETS := $(shell command -v $(GOBIN)/go-make >/dev/null || \ diff --git a/README.md b/README.md index 06ea010..770998a 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,10 @@ writing effective unit, component, and integration tests in [`go`][go]. To accomplish this, the `testing` framework provides a couple of extensions for to standard [`testing`][testing] package of [`go`][go] that support a simple -setup of [`gomock`][gomock] and [`gock`][gock] in isolated, parallel, and -parameterized tests using a common pattern to setup with strong validation of -mock request and response that work under various failure scenarios and even in -the presense of [`go`-routines][go-routines]. +setup of test cases using [`gomock`][gomock] and [`gock`][gock] in isolated, +parallel, and parameterized tests using a common pattern with strong validation +of mock request and response that work under various failure scenarios and even +in the presence of spawned [`go`-routines][go-routines]. [go-routines]: @@ -90,6 +90,7 @@ var testUnitParams = map[string]UnitParams { func TestUnit(t *testing.T) { test.Map(t, testParams). + Timeout(50 * time.Millisecond) Run(func(t test.Test, param UnitParams){ // Given @@ -124,11 +125,10 @@ way. For variations have a closer look at the [test](test) package. ### Why parameterized test? -Parameterized test are an efficient way to setup a high number of related test -cases cover the system under test in a black box mode from feature perspective. -With the right tools and concepts - as provided by this `testing` framework, -parameterized test allow to cover all success and failure paths of a system -under test as outlined above. +Parameterized test are an effective way to set up a systematic set of test +cases covering a system under test in a black box mode. With the right tools +and concepts — as provided by this `testing` framework, parameterized test +allow to cover all success and failure paths of a system under test. ### Why parallel tests? @@ -142,7 +142,7 @@ effort needed to write parallel tests. ### Why isolation of tests? -Test isolation is a precondition to have stable running test - especially run +Test isolation is a precondition to have stable running test — especially run in parallel. Isolation must happen from input perspective, i.e. the outcome of a test must not be affected by any previous running test, but also from output perspective, i.e. it must not affect any later running test. This is often @@ -155,36 +155,38 @@ tests](#requirements-for-parallel-isolated-tests). Test are only meaningful, if they validate ensure pre-conditions and validate post-conditions sufficiently strict. Without validation test cannot ensure that -the system under test behaves as expected - even with 100% code and branch +the system under test behaves as expected — even with 100% code and branch coverage. As a consequence, a system may fail in unexpected ways in production. -Thus it is advised to validate mock input parameters for mocked requests and -to carefully define the order of mock requests and responses. The -[`mock`](mock) framework makes this approach as simple as possible, but it is -still the responsibility of the developer to setup the validation correctly. +Thus, it is advised to validate input parameters for mocked requests and to +carefully define the order of mock requests and responses. The [`mock`](mock) +framework makes this approach as simple as possible, but it is still the +responsibility of the test developer to set up the validation correctly. ## Framework structure The `testing` framework consists of the following sub-packages: -* [`test`](test) provides a small framework to simply isolate the test execution - and safely check whether a test fails or succeeds as expected in coordination - with the [`mock`](mock) package - even in if a system under test spans - detached [`go`-routines][go-routines]. +* [`test`](test) provides a small framework to isolate the test execution and + safely check whether a test fails or succeeds as expected in combination with + the [`mock`](mock) package — even in if a system under test spans detached + [`go`-routines][go-routines]. -* [`mock`](mock) provides the means to setup a simple chain or a complex network - of expected mock calls with minimal effort. This makes it easy to extend the - usual narrow range of mocking to larger components using a unified pattern. +* [`mock`](mock) provides the means to set up a simple chain as well as a + complex network of expected mock calls with minimal effort. This makes it + easy to extend the usual narrow range of mocking to larger components using + a unified test pattern. -* [`gock`](gock) provides a drop-in extension for [Gock][gock] consisting of a - controller and a mock storage that allows to run tests isolated. This allows - to parallelize simple test and parameterized tests. +* [`gock`](gock) provides a drop-in extension for the [Gock][gock] package + consisting of a controller and a mock storage that allows running tests + isolated. This allows parallelizing simple test as well as parameterized + tests. * [`perm`](perm) provides a small framework to simplify permutation tests, i.e. a consistent test set where conditions can be checked in all known orders - with different outcome. This is very handy in combination with [`test`](test) - to validated the [`mock`](mock) framework, but may be useful in other cases + with different outcome. This was very handy in combination with [`test`](test) + for validating the [`mock`](mock) framework, but may be useful in other cases too. Please see the documentation of the sub-packages for more details. @@ -192,21 +194,21 @@ Please see the documentation of the sub-packages for more details. ## Requirements for parallel isolated tests -Running tests in parallel not only makes test faster, but also helps to detect -race conditions that else randomly appear in production when running tests +Running tests in parallel makes test not only faster, but also helps to detect +race conditions that else randomly appear in production, when running tests with `go test -race`. **Note:** there are some general requirements for running test in parallel: -1. Tests *must not modify* environment variables dynamically - utilize test +1. Tests *must not modify* environment variables dynamically — utilize test specific configuration instead. -2. Tests *must not require* reserved service ports and open listeners - setup +2. Tests *must not require* reserved service ports and open listeners — setup services to acquire dynamic ports instead. 3. Tests *must not share* files, folder and pipelines, e.g. `stdin`, `stdout`, - or `stderr` - implement logic by using wrappers that can be redirected and + or `stderr` — implement logic by using wrappers that can be redirected and mocked. 4. Tests *must not share* database schemas or tables, that are updated during - execution of parallel tests - implement test to setup test specific database + execution of parallel tests — implement test to set up test specific database schemas. 5. Tests *must not share* process resources, that are update during execution of parallel tests. Many frameworks make use of common global resources that @@ -215,17 +217,17 @@ with `go test -race`. Examples for such shared resources in common frameworks are: * Using of [monkey patching][monkey] to modify commonly used global functions, - e.g. `time.Now()` - implement access to these global functions using lambdas + e.g. `time.Now()` — implement access to these global functions using lambdas and interfaces to allow for mocking. -* Using of [`gock`][gock] to mock HTTP responses on transport level - make use +* Using of [`gock`][gock] to mock HTTP responses on transport level — make use of the [`gock`](gock)-controller provided by this framework. * Using the [Gin][gin] HTTP web framework which uses a common `json`-parser setup instead of a service specific configuration. While this is not a huge - deal, the repeated global setup creates race alerts. Instead use [`chi`][chi] - that supports a service specific configuration. + deal, the repeated global setup creates race alerts. Instead, use + [`chi`][chi] that supports a service specific configuration. -With a careful design the general pattern provided above can be used to support -parallel test execution. +With a careful system design, the general pattern provided above can be used +to create parallel test for a wide range of situations. ## Building @@ -272,17 +274,16 @@ is following the [conventional commit][convent-commit] best practice. ## Terms of Usage -This software is open source as is under the MIT license. If you start using -the software, please give it a star, so that I know to be more careful with -changes. If this project has more than 25 Stars, I will introduce semantic -versions for changes. +This software is open source under the MIT license. You can use it without +restrictions and liabilities. Please give it a star, so that I know. If the +project has more than 25 Stars, I will introduce semantic versions `v1`. ## Contributing If you like to contribute, please create an issue and/or pull request with a proper description of your proposal or contribution. I will review it and -provide feedback on it. +provide feedback on it as fast as possible. [testing]: diff --git a/go.mod b/go.mod index 2a85ce6..8c7217d 100644 --- a/go.mod +++ b/go.mod @@ -7,15 +7,16 @@ require ( github.com/h2non/gock v1.2.0 github.com/huandu/go-clone v1.6.0 github.com/stretchr/testify v1.9.0 - golang.org/x/text v0.13.0 + golang.org/x/text v0.18.0 golang.org/x/tools v0.26.0 ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect golang.org/x/mod v0.21.0 // indirect golang.org/x/sync v0.8.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/go.sum b/go.sum index bd77b4b..00297b5 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= @@ -22,10 +23,12 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= @@ -51,8 +54,8 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc 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= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 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..0866e06 100644 --- a/test/README.md +++ b/test/README.md @@ -22,29 +22,32 @@ func TestUnit(t *testing.T) { // When panic("fail") - })(t) - - // Then ... + })(t) } ``` +But there are many other supported use case, you can discover reading the +below examples. + ## Isolated parameterized parallel test runner The `test` framework supports to run isolated, parameterized, parallel tests using a lean test runner. The runner can be instantiated with a single test -parameter set (`New`), a slice of test parameter sets (`Slice`), or a map of -test case name to test parameter sets (`Map` - preferred pattern). The test is -started by `Run` that accepts a simple test function as input, using a -`test.Test` interface, that is compatible with most tools, e.g. +parameter set (`test.Any`), a slice of test parameter sets (`test.Slice`), or a +map of test case name to test parameter sets (`test.Map` - preferred pattern). +The test is started by `Run` that accepts a simple test function as input, +using a `test.Test` interface, that is compatible with most tools, e.g. [`gomock`][gomock]. ```go func TestUnit(t *testing.T) { - test.New|Slice|Map(t, testParams). - /* Filter("test-case-name", false|true). */ + test.Any|Slice|Map(t, testParams). + Filter("test-case-name", false|true). + Timeout(5*time.Millisecond). + StopEarly(time.Millisecond). Run|RunSeq(func(t test.Test, param UnitParams){ // Given @@ -60,8 +63,8 @@ func TestUnit(t *testing.T) { This creates and starts a lean test wrapper using a common interface, that isolates test execution and intercepts all failures (including panics), to either forward or suppress them. The result is controlled by providing a test -parameter of type `test.Expect` (name `expect`) that supports `Failure` (false) -and `Success` (true - default). +parameter of type `test.Expect` (name `expect`) that supports `test.Failure` +(false) and `Success` (true - default). Similar a test case name can be provided using type `test.Name` (name `name` - default value `unknown-%d`) or as key using a test case name to parameter set @@ -74,7 +77,8 @@ sequential test execution. It is also possible to select a subset of tests for execution by setting up a `Filter` using a regular expression to match or filter by the normalized test -name. +name, or to set up a `Timeout` as well as a grace period to `StopEarly` for +giving the `Cleanup`-functions sufficient time to free resources. ## Isolated in-test environment setup @@ -103,7 +107,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,24 +128,27 @@ func TestUnit(t *testing.T) { } ``` -Or the interface of the underlying `test.Tester`: +Or finally, use even more directly the flexible `test.Context` that is +providing the features on top of the underlying `test.Test` interface +abstraction, if you need more control about the test execution: ```go func TestUnit(t *testing.T) { t.Parallel() - test.NewTester(t, test.Success).Run(func(t test.Test){ - // Given + test.New(t, test.Success). + Timeout(5*time.Millisecond). + StopEarly(time.Millisecond). + Run(func(t test.Test){ + // Given - // When + // When - // Then - })(t) + // Then + })(t) } ``` -But this should usually be unnecessary. - ## Isolated failure/panic validation @@ -163,7 +170,7 @@ func TestUnit(t *testing.T) { // When t.Errorf("fail") ... - // And one of the following + // And one of the terminal calls. t.Fatalf("fail") t.FailNow() panic("fail") @@ -174,7 +181,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. @@ -182,10 +189,32 @@ source of the panic, you need to spawn a new unrecovered go-routine. hard to recreate. Do not try it. +## Test result builder + +Comparing test results is most efficient, when you directly can compare the +actual objects. However, this is sometimes prevented by the objects not being +open for construction and having private states. The `test`-package supports +helpers to construct objects and access private fields using reflection. + +* `test.NewBuilder[...]()` allows constructing new objects from scratch. +* `test.NewGetter(...)` allows reading private fields of an object by name. +* `test.NewSetter(...)` allows writing private fields by name, and finally +* `test.Accessor(...)` allows reading and writing of private fields by name. + +The following example shows how the private properties of a close error can +be set using the `test.NewBuilder[...]()`. + +```go + err := test.NewBuilder[viper.ConfigFileNotFoundError](). + Set("locations", fmt.Sprintf("%s", "...path...")). + Set("name", "test").Build() +``` + + ## 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 +230,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..e4652a1 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 { @@ -93,9 +93,9 @@ var ( return dir }() // CallerTestErrorf provides the file with the line number of the `Errorf` - // call in testing. - CallerTestErrorf = path.Join(SourceDir, "testing.go:238") - // CallerGomockErrorf provides the file with the line number of the - // `Errorf` call in gomock. - CallerGomockErrorf = path.Join(SourceDir, "gomock.go:61") + // call in the test context implementation. + CallerTestErrorf = path.Join(SourceDir, "context.go:276") + // CallerReporterErrorf provides the file with the line number of the + // `Errorf` call in the test reporter/validator implementation. + CallerReporterErrorf = 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..5384715 --- /dev/null +++ b/test/common_test.go @@ -0,0 +1,178 @@ +package test_test + +import ( + "regexp" + + "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 +} + +var ( + // TestEmpty is a test function that does nothing. + TestEmpty = func(test.Test) {} + // TestErrorf is a test function that fails with an error message. + TestErrorf = func(t test.Test) { t.Errorf("fail") } + // TestFatalf is a test function that fails with a fatal error message. + TestFatalf = func(t test.Test) { + // Duplicate terminal failures are ignored. + go func() { t.Fatalf("fail") }() + t.Fatalf("fail") + } + // TestFailNow is a test function that fails immediately. + TestFailNow = func(t test.Test) { + // Duplicate terminal failures are ignored. + go func() { t.FailNow() }() + t.FailNow() + } + // TestPanic is a test function that panics. + TestPanic = func(t test.Test) { + // Duplicate terminal failures are ignored. + go func() { t.(*test.Context).Panic("fail") }() + panic("fail") + } +) + +// 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: TestEmpty, + expect: test.Success, + }, + "base errorf": { + test: TestErrorf, + expect: test.Failure, + }, + "base fatalf": { + test: TestFatalf, + expect: test.Failure, + consumed: true, + }, + "base failnow": { + test: TestFailNow, + expect: test.Failure, + consumed: true, + }, + "base panic": { + test: TestPanic, + expect: test.Failure, + consumed: true, + }, + + "inrun success": { + test: test.InRun(test.Success, TestEmpty), + expect: test.Success, + }, + "inrun success with errorf": { + test: test.InRun(test.Success, TestErrorf), + expect: test.Failure, + }, + "inrun success with fatalf": { + test: test.InRun(test.Success, TestFatalf), + expect: test.Failure, + consumed: true, + }, + "inrun success with failnow": { + test: test.InRun(test.Success, TestFailNow), + expect: test.Failure, + consumed: true, + }, + "inrun success with panic": { + test: test.InRun(test.Success, TestPanic), + expect: test.Failure, + consumed: true, + }, + + "inrun failure": { + test: test.InRun(test.Failure, TestEmpty), + expect: test.Failure, + }, + "inrun failure with errorf": { + test: test.InRun(test.Failure, TestErrorf), + expect: test.Success, + }, + "inrun failure with fatalf": { + test: test.InRun(test.Failure, TestFatalf), + expect: test.Success, + consumed: true, + }, + "inrun failure with failnow": { + test: test.InRun(test.Failure, TestFailNow), + expect: test.Success, + consumed: true, + }, + "inrun failure with panic": { + test: test.InRun(test.Failure, TestPanic), + 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() +} diff --git a/test/context.go b/test/context.go new file mode 100644 index 0000000..8edd032 --- /dev/null +++ b/test/context.go @@ -0,0 +1,463 @@ +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() + // TempDir creates a new temporary directory for the test. + TempDir() string + // Setenv sets an environment variable for the test. + Setenv(key, value string) + // Deadline returns the deadline of the test and a flag indicating whether + // the deadline is set. + Deadline() (deadline time.Time, ok bool) + // 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() +} + +// 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 { + t.t.Helper() + + t.mu.Lock() + defer t.mu.Unlock() + + 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 { + t.t.Helper() + + t.mu.Lock() + defer t.mu.Unlock() + + 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.t.Helper() + + t.mu.Lock() + defer t.mu.Unlock() + + 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.t.Helper() + + t.mu.Lock() + defer t.mu.Unlock() + + 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.t.Helper() + + 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 { + t.t.Helper() + + 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() { + t.t.Helper() + + defer func() { + if err := recover(); err != nil && + err != "testing: t.Parallel called multiple times" { + t.Panic(err) + } + }() + + t.t.Parallel() +} + +// TempDir delegates the request to the parent test context. +func (t *Context) TempDir() string { + t.t.Helper() + + return t.t.TempDir() +} + +// 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.Helper() + t.t.Setenv(key, value) +} + +// 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) { + t.t.Helper() + + t.mu.Lock() + defer t.mu.Unlock() + if !t.deadline.IsZero() { + return t.deadline, true + } + return t.t.Deadline() +} + +// 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.t.Helper() + + t.failed.Store(true) + + t.mu.Lock() + defer t.mu.Unlock() + + 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.t.Helper() + + if t.failed.Swap(true) { + runtime.Goexit() + } + + t.mu.Lock() + defer t.mu.Unlock() + 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.t.Helper() + + if t.failed.Swap(true) { + runtime.Goexit() + } + + t.mu.Lock() + defer t.mu.Unlock() + 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. +// +// TODO: check offset or/and find a better solution to handle panic stack. +const panicOriginStackOffset = 10 + +// Panic handles failure notifications of panics that also abort the test +// execution immediately. +func (t *Context) Panic(arg any) { + t.t.Helper() + + if t.failed.Swap(true) { + runtime.Goexit() + } + + t.mu.Lock() + defer t.mu.Unlock() + 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.t.Helper() + + if parallel { + t.t.Parallel() + } + + // Register cleanup handlers. + t.register() + + // Setup shorter deadline for detached test function. + wait := time.Duration(math.MaxInt64) + if deadline, ok := t.Deadline(); ok { + wait = time.Until(deadline) + } + + // Execute test function with channel to signal completion. + done := make(chan any, 1) + go t.run(test, done) + + // Wait for test to finish or deadline to expire. + select { + case <-done: + // Panic is already handled by the reporter. + case <-time.After(wait): + t.Fatalf("stopped by deadline") + } + + 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.t.Helper() + + defer func() { + t.t.Helper() + + // Unlock the waiting test context. + defer func() { done <- nil }() + + // Intercept and report panic as a failure. + if arg := recover(); arg != nil { + t.Panic(arg) + } + }() + + test(t) +} + +// register registers the clean up handlers with the parent test context. +func (t *Context) register() { + t.t.Helper() + + // Register cleanup handlers with the parent test context. + if c, ok := t.t.(Cleanuper); ok { + c.Cleanup(func() { + t.t.Helper() + + t.mu.Lock() + cleanups := slices.Reverse(t.cleanups) + t.mu.Unlock() + + for _, cleanup := range cleanups { + cleanup() + } + }) + } + + // Register handler to unlocked the waiting test context. + t.Cleanup(func() { + t.t.Helper() + t.finish() + }) +} + +// 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()) + } + } +} + +// 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..ede220e --- /dev/null +++ b/test/context_test.go @@ -0,0 +1,194 @@ +package test_test + +import ( + "os" + "testing" + "time" + + "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) + })) + } +} + +type TestDeadlineParam struct { + time, early, sleep time.Duration + expect mock.SetupFunc + failure test.Expect +} + +// TODO: fix unit test for deadline!! +var TestDeadlineParams = map[string]TestDeadlineParam{ + "failed": { + time: 0, + early: 0, + sleep: time.Millisecond, + expect: test.Fatalf("finished regularly"), + failure: test.Failure, + }, + "timeout": { + time: time.Millisecond, + early: 0, + sleep: time.Second, + expect: test.Fatalf("stopped by deadline"), + failure: test.Failure, + }, + "early": { + time: 5 * time.Millisecond, + early: 4 * time.Millisecond, + sleep: 4 * time.Millisecond, + expect: test.Fatalf("stopped by deadline"), + failure: test.Failure, + }, + "to-late": { + time: 5 * time.Millisecond, + early: 1 * time.Millisecond, + sleep: 1 * time.Millisecond, + expect: test.Fatalf("finished regularly"), + failure: test.Failure, + }, + "parent": { + time: 0, + early: 0, + sleep: 12 * time.Millisecond, + expect: mock.Chain( + test.Fatalf("stopped by deadline"), + test.Errorf("Expected test to succeed but it failed: %s", + "TestDeadline/parent"), + ), + failure: test.Failure, + }, +} + +func TestDeadline(t *testing.T) { + t.Parallel() + + test.Map(t, TestDeadlineParams). + Timeout(0).StopEarly(0). + Run(func(t test.Test, param TestDeadlineParam) { + mock.NewMocks(t).Expect(param.expect) + + test.New(t, !param.failure). + Timeout(param.time).StopEarly(param.early). + Run(func(t test.Test) { + // When + time.Sleep(param.sleep) + + // Then + t.Fatalf("finished regularly") + }, !test.Parallel) + }) +} diff --git a/test/gomock.go b/test/gomock.go index ef56419..c981893 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) } @@ -137,7 +137,8 @@ func UnexpectedCall[T any]( return func(_ Test, mocks *mock.Mocks) mock.SetupFunc { return Fatalf("Unexpected call to %T.%v(%v) at %s because: %s", mock.Get(mocks, creator), method, args, caller, - fmt.Errorf("there are no expected calls "+ //nolint:goerr113 // necessary + //nolint:goerr113 // necessary + fmt.Errorf("there are no expected calls "+ "of the method \"%s\" for that receiver", method)) } } @@ -162,7 +163,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..119f89d 100644 --- a/test/gomock_test.go +++ b/test/gomock_test.go @@ -125,7 +125,7 @@ var testCallMatcherParams = map[string]MatcherParams{ match: test.Errorf("fail"), expectMatches: true, expectString: "is equal to *test.Validator.Errorf" + - "(is equal to fail (string)) " + CallerGomockErrorf + + "(is equal to fail (string)) " + CallerReporterErrorf + " (*gomock.Call)", }, "call-matcher-success-any-nay": { @@ -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 @@ -235,7 +235,7 @@ var testReporterParams = map[string]ReporterParams{ "errorf consumed": { mockSetup: test.Errorf("fail"), failSetup: test.ConsumedCall(test.NewValidator, - "Errorf", CallerTestErrorf, CallerGomockErrorf, "fail"), + "Errorf", CallerTestErrorf, CallerReporterErrorf, "fail"), call: func(t test.Test) { t.Errorf("fail") t.Errorf("fail") 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..7ab8b32 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,154 @@ func TestFind(t *testing.T) { assert.Equal(t, param.expect, expect) }) } + +//revive:disable-next-line:function-length // Test suite approach. +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) {}) -}