From d75b3c9a43da12e1c36f694077601ea83b98bb76 Mon Sep 17 00:00:00 2001 From: Tronje Krop Date: Tue, 18 Nov 2025 13:20:58 +0100 Subject: [PATCH] feat: enabled reporter reuse (#131) Signed-off-by: Tronje Krop --- README.md | 12 ++-- VERSION | 2 +- internal/mock/common_test.go | 17 +++++- test/caller_test.go | 48 +++++++++++----- test/context.go | 62 +++++++++++---------- test/context_test.go | 20 +++---- test/reporter.go | 65 +++++++++++++++++++--- test/reporter_test.go | 103 ++++++++++++++++++++++++++++++++--- test/runner.go | 3 +- 9 files changed, 251 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 220da95..e2ea4d7 100644 --- a/README.md +++ b/README.md @@ -47,14 +47,14 @@ ## Introduction -Goal of the `testing` framework is to provide simple and efficient tools to for +Goal of the `testing` framework is to provide simple common patterns for writing effective unit, component, and integration tests in [`go`][go]. -To accomplish this, the `testing` framework provides a couple of extensions for -[`go`][go]'s [`testing`][testing] package that support a simple setup of -strongly isolated and parallel running unit test using [`gomock`][gomock] and/or -[`gock`][gock] that work under various failure scenarios, e.g. panics, and even -in the presence of spawned [`go`-routines][go-routines]. +To accomplish this, the `testing` framework provides a couple of extensions +for [`go`][go]'s [`testing`][testing] package that support a simple setup of +strongly isolated and parallel running unit tests using [`gomock`][gomock] +and/or [`gock`][gock] that work under various failure scenarios and in the +presence of spawned [`go`-routines][go-routines]. [go-routines]: diff --git a/VERSION b/VERSION index 2aa9160..31c01c9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.44 +0.0.45 diff --git a/internal/mock/common_test.go b/internal/mock/common_test.go index c4954c2..b947d66 100644 --- a/internal/mock/common_test.go +++ b/internal/mock/common_test.go @@ -324,9 +324,20 @@ var ( Results: []*Params{}, Variadic: true, }, { - Name: "Panic", - Params: []*Params{{Name: "arg", Type: "any"}}, - Results: []*Params{}, + Name: "Log", + Params: []*Params{ + {Name: "args", Type: "[]any"}, + }, + Results: []*Params{}, + Variadic: true, + }, { + Name: "Logf", + Params: []*Params{ + {Name: "format", Type: "string"}, + {Name: "args", Type: "[]any"}, + }, + Results: []*Params{}, + Variadic: true, }} methodsGoMockTestReporter = []*Method{{ diff --git a/test/caller_test.go b/test/caller_test.go index d242222..17ff0a4 100644 --- a/test/caller_test.go +++ b/test/caller_test.go @@ -20,6 +20,20 @@ type Caller struct { path string } +// Log is the caller reporter function to capture the callers file and line +// number of the `Log` call. +func (c *Caller) Log(_ ...any) { + _, path, line, _ := runtime.Caller(1) + c.path = path + ":" + strconv.Itoa(line) +} + +// Logf is the caller reporter function to capture the callers file and line +// number of the `Logf` call. +func (c *Caller) Logf(_ string, _ ...any) { + _, path, line, _ := runtime.Caller(1) + c.path = path + ":" + strconv.Itoa(line) +} + // Error is the caller reporter function to capture the callers file and line // number of the `Error` call. func (c *Caller) Error(_ ...any) { @@ -76,8 +90,8 @@ 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.New(&testing.T{}, test.Failure, false) +func getCaller(call func(t test.Panicer)) string { + t := test.New(&testing.T{}, false).Expect(test.Failure) mocks := mock.NewMocks(t) caller := mock.Get(mocks, func(*gomock.Controller) *Caller { @@ -92,32 +106,40 @@ func getCaller(call func(t test.Reporter)) string { } var ( + // CallerLog provides the file with line number of the `Log` call. + CallerLog = getCaller(func(t test.Panicer) { + t.Log("log") + }) + // CallerLogf provides the file with line number of the `Logf` call. + CallerLogf = getCaller(func(t test.Panicer) { + t.Logf("%s", "log") + }) // CallerError provides the file with line number of the `Error` call. - CallerError = getCaller(func(t test.Reporter) { + CallerError = getCaller(func(t test.Panicer) { t.Error("fail") }) // CallerErrorf provides the file with line number of the `Errorf` call. - CallerErrorf = getCaller(func(t test.Reporter) { + CallerErrorf = getCaller(func(t test.Panicer) { t.Errorf("%s", "fail") }) // CallerFatal provides the file with line number of the `Fatal` call. - CallerFatal = getCaller(func(t test.Reporter) { + CallerFatal = getCaller(func(t test.Panicer) { t.Fatal("fail") }) // CallerFatalf provides the file with line number of the `Fatalf` call. - CallerFatalf = getCaller(func(t test.Reporter) { + CallerFatalf = getCaller(func(t test.Panicer) { t.Fatalf("%s", "fail") }) // CallerFail provides the file with line number of the `Fail` call. - CallerFail = getCaller(func(t test.Reporter) { + CallerFail = getCaller(func(t test.Panicer) { t.Fail() }) // CallerFailNow provides the file with line number of the `FailNow` call. - CallerFailNow = getCaller(func(t test.Reporter) { + CallerFailNow = getCaller(func(t test.Panicer) { t.FailNow() }) // CallerPanic provides the file with line number of the `FailNow` call. - CallerPanic = getCaller(func(t test.Reporter) { + CallerPanic = getCaller(func(t test.Panicer) { t.Panic("fail") }) @@ -125,15 +147,15 @@ var ( SourceDir = test.Must(os.Getwd()) // CallerTestError provides the file with the line number of the `Error` // call in the test context implementation. - CallerTestError = path.Join(SourceDir, "context.go:352") + CallerTestError = path.Join(SourceDir, "context.go:344") // CallerReporterErrorf provides the file with the line number of the // `Errorf` call in the test reporter/validator implementation. - CallerReporterError = path.Join(SourceDir, "reporter.go:87") + CallerReporterError = path.Join(SourceDir, "reporter.go:135") // CallerTestErrorf provides the file with the line number of the `Errorf` // call in the test context implementation. - CallerTestErrorf = path.Join(SourceDir, "context.go:370") + CallerTestErrorf = path.Join(SourceDir, "context.go:362") // CallerReporterErrorf provides the file with the line number of the // `Errorf` call in the test reporter/validator implementation. - CallerReporterErrorf = path.Join(SourceDir, "reporter.go:109") + CallerReporterErrorf = path.Join(SourceDir, "reporter.go:158") ) diff --git a/test/context.go b/test/context.go index 41a1958..2ff9188 100644 --- a/test/context.go +++ b/test/context.go @@ -16,6 +16,8 @@ import ( // 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 { //nolint:interfacebloat // Minimal interface. + // Embeds the basic test reporter interface. + Reporter // Name provides the test name. Name() string // Helper declares a test helper function. @@ -38,33 +40,13 @@ type Test interface { //nolint:interfacebloat // Minimal interface. SkipNow() // Skipped reports whether the test has been skipped. Skipped() bool - // Log provides a logging function for the test. - Log(args ...any) - // Logf provides a logging function for the test. - Logf(format string, args ...any) - // Error handles a failure messages when a test is supposed to continue. - Error(args ...any) - // Errorf handles a failure messages when a test is supposed to continue. - Errorf(format string, args ...any) - // Fatal handles a fatal failure message that immediate aborts of the test - // execution. - Fatal(args ...any) - // Fatalf handles a fatal failure message that immediate aborts of the test - // execution. - Fatalf(format string, args ...any) - // Fail handles a failure message that immediate aborts of the test - // execution. - Fail() - // FailNow handles fatal failure notifications without log output that - // aborts test execution immediately. - FailNow() // Failed reports whether the test has failed. Failed() bool // Cleanup is a function called to setup test cleanup after execution. Cleanup(cleanup func()) } -// Cleanuper defines an interface to add a custom mehtod that is called after +// Cleanuper defines an interface to add a custom method that is called after // the test execution to cleanup the test environment. type Cleanuper interface { Cleanup(cleanup func()) @@ -80,7 +62,7 @@ func Run(expect Expect, test Func) func(*testing.T) { return func(t *testing.T) { t.Helper() - New(t, expect, Parallel).Run(test) + New(t, Parallel).Expect(expect).Run(test) } } @@ -91,7 +73,7 @@ func RunSeq(expect Expect, test Func) func(*testing.T) { return func(t *testing.T) { t.Helper() - New(t, expect, !Parallel).Run(test) + New(t, !Parallel).Expect(expect).Run(test) } } @@ -102,7 +84,7 @@ func InRun(expect Expect, test Func) Func { return func(t Test) { t.Helper() - New(t, expect, !Parallel).Run(test) + New(t, !Parallel).Expect(expect).Run(test) } } @@ -123,14 +105,14 @@ type Context struct { } // 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, parallel bool) *Context { +// context with. The parent test context is used to delegate methods calls +// to the parent context to propagate test results. +func New(t Test, parallel bool) *Context { if tx, ok := t.(*Context); ok { return &Context{ t: tx, wg: tx.wg, deadline: tx.deadline, - expect: expect, + expect: true, parallel: parallel, } } @@ -142,11 +124,23 @@ func New(t Test, expect Expect, parallel bool) *Context { deadline, _ := t.Deadline() return deadline }(t), - expect: expect, + expect: true, parallel: parallel, } } +// Expect sets up a new test outcome. +func (t *Context) Expect(expect Expect) *Context { + t.t.Helper() + + t.mu.Lock() + defer t.mu.Unlock() + + t.expect = expect + + return 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 @@ -325,6 +319,9 @@ func (t *Context) Log(args ...any) { t.t.Helper() t.t.Log(args...) + if t.reporter != nil { + t.reporter.Log(args...) + } } // Logf delegates request to the parent context. It provides a logging function @@ -333,6 +330,9 @@ func (t *Context) Logf(format string, args ...any) { t.t.Helper() t.t.Logf(format, args...) + if t.reporter != nil { + t.reporter.Logf(format, args...) + } } // Error handles failure messages where the test is supposed to continue. On @@ -466,7 +466,9 @@ func (t *Context) Panic(arg any) { stack := regexPanic.Split(string(debug.Stack()), -1) t.t.Fatalf("panic: %v\n%s\n%s", arg, stack[0], stack[1]) } else if t.reporter != nil { - t.reporter.Panic(arg) + if reporter, ok := t.reporter.(Panicer); ok { + reporter.Panic(arg) + } } runtime.Goexit() } diff --git a/test/context_test.go b/test/context_test.go index e498965..0edefb5 100644 --- a/test/context_test.go +++ b/test/context_test.go @@ -75,8 +75,8 @@ func TestContext(t *testing.T) { mock.NewMocks(t).Expect(param.setup) // When - test.New(t, test.Success, !test.Parallel). - Run(param.test) + test.New(t, !test.Parallel). + Expect(test.Success).Run(param.test) })) } } @@ -129,8 +129,8 @@ func TestCleanup(t *testing.T) { t.Cleanup(func() { wg.Wait() }) // When - test.New(t, test.Success, test.Parallel). - Run(param.test) + test.New(t, test.Parallel). + Expect(test.Success).Run(param.test) // Then defer wg.Done() @@ -208,11 +208,11 @@ func TestContextParallel(t *testing.T) { } // When - test.New(t, test.Success, param.parallel). - Run(func(t test.Test) { - mock.NewMocks(t).Expect(param.setup) - param.during(t) - }) + test.New(t, param.parallel). + Expect(test.Success).Run(func(t test.Test) { + mock.NewMocks(t).Expect(param.setup) + param.during(t) + }) })) } } @@ -273,7 +273,7 @@ func TestDeadline(t *testing.T) { Run(func(t test.Test, param DeadlineParams) { mock.NewMocks(t).Expect(param.expect) - test.New(t, !param.failure, !test.Parallel). + test.New(t, !test.Parallel).Expect(!param.failure). Timeout(param.time).StopEarly(param.early). Run(func(t test.Test) { // When diff --git a/test/reporter.go b/test/reporter.go index 8c0c56f..959f0e5 100644 --- a/test/reporter.go +++ b/test/reporter.go @@ -11,11 +11,11 @@ import ( // Reporter is a minimal interface for abstracting test report methods that are // needed to setup an isolated test environment for GoMock and Testify. -// -// `Reporter` is currently not implemented by `Test` and `testing.T`. -// -// TODO: consider dropping `Panic` to allow compatibility with `Test`. type Reporter interface { + // Log provides a logging function for the test. + Log(args ...any) + // Logf provides a logging function for the test. + Logf(format string, args ...any) // Error reports a failure messages when a test is supposed to continue. Error(args ...any) // Errorf reports a failure messages when a test is supposed to continue. @@ -32,6 +32,12 @@ type Reporter interface { // FailNow reports fatal failure notifications without log output that // aborts test execution immediately. FailNow() +} + +// Panicer is a test reporter that supports in addition panic reporting. +type Panicer interface { + // Embeds the basic test reporter interface. + Reporter // Panic reports a panic. Panic(arg any) } @@ -55,8 +61,8 @@ func NewValidator(ctrl *gomock.Controller) *Validator { 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 = New(t.t, t.expect, t.parallel) - t.expect = Failure + ctrl.T = New(t.t, t.parallel).Expect(t.expect) + t.expect = false t.Reporter(validator) } return validator @@ -67,6 +73,49 @@ func (v *Validator) EXPECT() *Recorder { return v.recorder } +// Log receive expected method call to `Log`. +func (v *Validator) Log(args ...any) { + v.ctrl.T.Helper() + v.ctrl.Call(v, "Log", args...) +} + +// Log indicate an expected method call to `Log`. +func (r *Recorder) Log(args ...any) *gomock.Call { + r.validator.ctrl.T.Helper() + return r.validator.ctrl.RecordCallWithMethodType(r.validator, "Log", + reflect.TypeOf((*Validator)(nil).Log), args...) +} + +// Log creates a validation method call setup for `Log`. +func Log(args ...any) mock.SetupFunc { + return func(mocks *mock.Mocks) any { + return mock.Get(mocks, NewValidator).EXPECT(). + Log(args...).Do(mocks.Do(Reporter.Log)) + } +} + +// Logf receive expected method call to `Logf`. +func (v *Validator) Logf(format string, args ...any) { + v.ctrl.T.Helper() + v.ctrl.Call(v, "Logf", append([]any{format}, args...)...) +} + +// Logf creates a validation method call setup for `Logf`. +func Logf(format string, args ...any) mock.SetupFunc { + return func(mocks *mock.Mocks) any { + return mock.Get(mocks, NewValidator).EXPECT(). + Logf(format, args...).Do(mocks.Do(Reporter.Logf)) + } +} + +// Logf indicate an expected method call to `Logf`. +func (r *Recorder) Logf(format string, args ...any) *gomock.Call { + r.validator.ctrl.T.Helper() + return r.validator.ctrl.RecordCallWithMethodType(r.validator, "Logf", + reflect.TypeOf((*Validator)(nil).Logf), + append([]any{format}, args...)...) +} + // Error receive expected method call to `Error`. func (v *Validator) Error(args ...any) { v.ctrl.T.Helper() @@ -214,7 +263,7 @@ func (r *Recorder) Panic(arg any) *gomock.Call { func Panic(arg any) mock.SetupFunc { return func(mocks *mock.Mocks) any { return mock.Get(mocks, NewValidator).EXPECT(). - Panic(EqError(arg)).Do(mocks.Do(Reporter.Panic)) + Panic(EqError(arg)).Do(mocks.Do(Panicer.Panic)) } } @@ -253,7 +302,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(New(t, false, false)) + mocks := mock.NewMocks(New(t, false).Expect(Failure)) calls := make([]func(*mock.Mocks) any, 0, len(setups)) for _, setup := range setups { calls = append(calls, diff --git a/test/reporter_test.go b/test/reporter_test.go index 3939ec1..e288f86 100644 --- a/test/reporter_test.go +++ b/test/reporter_test.go @@ -145,7 +145,7 @@ func TestCallMatcher(t *testing.T) { test.Map(t, callMatcherTestCases). Run(func(t test.Test, param MatcherParams) { // Given - send mock calls to unchecked test context. - mocks := mock.NewMocks(test.New(t, test.Success, false)) + mocks := mock.NewMocks(test.New(t, false).Expect(test.Success)) matcher := param.matcher(evalCall(param.base, mocks)) // When @@ -161,52 +161,106 @@ type ReporterParams struct { setup mock.SetupFunc misses func(test.Test, *mock.Mocks) mock.SetupFunc call test.Func + expect test.Expect } var reporterTestCases = map[string]ReporterParams{ + "log called": { + setup: test.Log("log message"), + call: func(t test.Test) { + test.Cast[*test.Context](t).Expect(test.Success) + t.Log("log message") + }, + expect: test.Success, + }, + "logf called": { + setup: test.Logf("%s", "log message"), + call: func(t test.Test) { + test.Cast[*test.Context](t).Expect(test.Success) + t.Logf("%s", "log message") + }, + expect: test.Success, + }, "error called": { setup: test.Error("fail"), call: func(t test.Test) { + test.Cast[*test.Context](t).Expect(test.Success) t.Error("fail") }, }, "errorf called": { setup: test.Errorf("%s", "fail"), call: func(t test.Test) { + test.Cast[*test.Context](t).Expect(test.Success) t.Errorf("%s", "fail") }, }, "fatal called": { setup: test.Fatal("fail"), call: func(t test.Test) { + test.Cast[*test.Context](t).Expect(test.Success) t.Fatal("fail") }, }, "fatalf called": { setup: test.Fatalf("%s", "fail"), call: func(t test.Test) { + test.Cast[*test.Context](t).Expect(test.Success) t.Fatalf("%s", "fail") }, }, "fail called": { setup: test.Fail(), call: func(t test.Test) { + test.Cast[*test.Context](t).Expect(test.Success) t.Fail() }, }, "failnow called": { setup: test.FailNow(), call: func(t test.Test) { + test.Cast[*test.Context](t).Expect(test.Success) t.FailNow() }, }, "panic called": { setup: test.Panic("fail"), - call: func(test.Test) { + call: func(t test.Test) { + test.Cast[*test.Context](t).Expect(test.Success) panic("fail") }, }, + "log undeclared": { + misses: test.UnexpectedCall(test.NewValidator, + "Log", CallerLog, "log"), + call: func(t test.Test) { + t.Log("log") + }, + }, + "log undeclared twice": { + misses: test.UnexpectedCall(test.NewValidator, + "Log", CallerLog, "log"), + call: func(t test.Test) { + t.Log("log") + t.Log("log") + }, + }, + "logf undeclared": { + misses: test.UnexpectedCall(test.NewValidator, + "Logf", CallerLogf, "%s", "log"), + call: func(t test.Test) { + t.Logf("%s", "log") + }, + }, + "logf undeclared twice": { + misses: test.UnexpectedCall(test.NewValidator, + "Logf", CallerLogf, "%s", "log"), + call: func(t test.Test) { + t.Logf("%s", "log") + t.Logf("%s", "log") + }, + }, "error undeclared": { misses: test.UnexpectedCall(test.NewValidator, "Error", CallerError, "fail"), @@ -248,7 +302,7 @@ var reporterTestCases = map[string]ReporterParams{ misses: test.UnexpectedCall(test.NewValidator, "Fatal", CallerFatal, "fail"), call: func(t test.Test) { - //revive:disable-next-line:unreachable-code // needed for testing + //revive:disable-next-line:unreachable-code // needed for testing. t.Fatal("fail") t.Fatal("fail") }, @@ -269,6 +323,22 @@ var reporterTestCases = map[string]ReporterParams{ t.Fatalf("%s", "fail") }, }, + "fail undeclared": { + misses: test.UnexpectedCall(test.NewValidator, + "Fail", CallerFail), + call: func(t test.Test) { + t.Fail() + }, + }, + "fail undeclared twice": { + misses: test.UnexpectedCall(test.NewValidator, + "Fail", CallerFail), + call: func(t test.Test) { + //revive:disable-next-line:unreachable-code // needed for testing + t.Fail() + t.Fail() + }, + }, "failnow undeclared": { misses: test.UnexpectedCall(test.NewValidator, "FailNow", CallerFailNow), @@ -301,6 +371,7 @@ var reporterTestCases = map[string]ReporterParams{ misses: test.ConsumedCall(test.NewValidator, "Error", CallerTestError, CallerReporterError, "fail"), call: func(t test.Test) { + test.Cast[*test.Context](t).Expect(test.Success) t.Error("fail") t.Error("fail") }, @@ -310,6 +381,7 @@ var reporterTestCases = map[string]ReporterParams{ misses: test.ConsumedCall(test.NewValidator, "Errorf", CallerTestErrorf, CallerReporterErrorf, "%s", "fail"), call: func(t test.Test) { + test.Cast[*test.Context](t).Expect(test.Success) t.Errorf("%s", "fail") t.Errorf("%s", "fail") }, @@ -317,7 +389,8 @@ var reporterTestCases = map[string]ReporterParams{ "fatal consumed": { setup: test.Fatal("fail"), call: func(t test.Test) { - //revive:disable-next-line:unreachable-code // needed for testing + test.Cast[*test.Context](t).Expect(test.Success) + //revive:disable-next-line:unreachable-code // needed for testing. t.Fatal("fail") t.Fatal("fail") }, @@ -325,24 +398,36 @@ var reporterTestCases = map[string]ReporterParams{ "fatalf consumed": { setup: test.Fatalf("%s", "fail"), call: func(t test.Test) { - //revive:disable-next-line:unreachable-code // needed for testing + test.Cast[*test.Context](t).Expect(test.Success) + //revive:disable-next-line:unreachable-code // needed for testing. t.Fatalf("%s", "fail") t.Fatalf("%s", "fail") }, }, + "fail consumed": { + setup: test.Fail(), + call: func(t test.Test) { + test.Cast[*test.Context](t).Expect(test.Success) + //revive:disable-next-line:unreachable-code // needed for testing. + t.Fail() + t.Fail() + }, + }, "failnow consumed": { setup: test.FailNow(), call: func(t test.Test) { - //revive:disable-next-line:unreachable-code // needed for testing + test.Cast[*test.Context](t).Expect(test.Success) + //revive:disable-next-line:unreachable-code // needed for testing. t.FailNow() t.FailNow() }, }, "panic consumed": { setup: test.Panic("fail"), - call: func(test.Test) { + call: func(t test.Test) { + test.Cast[*test.Context](t).Expect(test.Success) panic("fail") - //nolint:govet // needed for testing + //nolint:govet // needed for testing. panic("fail") }, }, @@ -380,7 +465,7 @@ var reporterTestCases = map[string]ReporterParams{ setup: mock.Chain(test.Errorf("%s", "fail"), test.Fail()), misses: test.MissingCalls(test.Fail()), call: func(t test.Test) { - t.Errorf("%s", "fail") + t.Fail() }, }, "failnow missing": { diff --git a/test/runner.go b/test/runner.go index 39477ae..8df179a 100644 --- a/test/runner.go +++ b/test/runner.go @@ -309,7 +309,8 @@ func (r *factory[P]) wrap( return func(t *testing.T) { t.Helper() - New(t, reflect.Find(param, Success, "expect", "*"), parallel). + New(t, parallel). + Expect(reflect.Find(param, Success, "expect", "*")). Timeout(reflect.Find(param, r.timeout, "timeout")). StopEarly(reflect.Find(param, r.early, "early")). Run(func(t Test) {