Skip to content

Commit 985e837

Browse files
committed
fix(logger): attribute parallel logs to the correct test under go test -json
The default Terratest logger wrote formatted lines straight to os.Stdout. For parallel tests, go test -json cannot tell which test a raw stdout write belongs to, so it tags each line with whichever test was active, mixing up the output of parallel tests (issue #1871). When a *testing.T is available, DoLog now routes the line through t.Log so the framework attributes it to the correct test, including under t.Parallel(). t.Log still streams immediately under -v on Go 1.14+, and an explicit non-stdout writer is still honored. If the test has already completed, it falls back to a direct stdout write instead of panicking.
1 parent 74bb0d6 commit 985e837

2 files changed

Lines changed: 97 additions & 3 deletions

File tree

modules/core/logger/logger.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ var (
4444
// because there is no log output with t.Logf (e.g., CircleCI kills tests after 10 minutes of no log output). With
4545
// this log method, you get log output continuously.
4646
//
47+
// When a *testing.T is available, the log is emitted through t.Log rather than written to stdout directly, so that
48+
// `go test -json` attributes each line to the correct test even when tests run in parallel (see DoLog and issue
49+
// #1871). t.Log still streams immediately under `-v` on Go 1.14+, so the benefits above are preserved.
50+
//
4751
Terratest = New(terratestLogger{})
4852
// TestingT can be used to use Go's testing.T to log. If this is used, but no testing.T is provided, it will fallback
4953
// to Default.
@@ -109,6 +113,10 @@ func (testingT) Logf(t testing.TestingT, format string, args ...any) {
109113
type terratestLogger struct{}
110114

111115
func (terratestLogger) Logf(t testing.TestingT, format string, args ...any) {
116+
if h, ok := t.(helper); ok {
117+
h.Helper()
118+
}
119+
112120
DoLog(t, callDepthWrapped, os.Stdout, fmt.Sprintf(format, args...))
113121
}
114122

@@ -132,13 +140,52 @@ var MutexStdout sync.Mutex
132140
// DoLog logs the given arguments to the given writer, along with a timestamp and information about what test and file is
133141
// doing the logging.
134142
func DoLog(t testing.TestingT, callDepth int, writer io.Writer, args ...any) {
143+
if h, ok := t.(helper); ok {
144+
h.Helper()
145+
}
146+
135147
date := time.Now()
136148
prefix := fmt.Sprintf("%s %s %s:", t.Name(), date.Format(time.RFC3339), CallerPrefix(callDepth+1))
137149
allArgs := append([]any{prefix}, args...)
138150

151+
// When we would otherwise write to stdout and a *testing.T is available, route the line through t.Log instead.
152+
// This lets `go test -json` attribute each line to the correct test, which it cannot do for raw stdout writes made
153+
// by tests running in parallel: such writes bypass the framework's per-test output coordination, so the JSON runner
154+
// tags them with whichever test happened to be active, mixing up the output of parallel tests (issue #1871). An
155+
// explicit non-stdout writer (e.g. a bytes.Buffer) is always honored as-is.
156+
if writer == os.Stdout {
157+
if sink, ok := t.(logSink); ok && logViaTestingT(sink, allArgs...) {
158+
return
159+
}
160+
}
161+
139162
fmt.Fprintln(writer, allArgs...)
140163
}
141164

165+
// logSink is satisfied by *testing.T (and *testing.B / *testing.F). See DoLog for why terratest routes stdout logging
166+
// through it: doing so is what allows `go test -json` to attribute output to the right test under t.Parallel().
167+
type logSink interface {
168+
Log(args ...any)
169+
Helper()
170+
}
171+
172+
// logViaTestingT routes the given args through the testing.T's Log method, which formats them like fmt.Sprintln (the
173+
// same as fmt.Fprintln) and streams them under the testing framework so they are attributed to the right test. It
174+
// recovers if the test has already completed, since t.Log panics in that case, and reports false so the caller can fall
175+
// back to writing directly to stdout rather than crash or drop the line.
176+
func logViaTestingT(sink logSink, args ...any) (logged bool) {
177+
defer func() {
178+
if recover() != nil {
179+
logged = false
180+
}
181+
}()
182+
183+
sink.Helper()
184+
sink.Log(args...)
185+
186+
return true
187+
}
188+
142189
// CallerPrefix returns the file and line number information about the methods that called this method, based on the current
143190
// goroutine's stack. The argument callDepth is the number of stack frames to ascend, with 0 identifying the method
144191
// that called CallerPrefix, 1 identifying the method that called that method, and so on.

modules/core/logger/logger_test.go

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,52 @@ func TestCustomLogger(t *testing.T) {
5959
assert.Equal(t, "subtest log", c.logs[2])
6060
}
6161

62-
// TestLockedLog makes sure that Log and Logf which use stdout are thread-safe.
62+
// fakeTestingT implements testing.TestingT but is deliberately NOT a *testing.T and has no Log method, so logging
63+
// through it exercises DoLog's stdout fallback path. A real *testing.T is instead routed through t.Log (see DoLog).
64+
type fakeTestingT struct{ name string }
65+
66+
func (fakeTestingT) Fail() {}
67+
func (fakeTestingT) FailNow() {}
68+
func (fakeTestingT) Fatal(...any) {}
69+
func (fakeTestingT) Fatalf(string, ...any) {}
70+
func (fakeTestingT) Error(...any) {}
71+
func (fakeTestingT) Errorf(string, ...any) {}
72+
func (f fakeTestingT) Name() string { return f.name }
73+
func (fakeTestingT) Helper() {}
74+
75+
// spyTestingT satisfies both testing.TestingT and the logSink that DoLog routes stdout logging through, recording each
76+
// Log call so tests can assert on what was routed.
77+
type spyTestingT struct {
78+
fakeTestingT
79+
logged []string
80+
}
81+
82+
func (s *spyTestingT) Log(args ...any) { s.logged = append(s.logged, fmt.Sprintln(args...)) }
83+
84+
// TestDoLogRoutesThroughTestingT verifies that DoLog routes to t.Log when writing to stdout for a *testing.T (so that
85+
// `go test -json` attributes output to the correct test), while always honoring an explicit non-stdout writer.
86+
//
87+
//nolint:paralleltest // asserts on os.Stdout routing
88+
func TestDoLogRoutesThroughTestingT(t *testing.T) {
89+
// writer == os.Stdout with a testing.T-like sink: routed through Log, nothing written to the real stdout.
90+
spy := &spyTestingT{fakeTestingT: fakeTestingT{name: "TestApply1"}}
91+
logger.DoLog(spy, 1, os.Stdout, "routed-message")
92+
require.Len(t, spy.logged, 1)
93+
assert.Contains(t, spy.logged[0], "TestApply1")
94+
assert.Contains(t, spy.logged[0], "routed-message")
95+
96+
// An explicit non-stdout writer is always honored and never routed to the sink.
97+
var buf bytes.Buffer
98+
99+
spy2 := &spyTestingT{fakeTestingT: fakeTestingT{name: "TestApply2"}}
100+
101+
logger.DoLog(spy2, 1, &buf, "buffered-message")
102+
assert.Empty(t, spy2.logged)
103+
assert.Contains(t, buf.String(), "buffered-message")
104+
}
105+
106+
// TestLockedLog makes sure that Log which uses the stdout fallback path is thread-safe. It uses fakeTestingT (not a
107+
// *testing.T) so DoLog writes to stdout under MutexStdout rather than routing through t.Log.
63108
//
64109
//nolint:paralleltest // test modifies os.Stdout
65110
func TestLockedLog(t *testing.T) {
@@ -69,19 +114,21 @@ func TestLockedLog(t *testing.T) {
69114
os.Stdout = stdout
70115
})
71116

117+
ft := fakeTestingT{name: t.Name()}
118+
72119
data := []struct {
73120
fn func(string)
74121
name string
75122
}{
76123
{
77124
fn: func(s string) {
78-
logger.Log(t, s)
125+
logger.Log(ft, s)
79126
},
80127
name: "Log",
81128
},
82129
{
83130
fn: func(s string) {
84-
logger.Default.Logf(t, "%s", s)
131+
logger.Default.Logf(ft, "%s", s)
85132
},
86133
name: "Logf",
87134
},

0 commit comments

Comments
 (0)