Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

testr: prevent panic when test keeps goroutines running #168

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
70 changes: 60 additions & 10 deletions testr/testr.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ limitations under the License.
package testr

import (
"sync/atomic"
"testing"

"github.com/go-logr/logr"
Expand Down Expand Up @@ -57,6 +58,12 @@ type TestingT interface {
Log(args ...interface{})
}

// cleanupT is implemented by the t instances that we get, but we don't require
// it in our API.
type cleanupT interface {
Cleanup(func())
}

// NewWithInterface returns a logr.Logger that prints through a
// TestingT object.
// In contrast to the simpler New, output formatting can be configured.
Expand All @@ -65,20 +72,32 @@ func NewWithInterface(t TestingT, opts Options) logr.Logger {
return logr.New(&l)
}

var noT TestingT

func newLoggerInterfaceWithOptions(t TestingT, opts Options) testloggerInterface {
return testloggerInterface{
t: t,
l := testloggerInterface{
t: new(atomic.Value),
Formatter: funcr.NewFormatter(funcr.Options{
LogTimestamp: opts.LogTimestamp,
Verbosity: opts.Verbosity,
}),
}
l.t.Store(&t)
if cleanup, ok := t.(cleanupT); ok {
cleanup.Cleanup(func() {
// Cannot store nil.
l.t.Store(&noT)
})
}
return l
}

// Underlier exposes access to the underlying testing.T instance. Since
// callers only have a logr.Logger, they have to know which
// implementation is in use, so this interface is less of an
// abstraction and more of a way to test type conversion.
//
// Returns nil when the test has already completed.
type Underlier interface {
GetUnderlying() *testing.T
}
Expand All @@ -87,6 +106,8 @@ type Underlier interface {
// callers only have a logr.Logger, they have to know which
// implementation is in use, so this interface is less of an
// abstraction and more of a way to test type conversion.
//
// Returns nil when the test has already completed.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slight change of behavior. OTOH, code calling this after test completion didn't get a usable TestingT either, so not a big difference in practice.

type UnderlierInterface interface {
GetUnderlying() TestingT
}
Expand Down Expand Up @@ -118,15 +139,24 @@ type testlogger struct {
}

func (l testlogger) GetUnderlying() *testing.T {
t := l.testloggerInterface.GetUnderlying()
if t == nil {
return nil
}

// This method is defined on testlogger, so the only type this could
// possibly be is testing.T, even though that's not guaranteed by the type
// system itself.
return l.t.(*testing.T) //nolint:forcetypeassert
return t.(*testing.T) //nolint:forcetypeassert
}

type testloggerInterface struct {
funcr.Formatter
t TestingT

// t will be cleared when the test is done because then t becomes
// unusable. In particular t.Log will panic with "Log in goroutine
// after ... has completed".
t *atomic.Value
}

func (l testloggerInterface) WithName(name string) logr.LogSink {
Expand All @@ -140,21 +170,41 @@ func (l testloggerInterface) WithValues(kvList ...interface{}) logr.LogSink {
}

func (l testloggerInterface) GetCallStackHelper() func() {
return l.t.Helper
t := l.GetUnderlying()
if t == nil {
return func() {}
}

return t.Helper
}

func (l testloggerInterface) Info(level int, msg string, kvList ...interface{}) {
l.t.Helper()
logInfo(l.t, l.FormatInfo, level, msg, kvList...)
t := l.GetUnderlying()
if t == nil {
return
}

t.Helper()
logInfo(t, l.FormatInfo, level, msg, kvList...)
}

func (l testloggerInterface) Error(err error, msg string, kvList ...interface{}) {
l.t.Helper()
logError(l.t, l.FormatError, err, msg, kvList...)
t := l.GetUnderlying()
if t == nil {
return
}

t.Helper()
logError(t, l.FormatError, err, msg, kvList...)
}

func (l testloggerInterface) GetUnderlying() TestingT {
return l.t
t := l.t.Load()
if t == &noT {
return nil
}
// This is the only type that this could possibly be.
return *t.(*TestingT) //nolint:forcetypeassert
}

// Assert conformance to the interfaces.
Expand Down
41 changes: 41 additions & 0 deletions testr/testr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ package testr

import (
"fmt"
"os"
"sync"
"testing"

"github.com/go-logr/logr"
Expand Down Expand Up @@ -87,3 +89,42 @@ func helper2(log logr.Logger, msg string) {
helper()
log.Info(msg)
}

var testStopWG sync.WaitGroup

func TestMain(m *testing.M) {
exitCode := m.Run()
testStopWG.Wait()

os.Exit(exitCode)
}

func TestStop(t *testing.T) {
// This test is set up so that a subtest spawns a goroutine and that
// goroutine logs through ktesting *after* the subtest has
// completed. This is not supported by testing.T.Log and normally
// leads to:
// panic: Log in goroutine after TestGoroutines/Sub has completed: INFO hello world
//
// It works with testr if (and only if) logging gets discarded
// before returning from the test.

var wg sync.WaitGroup
wg.Add(1)
testStopWG.Add(1)
logger := New(t)
go func() {
defer testStopWG.Done()

// Wait for test to have returned.
wg.Wait()

// This output must be discarded because the test has
// completed.
logger.Info("simple info message")
logger.Error(nil, "error message")
logger.WithName("me").WithValues("completed", true).Info("complex info message", "anotherValue", 1)
}()
// Allow goroutine above to proceed.
wg.Done()
}