This post talks about the internals of the testing package.
I am releasing a series of articles to dive deep into the internals of a few packages.
If you are interested, I’ll post their release on Twitter and LinkedIn .
Internal of the testing Log API
Every time in a test we use the Log
API from the T
type, part of the testing
package,
what happens behind the scene is more complex than you may think.
package testlog
func TestLog(t *testing.T) {
t.Log("Deep dive!")
}
Depth
Each test log has a specific depth.
The depth concept is introduced because logs are written as nested information.
OT: I wrote a go package long time ago to reproduce similar behaviours
As an example, this test:
package testlog
func TestNestedLog(t *testing.T) {
t.Run("test one", func(t *testing.T) {
t.Log("One")
t.Run("test nested one", func(t *testing.T) {
t.Log("Nested One")
})
})
t.Run("test two", func(t *testing.T) {
t.Log("Two")
})
t.Run("test three", func(t *testing.T) {
t.Log("Three")
})
}
Will output this log:
=== RUN TestNestedLog
=== RUN TestNestedLog/test_one
test_test.go:17: One
=== RUN TestNestedLog/test_one/test_nested_one
test_test.go:19: Nested One
=== RUN TestNestedLog/test_two
test_test.go:24: Two
=== RUN TestNestedLog/test_three
test_test.go:28: Three
--- PASS: TestNestedLog (0.00s)
--- PASS: TestNestedLog/test_one (0.00s)
--- PASS: TestNestedLog/test_one/test_nested_one (0.00s)
--- PASS: TestNestedLog/test_two (0.00s)
--- PASS: TestNestedLog/test_three (0.00s)
PASS
To achieve this behavior, the Log
API calls the logDepth
unexported method,
which implements most of the logging behaviors.
package testing
// ...
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L915
func (c *common) Log(args ...any) {
// ...
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L917
c.log(fmt.Sprintln(args...))
}
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L875
func (c *common) log(s string) {
c.logDepth(s, 3)
}
Verbose is chatty!
When using the Log
API, we know that the testing binary will print the log message only when at least one of the following criteria is met:
- The flag
test.v
is set as true - A test fails
ThelogDepth
method implements the logic itself.
package testing
// ...
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L880
func (c *common) logDepth(s string, depth int) {
// ...
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L896
if c.chatty != nil {
// ...
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L902
c.chatty.Printf(c.name, "%s", c.decorate(s, depth+1))
return
}
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L907
c.output = append(c.output, c.decorate(s, depth+1)...)
// ...
}
The chatty
field represents a printer, nil
by default,
but when passing the flag test.v
as true, it is initiated with newChattyPrinter(t.w)
to print the content of the log also on test success.
The chattyPrinter
has a simple implementation logic,
since it prints the log appending some extra information,
such as the test name or === CONT
if the previously written log came from another test case.
package testing
// ...
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L476
func (p *chattyPrinter) Printf(testName, format string, args ...any) {
p.lastNameMu.Lock()
defer p.lastNameMu.Unlock()
if p.lastName == "" {
p.lastName = testName
} else if p.lastName != testName {
fmt.Fprintf(p.w, "=== CONT %s\n", testName)
p.lastName = testName
}
fmt.Fprintf(p.w, format, args...)
}
Non-verbose prints only on failure
In the other scenario, if the flag test.v
is not set as true, the log is appended to the test output, as shown
package testing
// ...
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L880
func (c *common) logDepth(s string, depth int) {
// ...
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L907
c.output = append(c.output, c.decorate(s, depth+1)...)
}
The test output is printed from the t.report
function only if the test is marked as failed.
package testing
// ...
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L1758
func (t *T) report() {
// ...
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L1762
dstr := fmtDuration(t.duration)
format := "--- %s: %s (%s)\n"
if t.Failed() {
t.flushToParent(t.name, format, "FAIL", t.name, dstr)
}
// ...
}
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L689
func (c *common) flushToParent(testName, format string, args ...any) {
p := c.parent
p.mu.Lock()
defer p.mu.Unlock()
c.mu.Lock()
defer c.mu.Unlock(
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L717
fmt.Fprintf(p.w, format, args...)
// ...
}
Logging when testing.T is done
There are, of course, edge cases that the testing package needs to handle, letting the software engineers know that something is not going down as planned.
In fact, it may happen that your tests are finished,
but a non-terminated goroutine spawned in the tests tries to call the Log
API.
An example:
package testlog
func TestMain(m *testing.M) {
code := m.Run()
time.Sleep(time.Second)
os.Exit(code)
}
func TestPanicAfterCompletion(t *testing.T) {
t.Parallel()
t.Run("sub", func(t *testing.T) {
go func() {
time.Sleep(100 * time.Millisecond)
t.Log("No, Surprise!")
}()
t.Log("I think you are, yeah!")
})
t.Log("Am I really done?")
}
Running this test makes the testing binary produce a panic:
panic: Log in goroutine after TestPanicAfterCompletion/inner has completed: No, Surprise!
goroutine 6 [running]:
testing.(*common).logDepth(0x14000003a00, {0x14000020200, 0xe}, 0x3)
/opt/homebrew/Cellar/go/1.19.4/libexec/src/testing/testing.go:894 +0x3c0
testing.(*common).log(...)
/opt/homebrew/Cellar/go/1.19.4/libexec/src/testing/testing.go:876
testing.(*common).Log(0x14000003a00, {0x140000487b8?, 0x0?, 0x0?})
/opt/homebrew/Cellar/go/1.19.4/libexec/src/testing/testing.go:917 +0x4c
github.com/damianopetrungaro/testlog.TestPanicAfterCompletion.func1.1()
/Users/damianopetrungaro/go/src/github.com/damianopetrungaro/testlog/test_test.go:21 +0x60
created by github.com/damianopetrungaro/testlog.TestPanicAfterCompletion.func1
/Users/damianopetrungaro/go/src/github.com/damianopetrungaro/testlog/test_test.go:19 +0x60
To handle this edge case,
the standard library tries to append the content of the log
to the first non-done parent of the given *T
caller.
If all the parents are done, a panic is called.
This is the implementation:
package testing
// ...
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L880
func (c *common) logDepth(s string, depth int) {
c.mu.Lock()
defer c.mu.Unlock()
if c.done {
for parent := c.parent; parent != nil; parent = parent.parent {
parent.mu.Lock()
defer parent.mu.Unlock()
if !parent.done {
parent.output = append(parent.output, parent.decorate(s, depth+1)...)
return
}
}
panic("Log in goroutine after " + c.name + " has completed: " + s)
}
// ...
}
On the other hand when a parent test is available and non-done as shown in this snippet:
package testlog
func TestMain(m *testing.M) {
code := m.Run()
time.Sleep(time.Second)
os.Exit(code)
}
func TestPanicAfterCompletion(t *testing.T) {
t.Parallel()
t.Run("sub", func(t *testing.T) {
go func() {
time.Sleep(100 * time.Millisecond)
t.Log("No, Surprise!")
}()
t.Log("I think you are, yeah!")
})
time.Sleep(200 * time.Millisecond)
t.Log("Am I really done?")
}
The late log t.Log("No, Surprise!")
is printed as part of the parent TestPanicAfterCompletion
test and not as the TestPanicAfterCompletion/sub
one.
=== RUN TestPanicAfterCompletion
=== PAUSE TestPanicAfterCompletion
=== CONT TestPanicAfterCompletion
=== CONT TestPanicAfterCompletion
test_test.go:27: Am I really done?
--- PASS: TestPanicAfterCompletion (0.20s)
test_test.go:21: No, Surprise!
=== RUN TestPanicAfterCompletion/sub
test_test.go:23: I think you are, yeah!
--- PASS: TestPanicAfterCompletion/sub (0.00s)
PASS
Do you need “help”?
The last piece of information I feel worth sharing
is what happen when calling a t.Log()
from a function marked as helper viat.Helper()
.
For example let’s take this simple test:
package testlog
import (
"testing"
)
func TestHelper(t *testing.T) {
helper(t)
nonHelper(t)
}
func nonHelper(t *testing.T) {
t.Log("I come from nonHelper!")
}
func helper(t *testing.T) {
t.Helper()
t.Log("I come from helper!")
}
When running, it prints:
=== RUN TestHelper
test_test.go:8: I come from helper!
test_test.go:13: I come from nonHelper!
--- PASS: TestHelper (0.00s)
PASS
If you notice, the first log message comes from the “wrong line”.
This occurs thanks to the Helper
API, which records the caller’s stack frame.
package testing
// ...
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L999
func (c *common) Helper() {
c.mu.Lock()
defer c.mu.Unlock()
if c.helperPCs == nil {
c.helperPCs = make(map[uintptr]struct{})
}
//...
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L1011
if _, found := c.helperPCs[pc[0]]; !found {
c.helperPCs[pc[0]] = struct{}{}
c.helperNames = nil // map will be recreated next time it is needed
}
}
When printing logs (always passing by logDepth
)
the decorate
method will call frameSkip
that searches for the previously recorded frames
and skips them:
package testing
// ...
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L880
func (c *common) logDepth(s string, depth int) {
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L902
c.chatty.Printf(c.name, "%s", c.decorate(s, depth+1))
return
// ...
}
// ...
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L651
func (c *common) decorate(s string, skip int) string {
frame := c.frameSkip(skip)
file := frame.File
line := frame.Line
// ...
}
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L577
func (c *common) frameSkip(skip int) runtime.Frame {
// https://github.com/golang/go/blob/go1.19.5/src/testing/testing.go#L640
if _, ok := c.helperNames[frame.Function]; !ok {
// Found a frame that wasn't inside a helper function.
return frame
}
// ...
}
Conclusion
I hope the implementation details are now clear to you, and expect more content about internals related to the testing package in the following weeks.
Feel free to send me a DM on Twitter, LinkedIn or add a comment in the section below!