From b108d79b986a09426f77946d65973fced82cf23e Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:50:31 +0900 Subject: [PATCH] fix: Add tests for analyzing if statements without else clauses Introduce new test cases in analyzer_test.go to evaluate the behavior of the Analyze function when handling if statements that lack else clauses. The tests cover scenarios with terminating bodies, non-terminating bodies, and panic conditions. Additionally, update branch candidate collection logic in branches.go to correctly identify implicit false paths based on the structure of the if body. Enhancements include the addition of helper functions for checking if a body terminates and for determining if fallthrough conditions are met, improving the accuracy of branch coverage analysis. --- analyzer_test.go | 158 ++++++++++++++++++++++++++++++++++ internal/analyzer/branches.go | 53 +++++++++++- 2 files changed, 208 insertions(+), 3 deletions(-) diff --git a/analyzer_test.go b/analyzer_test.go index 04cdb8d..6b002cd 100644 --- a/analyzer_test.go +++ b/analyzer_test.go @@ -3,6 +3,7 @@ package gobce import ( "os" "path/filepath" + "strconv" "strings" "testing" ) @@ -135,3 +136,160 @@ func score(v int) int { t.Fatalf("expected uncovered branches from resolvable source file") } } + +func TestAnalyzeIfWithoutElseTerminatingBody(t *testing.T) { + tests := []struct { + name string + ifBodyCount int + fallthroughCount int + wantUncoveredTruePath bool + wantUncoveredImplicitFalse bool + }{ + { + name: "only_true_path_covered", + ifBodyCount: 1, + fallthroughCount: 0, + wantUncoveredImplicitFalse: true, + }, + { + name: "only_false_path_covered", + ifBodyCount: 0, + fallthroughCount: 1, + wantUncoveredTruePath: true, + }, + { + name: "both_paths_covered", + ifBodyCount: 1, + fallthroughCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmp := t.TempDir() + srcPath := filepath.Join(tmp, "sample.go") + src := `package sample + +func score(v int) int { + if v > 10 { + return 1 + } + return 2 +} +` + if err := os.WriteFile(srcPath, []byte(src), 0o644); err != nil { + t.Fatalf("write source: %v", err) + } + + coverPath := filepath.Join(tmp, "coverage.out") + coverage := strings.Join([]string{ + "mode: set", + srcPath + ":3.23,4.13 1 1", + srcPath + ":4.13,6.3 1 " + strconv.Itoa(tt.ifBodyCount), + srcPath + ":6.3,7.10 1 " + strconv.Itoa(tt.fallthroughCount), + }, "\n") + if err := os.WriteFile(coverPath, []byte(coverage), 0o644); err != nil { + t.Fatalf("write coverage: %v", err) + } + + result, err := Analyze(AnalyzeInput{CoverProfilePath: coverPath}) + if err != nil { + t.Fatalf("analyze: %v", err) + } + + gotUncoveredTruePath := hasUncoveredBranchKind(result, "if_true_path") + if gotUncoveredTruePath != tt.wantUncoveredTruePath { + t.Fatalf("if_true_path uncovered: got %v, want %v", gotUncoveredTruePath, tt.wantUncoveredTruePath) + } + + gotUncoveredImplicitFalse := hasUncoveredBranchKind(result, "if_implicit_false_path") + if gotUncoveredImplicitFalse != tt.wantUncoveredImplicitFalse { + t.Fatalf("if_implicit_false_path uncovered: got %v, want %v", gotUncoveredImplicitFalse, tt.wantUncoveredImplicitFalse) + } + }) + } +} + +func TestAnalyzeIfWithoutElseNonTerminatingBody(t *testing.T) { + tmp := t.TempDir() + srcPath := filepath.Join(tmp, "sample.go") + src := `package sample + +func score(v int) int { + score := 0 + if v > 10 { + score = 1 + } + return score +} +` + if err := os.WriteFile(srcPath, []byte(src), 0o644); err != nil { + t.Fatalf("write source: %v", err) + } + + coverPath := filepath.Join(tmp, "coverage.out") + coverage := strings.Join([]string{ + "mode: set", + srcPath + ":3.23,5.13 2 1", + srcPath + ":5.13,7.3 1 0", + srcPath + ":7.3,8.14 1 1", + }, "\n") + if err := os.WriteFile(coverPath, []byte(coverage), 0o644); err != nil { + t.Fatalf("write coverage: %v", err) + } + + result, err := Analyze(AnalyzeInput{CoverProfilePath: coverPath}) + if err != nil { + t.Fatalf("analyze: %v", err) + } + + if hasUncoveredBranchKind(result, "if_implicit_false_path") { + t.Fatalf("did not expect if_implicit_false_path for non-terminating if body") + } +} + +func TestAnalyzeIfWithoutElsePanicBody(t *testing.T) { + tmp := t.TempDir() + srcPath := filepath.Join(tmp, "sample.go") + src := `package sample + +func score(v int) int { + if v > 10 { + panic("too high") + } + return 2 +} +` + if err := os.WriteFile(srcPath, []byte(src), 0o644); err != nil { + t.Fatalf("write source: %v", err) + } + + coverPath := filepath.Join(tmp, "coverage.out") + coverage := strings.Join([]string{ + "mode: set", + srcPath + ":3.23,4.13 1 1", + srcPath + ":4.13,6.3 1 1", + srcPath + ":6.3,7.10 1 0", + }, "\n") + if err := os.WriteFile(coverPath, []byte(coverage), 0o644); err != nil { + t.Fatalf("write coverage: %v", err) + } + + result, err := Analyze(AnalyzeInput{CoverProfilePath: coverPath}) + if err != nil { + t.Fatalf("analyze: %v", err) + } + + if !hasUncoveredBranchKind(result, "if_implicit_false_path") { + t.Fatalf("expected if_implicit_false_path in uncovered branches") + } +} + +func hasUncoveredBranchKind(result Result, kind string) bool { + for _, b := range result.UncoveredBranches { + if b.Kind == kind { + return true + } + } + return false +} diff --git a/internal/analyzer/branches.go b/internal/analyzer/branches.go index 2b75e10..601fa9d 100644 --- a/internal/analyzer/branches.go +++ b/internal/analyzer/branches.go @@ -44,7 +44,7 @@ func collectFileBranchCandidates(filePath string, blocks []coverageBlock) ([]bra FilePath: filePath, Line: fset.Position(n.Body.Lbrace).Line, Kind: "if_true_path", - Covered: spanCovered(blocks, fset, n.Body.Pos(), n.Body.End()), + Covered: blockStmtCovered(blocks, fset, n.Body), }) if n.Else != nil { @@ -54,6 +54,13 @@ func collectFileBranchCandidates(filePath string, blocks []coverageBlock) ([]bra Kind: "if_false_path", Covered: spanCovered(blocks, fset, n.Else.Pos(), n.Else.End()), }) + } else if ifBodyTerminates(n.Body) { + candidates = append(candidates, branchCandidate{ + FilePath: filePath, + Line: fset.Position(n.If).Line, + Kind: "if_implicit_false_path", + Covered: fallthroughCovered(blocks, fset, n.Body), + }) } case *ast.SwitchStmt: @@ -61,7 +68,7 @@ func collectFileBranchCandidates(filePath string, blocks []coverageBlock) ([]bra case *ast.TypeSwitchStmt: candidates = append(candidates, collectCaseClauseCandidates(filePath, fset, blocks, n.Body.List, "type_switch_case_path", "type_switch_default_path")...) case *ast.ForStmt: - bodyCovered := spanCovered(blocks, fset, n.Body.Pos(), n.Body.End()) + bodyCovered := blockStmtCovered(blocks, fset, n.Body) stmtCovered := spanCovered(blocks, fset, n.Pos(), n.Body.Lbrace) candidates = append(candidates, branchCandidate{ FilePath: filePath, @@ -76,7 +83,7 @@ func collectFileBranchCandidates(filePath string, blocks []coverageBlock) ([]bra Covered: stmtCovered && !bodyCovered, }) case *ast.RangeStmt: - bodyCovered := spanCovered(blocks, fset, n.Body.Pos(), n.Body.End()) + bodyCovered := blockStmtCovered(blocks, fset, n.Body) stmtCovered := spanCovered(blocks, fset, n.Pos(), n.Body.Lbrace) candidates = append(candidates, branchCandidate{ FilePath: filePath, @@ -118,6 +125,46 @@ func collectCaseClauseCandidates(filePath string, fset *token.FileSet, blocks [] return candidates } +func ifBodyTerminates(body *ast.BlockStmt) bool { + if len(body.List) == 0 { + return false + } + + last := body.List[len(body.List)-1] + switch s := last.(type) { + case *ast.ReturnStmt: + return true + case *ast.BranchStmt: + return true + case *ast.ExprStmt: + call, ok := s.X.(*ast.CallExpr) + if !ok { + return false + } + ident, ok := call.Fun.(*ast.Ident) + return ok && ident.Name == "panic" + default: + return false + } +} + +func fallthroughCovered(blocks []coverageBlock, fset *token.FileSet, body *ast.BlockStmt) bool { + endPos := fset.Position(body.End()) + for _, b := range blocks { + if b.Count <= 0 { + continue + } + if b.StartLine == endPos.Line && b.StartCol == endPos.Column { + return true + } + } + return false +} + +func blockStmtCovered(blocks []coverageBlock, fset *token.FileSet, body *ast.BlockStmt) bool { + return spanCovered(blocks, fset, body.Lbrace+1, body.End()) +} + func spanCovered(blocks []coverageBlock, fset *token.FileSet, start token.Pos, end token.Pos) bool { startPos := fset.Position(start) endPos := fset.Position(end)