diff --git a/go/analysis/passes/testinggoroutine/doc.go b/go/analysis/passes/testinggoroutine/doc.go index a68adb12b4c..4cd5b71e9ec 100644 --- a/go/analysis/passes/testinggoroutine/doc.go +++ b/go/analysis/passes/testinggoroutine/doc.go @@ -7,7 +7,7 @@ // // # Analyzer testinggoroutine // -// testinggoroutine: report calls to (*testing.T).Fatal from goroutines started by a test. +// testinggoroutine: report calls to (*testing.T).Fatal from goroutines started by a test // // Functions that abruptly terminate a test, such as the Fatal, Fatalf, FailNow, and // Skip{,f,Now} methods of *testing.T, must be called from the test goroutine itself. diff --git a/go/analysis/passes/testinggoroutine/testdata/src/a/a.go b/go/analysis/passes/testinggoroutine/testdata/src/a/a.go index c8fc91bb29b..4e46a46c55f 100644 --- a/go/analysis/passes/testinggoroutine/testdata/src/a/a.go +++ b/go/analysis/passes/testinggoroutine/testdata/src/a/a.go @@ -275,6 +275,212 @@ func TestWithCustomType(t *testing.T) { } } +func helpTB(tb testing.TB) { + tb.FailNow() +} + +func TestTB(t *testing.T) { + go helpTB(t) // want "call to .+TB.+FailNow from a non-test goroutine" +} + func TestIssue48124(t *testing.T) { - go h() + go helper(t) // want "call to .+T.+Skip from a non-test goroutine" +} + +func TestEachCall(t *testing.T) { + go helper(t) // want "call to .+T.+Skip from a non-test goroutine" + go helper(t) // want "call to .+T.+Skip from a non-test goroutine" +} + +func TestWithSubtest(t *testing.T) { + t.Run("name", func(t2 *testing.T) { + t.FailNow() // want "call to .+T.+FailNow on t defined outside of the subtest" + t2.Fatal() + }) + + f := func(t3 *testing.T) { + t.FailNow() + t3.Fatal() + } + t.Run("name", f) // want "call to .+T.+FailNow on t defined outside of the subtest" + + g := func(t4 *testing.T) { + t.FailNow() + t4.Fatal() + } + g(t) + + t.Run("name", helper) + + go t.Run("name", func(t2 *testing.T) { + t.FailNow() // want "call to .+T.+FailNow on t defined outside of the subtest" + t2.Fatal() + }) +} + +func TestMultipleVariables(t *testing.T) { + { // short decl + f, g := func(t1 *testing.T) { + t1.Fatal() + }, func(t2 *testing.T) { + t2.Error() + } + + go f(t) // want "call to .+T.+Fatal from a non-test goroutine" + go g(t) + + t.Run("name", f) + t.Run("name", g) + } + + { // var decl + var f, g = func(t1 *testing.T) { + t1.Fatal() + }, func(t2 *testing.T) { + t2.Error() + } + + go f(t) // want "call to .+T.+Fatal from a non-test goroutine" + go g(t) + + t.Run("name", f) + t.Run("name", g) + } +} + +func BadIgnoresMultipleAssignments(t *testing.T) { + { + f := func(t1 *testing.T) { + t1.Fatal() + } + go f(t) // want "call to .+T.+Fatal from a non-test goroutine" + + f = func(t2 *testing.T) { + t2.Error() + } + go f(t) // want "call to .+T.+Fatal from a non-test goroutine" + } + { + f := func(t1 *testing.T) { + t1.Error() + } + go f(t) + + f = func(t2 *testing.T) { + t2.FailNow() + } + go f(t) // false negative + } +} + +func TestGoDoesNotDescendIntoSubtest(t *testing.T) { + f := func(t2 *testing.T) { + g := func(t3 *testing.T) { + t3.Fatal() // fine + } + t2.Run("name", g) + t2.FailNow() // bad + } + go f(t) // want "call to .+T.+FailNow from a non-test goroutine" +} + +func TestFreeVariableAssignedWithinEnclosing(t *testing.T) { + f := func(t2 *testing.T) { + inner := t + inner.FailNow() + } + + go f(nil) // want "call to .+T.+FailNow from a non-test goroutine" + + t.Run("name", func(t3 *testing.T) { + go f(nil) // want "call to .+T.+FailNow from a non-test goroutine" + }) + + // Without pointer analysis we cannot tell if inner is t or t2. + // So we accept a false negatives on the following examples. + t.Run("name", f) + + go func(_ *testing.T) { + t.Run("name", f) + }(nil) + + go t.Run("name", f) +} + +func TestWithUnusedSelection(t *testing.T) { + go func() { + _ = t.FailNow + }() + t.Run("name", func(t2 *testing.T) { + _ = t.FailNow + }) +} + +func TestMethodExprsAreIgnored(t *testing.T) { + go func() { + (*testing.T).FailNow(t) + }() +} + +func TestRecursive(t *testing.T) { + t.SkipNow() + + go TestRecursive(t) // want "call to .+T.+SkipNow from a non-test goroutine" + + t.Run("name", TestRecursive) +} + +func TestMethodSelection(t *testing.T) { + var h helperType + + go h.help(t) // want "call to .+T.+SkipNow from a non-test goroutine" + t.Run("name", h.help) +} + +type helperType struct{} + +func (h *helperType) help(t *testing.T) { t.SkipNow() } + +func TestIssue63799a(t *testing.T) { + done := make(chan struct{}) + go func() { + defer close(done) + t.Run("", func(t *testing.T) { + t.Fatal() // No warning. This is in a subtest. + }) + }() + <-done +} + +func TestIssue63799b(t *testing.T) { + // Simplified from go.dev/cl/538698 + + // nondet is some unspecified boolean placeholder. + var nondet func() bool + + t.Run("nohup", func(t *testing.T) { + if nondet() { + t.Skip("ignored") + } + + go t.Run("nohup-i", func(t *testing.T) { + t.Parallel() + if nondet() { + if nondet() { + t.Skip("go.dev/cl/538698 wanted to have skip here") + } + + t.Error("ignored") + } else { + t.Log("ignored") + } + }) + }) +} + +func TestIssue63849(t *testing.T) { + go func() { + helper(t) // False negative. We do not do an actual interprodecural reachability analysis. + }() + go helper(t) // want "call to .+T.+Skip from a non-test goroutine" } diff --git a/go/analysis/passes/testinggoroutine/testdata/src/a/b.go b/go/analysis/passes/testinggoroutine/testdata/src/a/b.go index 5e95177f404..1169c3fa5de 100644 --- a/go/analysis/passes/testinggoroutine/testdata/src/a/b.go +++ b/go/analysis/passes/testinggoroutine/testdata/src/a/b.go @@ -4,4 +4,8 @@ package a -func h() {} +import "testing" + +func helper(t *testing.T) { + t.Skip() +} diff --git a/go/analysis/passes/testinggoroutine/testinggoroutine.go b/go/analysis/passes/testinggoroutine/testinggoroutine.go index 907b71503e0..dc5307a15d0 100644 --- a/go/analysis/passes/testinggoroutine/testinggoroutine.go +++ b/go/analysis/passes/testinggoroutine/testinggoroutine.go @@ -6,18 +6,28 @@ package testinggoroutine import ( _ "embed" + "fmt" "go/ast" + "go/token" + "go/types" "golang.org/x/tools/go/analysis" "golang.org/x/tools/go/analysis/passes/inspect" "golang.org/x/tools/go/analysis/passes/internal/analysisutil" + "golang.org/x/tools/go/ast/astutil" "golang.org/x/tools/go/ast/inspector" - "golang.org/x/tools/internal/typeparams" + "golang.org/x/tools/go/types/typeutil" ) //go:embed doc.go var doc string +var reportSubtest bool + +func init() { + Analyzer.Flags.BoolVar(&reportSubtest, "subtest", false, "whether to check if t.Run subtest is terminated correctly; experimental") +} + var Analyzer = &analysis.Analyzer{ Name: "testinggoroutine", Doc: analysisutil.MustExtractDoc(doc, "testinggoroutine"), @@ -26,15 +36,6 @@ var Analyzer = &analysis.Analyzer{ Run: run, } -var forbidden = map[string]bool{ - "FailNow": true, - "Fatal": true, - "Fatalf": true, - "Skip": true, - "Skipf": true, - "SkipNow": true, -} - func run(pass *analysis.Pass) (interface{}, error) { inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) @@ -42,38 +43,90 @@ func run(pass *analysis.Pass) (interface{}, error) { return nil, nil } - // Filter out anything that isn't a function declaration. - onlyFuncs := []ast.Node{ - (*ast.FuncDecl)(nil), + toDecl := localFunctionDecls(pass.TypesInfo, pass.Files) + + // asyncs maps nodes whose statements will be executed concurrently + // with respect to some test function, to the call sites where they + // are invoked asynchronously. There may be multiple such call sites + // for e.g. test helpers. + asyncs := make(map[ast.Node][]*asyncCall) + var regions []ast.Node + addCall := func(c *asyncCall) { + if c != nil { + r := c.region + if asyncs[r] == nil { + regions = append(regions, r) + } + asyncs[r] = append(asyncs[r], c) + } } - inspect.Nodes(onlyFuncs, func(node ast.Node, push bool) bool { - fnDecl, ok := node.(*ast.FuncDecl) - if !ok { + // Collect all of the go callee() and t.Run(name, callee) extents. + inspect.Nodes([]ast.Node{ + (*ast.FuncDecl)(nil), + (*ast.GoStmt)(nil), + (*ast.CallExpr)(nil), + }, func(node ast.Node, push bool) bool { + if !push { return false } + switch node := node.(type) { + case *ast.FuncDecl: + return hasBenchmarkOrTestParams(node) - if !hasBenchmarkOrTestParams(fnDecl) { - return false + case *ast.GoStmt: + c := goAsyncCall(pass.TypesInfo, node, toDecl) + addCall(c) + + case *ast.CallExpr: + c := tRunAsyncCall(pass.TypesInfo, node) + addCall(c) } + return true + }) - // Now traverse the benchmark/test's body and check that none of the - // forbidden methods are invoked in the goroutines within the body. - ast.Inspect(fnDecl, func(n ast.Node) bool { - goStmt, ok := n.(*ast.GoStmt) + // Check for t.Forbidden() calls within each region r that is a + // callee in some go r() or a t.Run("name", r). + // + // Also considers a special case when r is a go t.Forbidden() call. + for _, region := range regions { + ast.Inspect(region, func(n ast.Node) bool { + if n == region { + return true // always descend into the region itself. + } else if asyncs[n] != nil { + return false // will be visited by another region. + } + + call, ok := n.(*ast.CallExpr) if !ok { return true } + x, sel, fn := forbiddenMethod(pass.TypesInfo, call) + if x == nil { + return true + } - checkGoStmt(pass, goStmt) + for _, e := range asyncs[region] { + if !withinScope(e.scope, x) { + forbidden := formatMethod(sel, fn) // e.g. "(*testing.T).Forbidden - // No need to further traverse the GoStmt since right - // above we manually traversed it in the ast.Inspect(goStmt, ...) - return false + var context string + var where analysis.Range = e.async // Put the report at the go fun() or t.Run(name, fun). + if _, local := e.fun.(*ast.FuncLit); local { + where = call // Put the report at the t.Forbidden() call. + } else if id, ok := e.fun.(*ast.Ident); ok { + context = fmt.Sprintf(" (%s calls %s)", id.Name, forbidden) + } + if _, ok := e.async.(*ast.GoStmt); ok { + pass.ReportRangef(where, "call to %s from a non-test goroutine%s", forbidden, context) + } else if reportSubtest { + pass.ReportRangef(where, "call to %s on %s defined outside of the subtest%s", forbidden, x.Name(), context) + } + } + } + return true }) - - return false - }) + } return nil, nil } @@ -100,7 +153,6 @@ func typeIsTestingDotTOrB(expr ast.Expr) (string, bool) { if !ok { return "", false } - varPkg := selExpr.X.(*ast.Ident) if varPkg.Name != "testing" { return "", false @@ -111,73 +163,116 @@ func typeIsTestingDotTOrB(expr ast.Expr) (string, bool) { return varTypeName, ok } -// goStmtFunc returns the ast.Node of a call expression -// that was invoked as a go statement. Currently, only -// function literals declared in the same function, and -// static calls within the same package are supported. -func goStmtFun(goStmt *ast.GoStmt) ast.Node { - switch fun := goStmt.Call.Fun.(type) { - case *ast.IndexExpr, *typeparams.IndexListExpr: - x, _, _, _ := typeparams.UnpackIndexExpr(fun) - id, _ := x.(*ast.Ident) - if id == nil { - break - } - if id.Obj == nil { - break - } - if funDecl, ok := id.Obj.Decl.(ast.Node); ok { - return funDecl - } - case *ast.Ident: - // TODO(cuonglm): improve this once golang/go#48141 resolved. - if fun.Obj == nil { - break - } - if funDecl, ok := fun.Obj.Decl.(ast.Node); ok { - return funDecl - } - case *ast.FuncLit: - return goStmt.Call.Fun +// asyncCall describes a region of code that needs to be checked for +// t.Forbidden() calls as it is started asynchronously from an async +// node go fun() or t.Run(name, fun). +type asyncCall struct { + region ast.Node // region of code to check for t.Forbidden() calls. + async ast.Node // *ast.GoStmt or *ast.CallExpr (for t.Run) + scope ast.Node // Report t.Forbidden() if t is not declared within scope. + fun ast.Expr // fun in go fun() or t.Run(name, fun) +} + +// withinScope returns true if x.Pos() is in [scope.Pos(), scope.End()]. +func withinScope(scope ast.Node, x *types.Var) bool { + if scope != nil { + return x.Pos() != token.NoPos && scope.Pos() <= x.Pos() && x.Pos() <= scope.End() } - return goStmt.Call + return false } -// checkGoStmt traverses the goroutine and checks for the -// use of the forbidden *testing.(B, T) methods. -func checkGoStmt(pass *analysis.Pass, goStmt *ast.GoStmt) { - fn := goStmtFun(goStmt) - // Otherwise examine the goroutine to check for the forbidden methods. - ast.Inspect(fn, func(n ast.Node) bool { - selExpr, ok := n.(*ast.SelectorExpr) - if !ok { - return true - } +// goAsyncCall returns the extent of a call from a go fun() statement. +func goAsyncCall(info *types.Info, goStmt *ast.GoStmt, toDecl func(*types.Func) *ast.FuncDecl) *asyncCall { + call := goStmt.Call - _, bad := forbidden[selExpr.Sel.Name] - if !bad { - return true + fun := astutil.Unparen(call.Fun) + if id := funcIdent(fun); id != nil { + if lit := funcLitInScope(id); lit != nil { + return &asyncCall{region: lit, async: goStmt, scope: nil, fun: fun} } + } - // Now filter out false positives by the import-path/type. - ident, ok := selExpr.X.(*ast.Ident) - if !ok { - return true + if fn := typeutil.StaticCallee(info, call); fn != nil { // static call or method in the package? + if decl := toDecl(fn); decl != nil { + return &asyncCall{region: decl, async: goStmt, scope: nil, fun: fun} } - if ident.Obj == nil || ident.Obj.Decl == nil { - return true - } - field, ok := ident.Obj.Decl.(*ast.Field) - if !ok { - return true - } - if typeName, ok := typeIsTestingDotTOrB(field.Type); ok { - var fnRange analysis.Range = goStmt - if _, ok := fn.(*ast.FuncLit); ok { - fnRange = selExpr - } - pass.ReportRangef(fnRange, "call to (*%s).%s from a non-test goroutine", typeName, selExpr.Sel) + } + + // Check go statement for go t.Forbidden() or go func(){t.Forbidden()}(). + return &asyncCall{region: goStmt, async: goStmt, scope: nil, fun: fun} +} + +// tRunAsyncCall returns the extent of a call from a t.Run("name", fun) expression. +func tRunAsyncCall(info *types.Info, call *ast.CallExpr) *asyncCall { + if len(call.Args) != 2 { + return nil + } + run := typeutil.Callee(info, call) + if run, ok := run.(*types.Func); !ok || !isMethodNamed(run, "testing", "Run") { + return nil + } + + fun := astutil.Unparen(call.Args[1]) + if lit, ok := fun.(*ast.FuncLit); ok { // function lit? + return &asyncCall{region: lit, async: call, scope: lit, fun: fun} + } + + if id := funcIdent(fun); id != nil { + if lit := funcLitInScope(id); lit != nil { // function lit in variable? + return &asyncCall{region: lit, async: call, scope: lit, fun: fun} } - return true - }) + } + + // Check within t.Run(name, fun) for calls to t.Forbidden, + // e.g. t.Run(name, func(t *testing.T){ t.Forbidden() }) + return &asyncCall{region: call, async: call, scope: fun, fun: fun} +} + +var forbidden = []string{ + "FailNow", + "Fatal", + "Fatalf", + "Skip", + "Skipf", + "SkipNow", +} + +// forbiddenMethod decomposes a call x.m() into (x, x.m, m) where +// x is a variable, x.m is a selection, and m is the static callee m. +// Returns (nil, nil, nil) if call is not of this form. +func forbiddenMethod(info *types.Info, call *ast.CallExpr) (*types.Var, *types.Selection, *types.Func) { + // Compare to typeutil.StaticCallee. + fun := astutil.Unparen(call.Fun) + selExpr, ok := fun.(*ast.SelectorExpr) + if !ok { + return nil, nil, nil + } + sel := info.Selections[selExpr] + if sel == nil { + return nil, nil, nil + } + + var x *types.Var + if id, ok := astutil.Unparen(selExpr.X).(*ast.Ident); ok { + x, _ = info.Uses[id].(*types.Var) + } + if x == nil { + return nil, nil, nil + } + + fn, _ := sel.Obj().(*types.Func) + if fn == nil || !isMethodNamed(fn, "testing", forbidden...) { + return nil, nil, nil + } + return x, sel, fn +} + +func formatMethod(sel *types.Selection, fn *types.Func) string { + var ptr string + rtype := sel.Recv() + if p, ok := rtype.(*types.Pointer); ok { + ptr = "*" + rtype = p.Elem() + } + return fmt.Sprintf("(%s%s).%s", ptr, rtype.String(), fn.Name()) } diff --git a/go/analysis/passes/testinggoroutine/testinggoroutine_test.go b/go/analysis/passes/testinggoroutine/testinggoroutine_test.go index 56c4385c546..b74d67ed88a 100644 --- a/go/analysis/passes/testinggoroutine/testinggoroutine_test.go +++ b/go/analysis/passes/testinggoroutine/testinggoroutine_test.go @@ -9,14 +9,14 @@ import ( "golang.org/x/tools/go/analysis/analysistest" "golang.org/x/tools/go/analysis/passes/testinggoroutine" - "golang.org/x/tools/internal/typeparams" ) +func init() { + testinggoroutine.Analyzer.Flags.Set("subtest", "true") +} + func Test(t *testing.T) { testdata := analysistest.TestData() - pkgs := []string{"a"} - if typeparams.Enabled { - pkgs = append(pkgs, "typeparams") - } + pkgs := []string{"a", "typeparams"} analysistest.Run(t, testdata, testinggoroutine.Analyzer, pkgs...) } diff --git a/go/analysis/passes/testinggoroutine/util.go b/go/analysis/passes/testinggoroutine/util.go new file mode 100644 index 00000000000..805ccf49e4e --- /dev/null +++ b/go/analysis/passes/testinggoroutine/util.go @@ -0,0 +1,96 @@ +// Copyright 2023 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package testinggoroutine + +import ( + "go/ast" + "go/types" + + "golang.org/x/tools/go/ast/astutil" + "golang.org/x/tools/internal/typeparams" +) + +// AST and types utilities that not specific to testinggoroutines. + +// localFunctionDecls returns a mapping from *types.Func to *ast.FuncDecl in files. +func localFunctionDecls(info *types.Info, files []*ast.File) func(*types.Func) *ast.FuncDecl { + var fnDecls map[*types.Func]*ast.FuncDecl // computed lazily + return func(f *types.Func) *ast.FuncDecl { + if f != nil && fnDecls == nil { + fnDecls = make(map[*types.Func]*ast.FuncDecl) + for _, file := range files { + for _, decl := range file.Decls { + if fnDecl, ok := decl.(*ast.FuncDecl); ok { + if fn, ok := info.Defs[fnDecl.Name].(*types.Func); ok { + fnDecls[fn] = fnDecl + } + } + } + } + } + // TODO: once we only support go1.19+, set f = f.Origin() here. + return fnDecls[f] + } +} + +// isMethodNamed returns true if f is a method defined +// in package with the path pkgPath with a name in names. +func isMethodNamed(f *types.Func, pkgPath string, names ...string) bool { + if f == nil { + return false + } + if f.Pkg() == nil || f.Pkg().Path() != pkgPath { + return false + } + if f.Type().(*types.Signature).Recv() == nil { + return false + } + for _, n := range names { + if f.Name() == n { + return true + } + } + return false +} + +func funcIdent(fun ast.Expr) *ast.Ident { + switch fun := astutil.Unparen(fun).(type) { + case *ast.IndexExpr, *typeparams.IndexListExpr: + x, _, _, _ := typeparams.UnpackIndexExpr(fun) // necessary? + id, _ := x.(*ast.Ident) + return id + case *ast.Ident: + return fun + default: + return nil + } +} + +// funcLitInScope returns a FuncLit that id is at least initially assigned to. +// +// TODO: This is closely tied to id.Obj which is deprecated. +func funcLitInScope(id *ast.Ident) *ast.FuncLit { + // Compare to (*ast.Object).Pos(). + if id.Obj == nil { + return nil + } + var rhs ast.Expr + switch d := id.Obj.Decl.(type) { + case *ast.AssignStmt: + for i, x := range d.Lhs { + if ident, isIdent := x.(*ast.Ident); isIdent && ident.Name == id.Name && i < len(d.Rhs) { + rhs = d.Rhs[i] + } + } + case *ast.ValueSpec: + for i, n := range d.Names { + if n.Name == id.Name && i < len(d.Values) { + rhs = d.Values[i] + } + } + } + lit, _ := rhs.(*ast.FuncLit) + return lit +} diff --git a/gopls/doc/analyzers.md b/gopls/doc/analyzers.md index b3dd20c8bad..c0b7c434159 100644 --- a/gopls/doc/analyzers.md +++ b/gopls/doc/analyzers.md @@ -594,7 +594,7 @@ Also report certain struct tags (json, xml) used with unexported fields. ## **testinggoroutine** -report calls to (*testing.T).Fatal from goroutines started by a test. +report calls to (*testing.T).Fatal from goroutines started by a test Functions that abruptly terminate a test, such as the Fatal, Fatalf, FailNow, and Skip{,f,Now} methods of *testing.T, must be called from the test goroutine itself. diff --git a/gopls/internal/settings/api_json.go b/gopls/internal/settings/api_json.go index 1f3678a94ec..a9f3db2a982 100644 --- a/gopls/internal/settings/api_json.go +++ b/gopls/internal/settings/api_json.go @@ -397,7 +397,7 @@ var GeneratedAPIJSON = &APIJSON{ }, { Name: "\"testinggoroutine\"", - Doc: "report calls to (*testing.T).Fatal from goroutines started by a test.\n\nFunctions that abruptly terminate a test, such as the Fatal, Fatalf, FailNow, and\nSkip{,f,Now} methods of *testing.T, must be called from the test goroutine itself.\nThis checker detects calls to these functions that occur within a goroutine\nstarted by the test. For example:\n\n\tfunc TestFoo(t *testing.T) {\n\t go func() {\n\t t.Fatal(\"oops\") // error: (*T).Fatal called from non-test goroutine\n\t }()\n\t}", + Doc: "report calls to (*testing.T).Fatal from goroutines started by a test\n\nFunctions that abruptly terminate a test, such as the Fatal, Fatalf, FailNow, and\nSkip{,f,Now} methods of *testing.T, must be called from the test goroutine itself.\nThis checker detects calls to these functions that occur within a goroutine\nstarted by the test. For example:\n\n\tfunc TestFoo(t *testing.T) {\n\t go func() {\n\t t.Fatal(\"oops\") // error: (*T).Fatal called from non-test goroutine\n\t }()\n\t}", Default: "true", }, { @@ -1162,7 +1162,7 @@ var GeneratedAPIJSON = &APIJSON{ }, { Name: "testinggoroutine", - Doc: "report calls to (*testing.T).Fatal from goroutines started by a test.\n\nFunctions that abruptly terminate a test, such as the Fatal, Fatalf, FailNow, and\nSkip{,f,Now} methods of *testing.T, must be called from the test goroutine itself.\nThis checker detects calls to these functions that occur within a goroutine\nstarted by the test. For example:\n\n\tfunc TestFoo(t *testing.T) {\n\t go func() {\n\t t.Fatal(\"oops\") // error: (*T).Fatal called from non-test goroutine\n\t }()\n\t}", + Doc: "report calls to (*testing.T).Fatal from goroutines started by a test\n\nFunctions that abruptly terminate a test, such as the Fatal, Fatalf, FailNow, and\nSkip{,f,Now} methods of *testing.T, must be called from the test goroutine itself.\nThis checker detects calls to these functions that occur within a goroutine\nstarted by the test. For example:\n\n\tfunc TestFoo(t *testing.T) {\n\t go func() {\n\t t.Fatal(\"oops\") // error: (*T).Fatal called from non-test goroutine\n\t }()\n\t}", URL: "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/testinggoroutine", Default: true, },