diff --git a/assertion.go b/assertion.go index a66a007bf..4ce0b3fcd 100644 --- a/assertion.go +++ b/assertion.go @@ -192,6 +192,9 @@ type AssertionFailure struct { // Allowed delta between actual and expected Delta *AssertionValue + + // Stacktrace of a fail + Stacktrace []CallerInfo } // AssertionValue holds expected or actual value diff --git a/chain.go b/chain.go index 68e6f3282..4d4bf3e41 100644 --- a/chain.go +++ b/chain.go @@ -2,6 +2,7 @@ package httpexpect import ( "fmt" + "runtime" "sync" "testing" @@ -435,6 +436,9 @@ func (c *chain) fail(failure AssertionFailure) { if c.severity == SeverityError { failure.IsFatal = true } + + failure.Stacktrace = GetCallerInfo() + c.failure = &failure } @@ -502,3 +506,41 @@ func isTestingTB(in AssertionHandler) bool { } return false } + +type CallerInfo struct { + FuncName string + File string + Line int +} + +func GetCallerInfo() []CallerInfo { + callers := []CallerInfo{} + for i := 0; ; i++ { + pc, file, line, ok := runtime.Caller(i) + if !ok { + break + } + + if file == "" { + break + } + + f := runtime.FuncForPC(pc) + if f == nil { + break + } + funcName := f.Name() + + if funcName == "testing.tRunner" { + break + } + + callers = append(callers, CallerInfo{ + FuncName: funcName, + File: file, + Line: line, + }) + } + + return callers +} diff --git a/chain_test.go b/chain_test.go index c5e668981..7363cc9ea 100644 --- a/chain_test.go +++ b/chain_test.go @@ -674,6 +674,22 @@ func TestChain_Severity(t *testing.T) { }) } +func TestChain_Stacktrace(t *testing.T) { + handler := &mockAssertionHandler{} + + chain := newChainWithConfig("test", Config{ + AssertionHandler: handler, + }.withDefaults()) + + opChain := chain.enter("test") + opChain.fail(testFailure()) + opChain.leave() + + assert.True(t, opChain.failed()) + assert.NotNil(t, handler.failure) + assert.NotEmpty(t, handler.failure.Stacktrace) +} + func TestChain_Reporting(t *testing.T) { handler := &mockAssertionHandler{} @@ -708,6 +724,8 @@ func TestChain_Reporting(t *testing.T) { assert.True(t, chain.failed()) // reported to parent assert.NotNil(t, handler.ctx) // reported to handler assert.NotNil(t, handler.failure) + + failure.Stacktrace = handler.failure.Stacktrace assert.Equal(t, failure, *handler.failure) } diff --git a/formatter.go b/formatter.go index 9361a6dd6..36bef49ed 100644 --- a/formatter.go +++ b/formatter.go @@ -58,6 +58,9 @@ type DefaultFormatter struct { // Exclude HTTP response from failure report. DisableResponses bool + // Enables printing of stacktrace on failure + StacktraceMode StacktraceMode + // Thousand separator. // Default is DigitSeparatorUnderscore. DigitSeparator DigitSeparator @@ -112,6 +115,16 @@ func (f *DefaultFormatter) FormatFailure( } } +type StacktraceMode int + +const ( + // Unconditionally disable stacktrace. + StacktraceModeDisabled StacktraceMode = iota + + // Format caller info as `at [function name]([file]:[line])` + StacktraceModeDefault +) + // DigitSeparator defines the separator used to format integers and floats. type DigitSeparator int @@ -209,6 +222,9 @@ type FormatData struct { HaveResponse bool Response string + HaveStacktrace bool + Stacktrace []string + EnableColors bool LineWidth int } @@ -284,6 +300,7 @@ func (f *DefaultFormatter) buildFormatData( f.fillRequest(&data, ctx, failure) f.fillResponse(&data, ctx, failure) + f.fillStacktrace(&data, ctx, failure) } return &data @@ -546,6 +563,23 @@ func (f *DefaultFormatter) fillResponse( } } +func (f *DefaultFormatter) fillStacktrace( + data *FormatData, ctx *AssertionContext, failure *AssertionFailure, +) { + data.Stacktrace = []string{} + + if f.StacktraceMode == StacktraceModeDisabled { + return + } + if f.StacktraceMode == StacktraceModeDefault { + for _, call := range failure.Stacktrace { + formatted := fmt.Sprintf("at %s(%s:%d)", call.FuncName, call.File, call.Line) + data.Stacktrace = append(data.Stacktrace, formatted) + } + data.HaveStacktrace = len(failure.Stacktrace) != 0 + } +} + func (f *DefaultFormatter) formatValue(value interface{}) string { if flt := extractFloat32(value); flt != nil { return f.reformatNumber(f.formatFloatValue(*flt, 32)) @@ -1016,6 +1050,13 @@ request: {{ .Request | indent | trim | color $.EnableColors "HiMagenta" }} response: {{ .Response | indent | trim | color $.EnableColors "HiMagenta" }} {{- end -}} +{{- if .HaveStacktrace }} + +trace: +{{- range $n, $call := .Stacktrace }} +{{ $call | indent }} +{{- end -}} +{{- end -}} {{- if .AssertPath }} assertion: diff --git a/formatter_test.go b/formatter_test.go index 02f36d148..04a14a769 100644 --- a/formatter_test.go +++ b/formatter_test.go @@ -1056,3 +1056,48 @@ func TestFormatter_ColorMode(t *testing.T) { }) } } + +func TestFormatter_Stacktrace(t *testing.T) { + df := &DefaultFormatter{ + StacktraceMode: StacktraceModeDefault, + } + ctx := &AssertionContext{} + + cases := []struct { + callerInfo CallerInfo + want string + }{ + { + CallerInfo{ + FuncName: "Foo()", + File: "formatter_test.go", + Line: 228, + }, + "at Foo()(formatter_test.go:228)", + }, + { + CallerInfo{ + FuncName: "Bar()", + File: "formatter.go", + Line: 123, + }, + "at Bar()(formatter.go:123)", + }, + { + CallerInfo{ + FuncName: "Buzz()", + File: "file.go", + Line: 5, + }, + "at Buzz()(file.go:5)", + }, + } + + for _, tc := range cases { + fl := &AssertionFailure{ + Stacktrace: []CallerInfo{tc.callerInfo}, + } + fd := df.buildFormatData(ctx, fl) + assert.Equal(t, []string{tc.want}, fd.Stacktrace) + } +}