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

zaptest: Add testing.TB compatible logger #518

Merged
merged 17 commits into from
Apr 12, 2018
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
zaptest/NewLogger: fail test on internal errors
Zap's errorOutput is used exclusively to log internal errors. If this
ever gets used in a test, something catastrophic happened and we should
fail the test.

This changes the logger returned by zaptest.NewLogger to implement this
behavior by passing a `WriteSyncer` to `zap.ErrorOutput` that marks the
test as failed upon being used.

To test this, we set up a test logger and replace its core with one that
will always fail to write.
  • Loading branch information
abhinav committed Apr 2, 2018
commit 2b3338cae312a9613c04cf3e00aa4d35c4bf10f7
30 changes: 28 additions & 2 deletions zaptest/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,39 @@ func NewLogger(t TestingT, opts ...LoggerOption) *zap.Logger {
o.applyLoggerOption(&cfg)
}

writer := newTestingWriter(t)
return zap.New(
zapcore.NewCore(
zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()),
testingWriter{t},
writer,
cfg.Level,
),

// Send zap errors to the same writer and mark the test as failed if
// that happens.
zap.ErrorOutput(writer.WithMarkFailed(true)),
)
Copy link
Contributor

@ash2k ash2k Mar 19, 2018

Choose a reason for hiding this comment

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

zap.New() sets the error output to stderr which should be overridden here. Otherwise there is no way to override it because testingWriter is not exported. And also I think it makes sense to do it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Makes sense, although if you end up writing to ErrorOutput, we should probably fail the test as well since that implies a serious Zap-internal error has occurred. I'll create a separate follow-up diff for that.

Copy link
Contributor

Choose a reason for hiding this comment

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

Excellent point. It should fail the test.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done in #566

}

// testingWriter is a WriteSyncer that writes to the given testing.TB.
type testingWriter struct{ t TestingT }
type testingWriter struct {
t TestingT

// If true, the test will be marked as failed if this testingWriter is
// ever used.
markFailed bool
}

func newTestingWriter(t TestingT) testingWriter {
return testingWriter{t: t}
}

// WithMarkFailed returns a copy of this testingWriter with markFailed set to
// the provided value.
func (w testingWriter) WithMarkFailed(v bool) testingWriter {
w.markFailed = v
return w
}

func (w testingWriter) Write(p []byte) (n int, err error) {
n = len(p)
Expand All @@ -85,6 +107,10 @@ func (w testingWriter) Write(p []byte) (n int, err error) {

// Note: t.Log is safe for concurrent use.
w.t.Logf("%s", p)
if w.markFailed {
w.t.Fail()
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we instead FailNow? If there's a bunch of debug output, it seems like it'll be hard to identify the offending log line unless it's the last one.

Copy link
Contributor

Choose a reason for hiding this comment

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

FailNow() should only be used from the test goroutine but the logger may have been invoked by some other goroutine that the test goroutine has started. So calling FailNow() can be an invalid thing to do. Related: golang/go#24678

}

return n, nil
}

Expand Down
69 changes: 65 additions & 4 deletions zaptest/logger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,16 @@ import (
"testing"

"go.uber.org/zap"
"go.uber.org/zap/internal/ztest"
"go.uber.org/zap/zapcore"

"github.com/stretchr/testify/assert"
)

func TestTestLogger(t *testing.T) {
ts := newTestLogSpy(t)
defer ts.AssertPassed()

log := NewLogger(ts)

log.Info("received work order")
Expand All @@ -57,6 +61,8 @@ func TestTestLogger(t *testing.T) {

func TestTestLoggerSupportsLevels(t *testing.T) {
ts := newTestLogSpy(t)
defer ts.AssertPassed()

log := NewLogger(ts, Level(zap.WarnLevel))

log.Info("received work order")
Expand All @@ -77,25 +83,69 @@ func TestTestLoggerSupportsLevels(t *testing.T) {

func TestTestingWriter(t *testing.T) {
ts := newTestLogSpy(t)
w := testingWriter{ts}
w := newTestingWriter(ts)

n, err := io.WriteString(w, "hello\n\n")
assert.NoError(t, err, "WriteString must not fail")
assert.Equal(t, 7, n)
}

func TestTestLoggerErrorOutput(t *testing.T) {
// This test verifies that the test logger logs internal messages to the
// testing.T and marks the test as failed.

ts := newTestLogSpy(t)
defer ts.AssertFailed()

log := NewLogger(ts)

// Replace with a core that fails.
log = log.WithOptions(zap.WrapCore(func(zapcore.Core) zapcore.Core {
return zapcore.NewCore(
zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()),
zapcore.Lock(zapcore.AddSync(ztest.FailWriter{})),
zapcore.DebugLevel,
)
}))

log.Info("foo") // this fails

if assert.Len(t, ts.Messages, 1, "expected a log message") {
assert.Regexp(t, `write error: failed`, ts.Messages[0])
}
}

// testLogSpy is a testing.TB that captures logged messages.
type testLogSpy struct {
testing.TB

mu sync.Mutex
mu sync.RWMutex
failed bool
Messages []string
}

func newTestLogSpy(t testing.TB) *testLogSpy {
return &testLogSpy{TB: t}
}

func (t *testLogSpy) Fail() {
t.mu.Lock()
t.failed = true
t.mu.Unlock()
}

func (t *testLogSpy) Failed() bool {
t.mu.RLock()
failed := t.failed
t.mu.RUnlock()
return failed
}

func (t *testLogSpy) FailNow() {
t.Fail()
t.TB.FailNow()
}

func (t *testLogSpy) Logf(format string, args ...interface{}) {
// Log messages are in the format,
//
Expand All @@ -106,7 +156,6 @@ func (t *testLogSpy) Logf(format string, args ...interface{}) {
m := fmt.Sprintf(format, args...)
m = m[strings.IndexByte(m, '\t')+1:]

// t.Log should be thread-safe.
t.mu.Lock()
t.Messages = append(t.Messages, m)
t.mu.Unlock()
Expand All @@ -115,5 +164,17 @@ func (t *testLogSpy) Logf(format string, args ...interface{}) {
}

func (t *testLogSpy) AssertMessages(msgs ...string) {
assert.Equal(t, msgs, t.Messages)
assert.Equal(t.TB, msgs, t.Messages, "logged messages did not match")
}

func (t *testLogSpy) AssertPassed() {
t.assertFailed(false, "expected test to pass")
}

func (t *testLogSpy) AssertFailed() {
t.assertFailed(true, "expected test to fail")
}

func (t *testLogSpy) assertFailed(v bool, msg string) {
assert.Equal(t.TB, v, t.failed, msg)
}