From 73b61b2b7fe0ae776328ad99ab7ba6915759eecf Mon Sep 17 00:00:00 2001 From: Tronje Krop Date: Sat, 21 Sep 2024 18:36:45 +0200 Subject: [PATCH] feat: add setenv and error support (#85) Signed-off-by: Tronje Krop --- Makefile.vars | 2 +- VERSION | 2 +- internal/mock/common_test.go | 8 +++ mock/README.md | 16 +++--- test/README.md | 4 +- test/caller_test.go | 2 +- test/reflect.go | 52 +++++++++++++++++ test/reflect_test.go | 53 ++++++++++++++++++ test/testing.go | 76 ++++++++++++++++--------- test/testing_test.go | 106 +++++++++++++++++++++++++++++++---- 10 files changed, 272 insertions(+), 49 deletions(-) create mode 100644 test/reflect.go create mode 100644 test/reflect_test.go diff --git a/Makefile.vars b/Makefile.vars index e939aeb..c320ff0 100644 --- a/Makefile.vars +++ b/Makefile.vars @@ -24,4 +24,4 @@ CODACY_API_BASE_URL := https://api.codacy.com # Custom linters applied to prepare next level (default: ). LINTERS_CUSTOM := nonamedreturns gochecknoinits tagliatelle # Linters swithed off to complete next level (default: ). -LINTERS_DISABLED := depguard +LINTERS_DISABLED := diff --git a/VERSION b/VERSION index 9789c4c..ceddfb2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.14 +0.0.15 diff --git a/internal/mock/common_test.go b/internal/mock/common_test.go index c77432b..f9ab2bf 100644 --- a/internal/mock/common_test.go +++ b/internal/mock/common_test.go @@ -205,6 +205,14 @@ var ( Params: []*Param{}, Results: []*Param{}, Variadic: false, + }, { + Name: "Setenv", + Params: []*Param{ + {Name: "key", Type: "string"}, + {Name: "value", Type: "string"}, + }, + Results: []*Param{}, + Variadic: false, }, { Name: "TempDir", Params: []*Param{}, diff --git a/mock/README.md b/mock/README.md index 03e0c8d..f0a469c 100644 --- a/mock/README.md +++ b/mock/README.md @@ -5,8 +5,8 @@ common mock controller interface for [`gomock`][gomock] and [`gock`][gock] that enables a unified, highly reusable integration pattern. Unfortunately, we had to sacrifice a bit of type-safety to allow for chaining -mock calls in an arbitrary way during setup. Anyhow, the offered in runtime -validation is a sufficient strategy to cover for the missing type-safety. +mock calls arbitrarily during setup. Anyhow, the offered in runtime validation +is a sufficient strategy to cover for the missing type-safety. ## Example usage @@ -39,8 +39,8 @@ patterns as described in the following. ## Generic mock controller setup -Usually, a new system under test must be create for each test run. Therefore, -the following generic pattern to setup the mock controller with an arbitrary +Usually, a new system under test must be created for each test run. Therefore, +the following generic pattern to set up the mock controller with an arbitrary system under test is very useful. ```go @@ -91,7 +91,7 @@ completion via `Do|DoAndReturn()`. For test with detached *goroutines* the test can wait via `mocks.Wait()`, before finishing and checking whether the mock calls are completely consumed. -Since some arguments needed to setup a mock call may only be available after +Since some arguments needed to set up a mock call may only be available after creating the test runner, the mock controller provides a dynamic key-value storage that is accessible via `SetArg(key,value)`, `SetArgs(map[key]value)`, and `GetArg(key)`. @@ -142,7 +142,7 @@ With the above preparations for mocking service calls we can now define the with other setup methods that determine the predecessors and successor mock calls. -* `Parallel` allows to creates an unordered set of mock calls that can be +* `Parallel` allows to create an unordered set of mock calls that can be combined with other setup methods that determine the predecessor and successor mock calls. @@ -150,8 +150,8 @@ With the above preparations for mocking service calls we can now define the no relation to predecessors and successors it was defined with. Beside this simple (un-)ordering methods there are two further methods for -completeness, that allow to control how predecessors and successors are used -to setup ordering conditions: +completeness, that allows control of how predecessors and successors are used +to set up ordering conditions: * `Sub` allows to define a sub-set or sub-chain of elements in `Parallel` and `Chain` as predecessor and successor context for further combination. diff --git a/test/README.md b/test/README.md index e7345ab..ac60c06 100644 --- a/test/README.md +++ b/test/README.md @@ -121,7 +121,7 @@ Or the interface of the underlying `test.Tester`: func TestUnit(t *testing.T) { t.Parallel() - test.Tester(t, test.Success).Run(func(t test.Test){ + test.NewTester(t, test.Success).Run(func(t test.Test){ // Given // When @@ -144,7 +144,7 @@ validator that is tightly integrated with the [`mock`](../mock) framework. func TestUnit(t *testing.T) { test.Run(func(t test.Test){ // Given - mocks := mock.NewMocks(t).Expect(mock.Setup( + mock.NewMocks(t).Expect(mock.Setup( test.Errorf("fail"), test.Fatalf("fail"), test.FailNow(), diff --git a/test/caller_test.go b/test/caller_test.go index 2bd555d..ac29bf9 100644 --- a/test/caller_test.go +++ b/test/caller_test.go @@ -94,7 +94,7 @@ var ( }() // CallerTestErrorf provides the file with the line number of the `Errorf` // call in testing. - CallerTestErrorf = path.Join(SourceDir, "testing.go:204") + CallerTestErrorf = path.Join(SourceDir, "testing.go:206") // CallerGomockErrorf provides the file with the line number of the // `Errorf` call in gomock. CallerGomockErrorf = path.Join(SourceDir, "gomock.go:61") diff --git a/test/reflect.go b/test/reflect.go new file mode 100644 index 0000000..5573362 --- /dev/null +++ b/test/reflect.go @@ -0,0 +1,52 @@ +package test + +import ( + "reflect" + "unsafe" +) + +// Error creates an Accessor for the given error to access and modify its +// unexported fields by field name. +// +// Example: +// +// err := test.Error(errors.New("error message")).Set("text", "new message").Get("") +// fmt.Println(err.Error()) // Output: new message +// +// err := test.Error(errors.New("error message")).Set("text", "new message").Get("text") +// fmt.Println(err) // Output: new message +func Error(err error) *Accessor[error] { + return NewAccessor[error](err) +} + +// Accessor allows you to access and modify unexported fields of a struct. +type Accessor[T any] struct { + target T +} + +// NewAccessor creates a generic accessor for the given target. +func NewAccessor[T any](target T) *Accessor[T] { + return &Accessor[T]{target: target} +} + +// Set sets the value of the accessor target's field with the given name. +func (a *Accessor[T]) Set(name string, value any) *Accessor[T] { + field := reflect.ValueOf(a.target).Elem().FieldByName(name) + // #nosec G103,G115 // This is a safe use of unsafe.Pointer. + reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())). + Elem().Set(reflect.ValueOf(value)) + + return a +} + +// Get returns the value of the field with the given name. If the name is empty, +// it returns the accessor target itself. +func (a *Accessor[T]) Get(name string) any { + if name == "" { + return a.target + } + field := reflect.ValueOf(a.target).Elem().FieldByName(name) + // #nosec G103,G115 // This is a safe use of unsafe.Pointer. + return reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())). + Elem().Interface() +} diff --git a/test/reflect_test.go b/test/reflect_test.go new file mode 100644 index 0000000..b7d5fbb --- /dev/null +++ b/test/reflect_test.go @@ -0,0 +1,53 @@ +package test_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/tkrop/go-testing/test" +) + +type testErrorParam struct { + error error + setup func(*test.Accessor[error]) + test func(test.Test, *test.Accessor[error]) +} + +var testErrorParams = map[string]testErrorParam{ + "test get": { + error: errors.New("test get"), + test: func(t test.Test, a *test.Accessor[error]) { + assert.Equal(t, "test get", a.Get("s")) + assert.Equal(t, errors.New("test get"), a.Get("")) + }, + }, + + "test set": { + error: errors.New("test set"), + setup: func(a *test.Accessor[error]) { + a.Set("s", "test set first"). + Set("s", "test set final") + }, + test: func(t test.Test, a *test.Accessor[error]) { + assert.Equal(t, "test set final", a.Get("s")) + assert.Equal(t, errors.New("test set final"), a.Get("")) + }, + }, +} + +func TestError(t *testing.T) { + test.Map(t, testErrorParams). + Run(func(t test.Test, param testErrorParam) { + // Given + accessor := test.Error(param.error) + + // When + if param.setup != nil { + param.setup(accessor) + } + + // The + param.test(t, accessor) + }) +} diff --git a/test/testing.go b/test/testing.go index b296c04..2a43d80 100644 --- a/test/testing.go +++ b/test/testing.go @@ -40,18 +40,6 @@ const ( Parallel = true ) -// ensureParallel ensures that the test runs test parameter sets in parallel. -func ensureParallel(t *testing.T) { - t.Helper() - defer func() { - if v := recover(); v != nil && - v != "testing: t.Parallel called multiple times" { - panic(v) - } - }() - t.Parallel() -} - // TODO: consider following convenience methods: // // // Result is a convenience method that returns the first argument ans swollows @@ -87,12 +75,18 @@ func ensureParallel(t *testing.T) { // return result // } -// Reporter is a minimal inferface for abstracting test report methods that are +// 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() } @@ -110,12 +104,14 @@ type Test interface { TempDir() string // Errorf handles a failure messages when a test is supposed to continue. Errorf(format string, args ...any) - // Fatalf handles a fatal failure messge that immediate aborts of the test + // 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 @@ -179,12 +175,18 @@ func (t *Tester) Helper() { t.t.Helper() } -// Parallel delegates request to the parent context if it is of type -// `*testing.T`. Else it is swallowing the request silently. +// 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() { - if t, ok := t.t.(*testing.T); ok { - ensureParallel(t) - } + 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. @@ -205,7 +207,7 @@ func (t *Tester) Errorf(format string, args ...any) { } } -// Fatalf handles a fatal failure messge that immediate aborts of the test +// 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. @@ -334,12 +336,25 @@ func (t *Tester) finish() { func (t *Tester) recover() { t.Helper() - //revive:disable-next-line:defer // is inside the defered function + //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" { + // TODO: t.Panic(v) + panic(v) + } +} + // unlock unlocks the wait group of the test by consuming the wait group // counter completely. func (t *Tester) unlock() { @@ -414,14 +429,25 @@ func (r *runner[P]) Run(call func(t Test, param P)) Runner[P] { // 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, false) + return r.run(call, !Parallel) } // parallel ensures that the test runner runs the test parameter sets in // parallel. func (r *runner[P]) parallel(parallel bool) { if parallel { - ensureParallel(r.t) + 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) } } @@ -506,7 +532,7 @@ func Run(expect Expect, test func(Test)) func(*testing.T) { // 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, false) + return run(expect, test, !Parallel) } // Run creates an isolated parallel or sequential test environment running the @@ -527,7 +553,7 @@ func InRun(expect Expect, test func(Test)) func(Test) { return func(t Test) { t.Helper() - NewTester(t, expect).Run(test, false) + NewTester(t, expect).Run(test, !Parallel) } } diff --git a/test/testing_test.go b/test/testing_test.go index 1fd5cb8..7f0c549 100644 --- a/test/testing_test.go +++ b/test/testing_test.go @@ -1,6 +1,7 @@ package test_test import ( + "os" "sync/atomic" "testing" @@ -281,21 +282,104 @@ func TestTypePanic(t *testing.T) { Run(func(test.Test, TestParam) {}) } -func TestParallel(t *testing.T) { - t.Parallel() - test.New[ParamParam](t, []ParamParam{{expect: false}}). - Run(func(t test.Test, _ ParamParam) { +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() + }, + }, + + // "expose panic in parallel": { + // during: func(t test.Test) { + // t.Setenv("TESTING", "during") + // t.Parallel() + // assert.Equal(t, "during", os.Getenv("TESTING")) + // }, + // expect: test.Panic("testing: t.Parallel called after t.Setenv;" + + // " cannot set environment variables in parallel tests"), + // }, +} + +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) + })) + } } -func TestParallelDenied(t *testing.T) { - t.Setenv("TESTING", "true") +// 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()) + 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: false}}). - Run(func(test.Test, ParamParam) {}) + test.New[ParamParam](t, []ParamParam{{expect: true}}). + Run(func(_ test.Test, _ ParamParam) {}) }