Skip to content

proposal: testing: report full call stack on test failure #78309

@pav-kv

Description

@pav-kv

Proposal Details

When a test fails inside a chain of t.Helper() functions, the testing package reports only the topmost non-helper frame. This discards useful intermediate context about which code path led to the failure.

For example, if the call stack is A -> B -> C { t.Helper() } -> D { t.Helper() t.Fail() }, we only see the B call in the output, but not A.

Common use-case is that the entire call stack uses t.Helper(). However, there are cases when it is desired do a more fine-grained hierarchy of Helpers (see example below).

Frameworks such as stretchr/testify sidestep this problem by printing the entire stack in the error message. However, there is also demand for a finer-grained stack printing behaviour that integrates well with the existing t.Helper() infrastructure and does not duplicate what testing package provides: stretchr/testify#1702.

Proposal: print the entire stack (excluding t.Helper frames), instead of the topmost frame.


Benefits:

  • Friendliness to third-party testing frameworks.
  • Better observability into test failures by default.
  • More user-friendly API: "forgetting" a t.Helper does not render in a test failure output that is hard to interpret. By default, a "forgotten" t.Helper doesn't prevent printing all interesting call sites. In fact, the mandatory t.Helper boilerplate becomes optional if the user does not want to skip any frames from the output.

Example:

Code
package main

import "testing"

type Obj struct {
	a int
	b int
}

func check(t *testing.T, x int) {
	t.Helper()
	if x < 0 {
		t.Fatal("negative value")
	}
}

func (o *Obj) setupA(t *testing.T) {
	t.Helper()
	check(t, o.a)
}

func (o *Obj) setupB(t *testing.T) {
	t.Helper()
	check(t, o.b)
}

func setup(t *testing.T, x int) Obj {
	o := Obj{a: x, b: x - 10}
	o.setupA(t)
	o.setupB(t) // t.Fail output points here
	return o
}

func TestHelperDemo(t *testing.T) {
	// We don't know which of these two calls fails.
	// Alternatively, if we put t.Helper() inside setup(), we
	// don't know which of setupA/setupB sub-steps fails.
	_ = setup(t, 100)
	_ = setup(t, 0)
}

go test output:

--- FAIL: TestHelperDemo (0.00s)
    main_test.go:30: negative value
FAIL
exit status 1
FAIL	example.com/testdemo	0.438s

Want something like:

--- FAIL: TestHelperDemo (0.00s)
    main_test.go:30: negative value
        main_test.go:36
FAIL
exit status 1
FAIL	example.com/testdemo	0.438s

This preserves the current behaviour (the first line is still the non-helper call site) while adding the intermediate context needed to diagnose the failure. Implementation-wise, this seems like a minor refactoring around the callSite / frameSkip funcs.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions