Skip to content

Commit b24530c

Browse files
authored
CEL test coverage calculation (#1209)
* added cel test coverage calculation and tests
1 parent 7f64ecc commit b24530c

File tree

7 files changed

+542
-9
lines changed

7 files changed

+542
-9
lines changed

policy/BUILD.bazel

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ cel_go_test(
9191
name = "context_pb_policy",
9292
cel_expr = "testdata/context_pb/policy.yaml",
9393
config = "testdata/context_pb/config.yaml",
94+
enable_coverage = True,
9495
file_descriptor_set = ":test_all_types_fds",
9596
test_src = "//policy/test:cel_test_runner.go",
9697
test_suite = "testdata/context_pb/tests.yaml",
@@ -101,6 +102,7 @@ cel_go_test(
101102
cel_expr = "testdata/k8s/policy.yaml",
102103
config = "testdata/k8s/config.yaml",
103104
deps = ["//policy:go_default_library"],
105+
enable_coverage = True,
104106
test_src = "//policy/test:k8s_cel_test_runner.go",
105107
test_suite = "testdata/k8s/tests.yaml",
106108
)
@@ -109,6 +111,7 @@ cel_go_test(
109111
name = "limits_policy",
110112
cel_expr = "testdata/limits/policy.yaml",
111113
config = "testdata/limits/config.yaml",
114+
enable_coverage = True,
112115
test_src = "//policy/test:cel_test_runner.go",
113116
test_suite = "testdata/limits/tests.yaml",
114117
)
@@ -117,6 +120,7 @@ cel_go_test(
117120
name = "nested_rule_policy",
118121
cel_expr = "testdata/nested_rule/policy.yaml",
119122
config = "testdata/nested_rule/config.yaml",
123+
enable_coverage = True,
120124
test_src = "//policy/test:cel_test_runner.go",
121125
test_suite = "testdata/nested_rule/tests.yaml",
122126
)
@@ -125,6 +129,7 @@ cel_go_test(
125129
name = "nested_rule2_policy",
126130
cel_expr = "testdata/nested_rule2/policy.yaml",
127131
config = "testdata/nested_rule2/config.yaml",
132+
enable_coverage = True,
128133
test_src = "//policy/test:cel_test_runner.go",
129134
test_suite = "testdata/nested_rule2/tests.yaml",
130135
)
@@ -133,6 +138,7 @@ cel_go_test(
133138
name = "nested_rule3_policy",
134139
cel_expr = "testdata/nested_rule3/policy.yaml",
135140
config = "testdata/nested_rule3/config.yaml",
141+
enable_coverage = True,
136142
test_src = "//policy/test:cel_test_runner.go",
137143
test_suite = "testdata/nested_rule3/tests.yaml",
138144
)
@@ -141,6 +147,7 @@ cel_go_test(
141147
name = "nested_rule4_policy",
142148
cel_expr = "testdata/nested_rule4/policy.yaml",
143149
config = "testdata/nested_rule4/config.yaml",
150+
enable_coverage = True,
144151
test_src = "//policy/test:cel_test_runner.go",
145152
test_suite = "testdata/nested_rule4/tests.yaml",
146153
)
@@ -149,6 +156,7 @@ cel_go_test(
149156
name = "nested_rule5_policy",
150157
cel_expr = "testdata/nested_rule5/policy.yaml",
151158
config = "testdata/nested_rule5/config.yaml",
159+
enable_coverage = True,
152160
test_src = "//policy/test:cel_test_runner.go",
153161
test_suite = "testdata/nested_rule5/tests.yaml",
154162
)
@@ -157,6 +165,7 @@ cel_go_test(
157165
name = "nested_rule6_policy",
158166
cel_expr = "testdata/nested_rule6/policy.yaml",
159167
config = "testdata/nested_rule6/config.yaml",
168+
enable_coverage = True,
160169
test_src = "//policy/test:cel_test_runner.go",
161170
test_suite = "testdata/nested_rule6/tests.yaml",
162171
)
@@ -165,6 +174,7 @@ cel_go_test(
165174
name = "nested_rule7_policy",
166175
cel_expr = "testdata/nested_rule7/policy.yaml",
167176
config = "testdata/nested_rule7/config.yaml",
177+
enable_coverage = True,
168178
test_src = "//policy/test:cel_test_runner.go",
169179
test_suite = "testdata/nested_rule7/tests.yaml",
170180
)
@@ -174,6 +184,7 @@ cel_go_test(
174184
cel_expr = "testdata/pb/policy.yaml",
175185
config = "testdata/pb/config.yaml",
176186
file_descriptor_set = ":test_all_types_fds",
187+
enable_coverage = True,
177188
test_src = "//policy/test:cel_test_runner.go",
178189
test_suite = "testdata/pb/tests.yaml",
179190
)
@@ -182,6 +193,7 @@ cel_go_test(
182193
name = "required_labels_policy",
183194
cel_expr = "testdata/required_labels/policy.yaml",
184195
config = "testdata/required_labels/config.yaml",
196+
enable_coverage = True,
185197
test_src = "//policy/test:cel_test_runner.go",
186198
test_suite = "testdata/required_labels/tests.yaml",
187199
)
@@ -190,6 +202,7 @@ cel_go_test(
190202
name = "restricted_destinations_policy",
191203
cel_expr = "testdata/restricted_destinations/policy.yaml",
192204
config = "testdata/restricted_destinations/config.yaml",
205+
enable_coverage = True,
193206
test_src = "//policy/test:cel_test_runner.go",
194207
test_suite = "testdata/restricted_destinations/tests.yaml",
195208
)
@@ -198,6 +211,7 @@ cel_go_test(
198211
name = "unnest_policy",
199212
cel_expr = "testdata/unnest/policy.yaml",
200213
config = "testdata/unnest/config.yaml",
214+
enable_coverage = True,
201215
test_src = "//policy/test:cel_test_runner.go",
202216
test_suite = "testdata/unnest/tests.yaml",
203217
)

test/cel_go_test.bzl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def cel_go_test(
3131
test_data_path = "",
3232
file_descriptor_set = "",
3333
filegroup = "",
34+
enable_coverage = False,
3435
deps = [],
3536
data = [],
3637
**kwargs):
@@ -59,6 +60,7 @@ def cel_go_test(
5960
this must be in binary format with either a .binarypb or .pb or.fds extension. If you need
6061
to support a textformat file_descriptor_set, embed it in the environment file. (default None)
6162
filegroup: str label of a filegroup containing the test suite, config, and cel_expr.
63+
enable_coverage: boolean indicating if coverage should be enabled for the test.
6264
deps: list of dependencies for the go_test rule
6365
data: list of data dependencies for the go_test rule
6466
**kwargs: additional arguments to pass to the go_test rule
@@ -104,6 +106,7 @@ def cel_go_test(
104106
"--test_suite_path=%s" % test_suite,
105107
"--config_path=%s" % config,
106108
"--base_config_path=%s" % base_config,
109+
"--enable_coverage=%s" % enable_coverage,
107110
]
108111

109112
if cel_expr_format == ".cel" or cel_expr_format == ".celpolicy" or cel_expr_format == ".yaml":

tools/celtest/BUILD.bazel

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,18 @@ package(
2323
go_library(
2424
name = "go_default_library",
2525
srcs = [
26+
"test_coverage_reporter.go",
2627
"test_runner.go",
2728
],
2829
importpath = "github.com/google/cel-go/tools/celtest",
2930
deps = [
3031
"//cel:go_default_library",
32+
"//common/ast:go_default_library",
33+
"//common/operators:go_default_library",
3134
"//common/types:go_default_library",
3235
"//common/types/ref:go_default_library",
3336
"//interpreter:go_default_library",
37+
"//parser:go_default_library",
3438
"//test:go_default_library",
3539
"//tools/compiler:go_default_library",
3640
"@com_github_google_go_cmp//cmp:go_default_library",
@@ -54,6 +58,7 @@ go_test(
5458
name = "go_default_test",
5559
size = "small",
5660
srcs = [
61+
"test_coverage_reporter_test.go",
5762
"test_runner_test.go",
5863
],
5964
data = [
@@ -63,6 +68,7 @@ go_test(
6368
embed = [":go_default_library"],
6469
deps = [
6570
"//cel:go_default_library",
71+
"//common/ast:go_default_library",
6672
"//common/decls:go_default_library",
6773
"//common/types:go_default_library",
6874
"//common/types/ref:go_default_library",
@@ -84,6 +90,7 @@ cel_go_test(
8490
name = "pb_policy",
8591
cel_expr = "testdata/pb/policy.yaml",
8692
config = "testdata/pb/config.yaml",
93+
enable_coverage = True,
8794
file_descriptor_set = "//policy:test_all_types_fds",
8895
test_data_path = "//policy",
8996
test_src = "//policy/test:cel_test_runner.go",
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package celtest provides functions for testing CEL policies and expressions.
16+
package celtest
17+
18+
import (
19+
"fmt"
20+
"strings"
21+
"testing"
22+
23+
"github.com/google/cel-go/common/ast"
24+
"github.com/google/cel-go/common/types"
25+
"github.com/google/cel-go/parser"
26+
)
27+
28+
// reportCoverage reports the coverage information for the provided programs.
29+
// - For the node coverage, the coverage is reported as a percentage of the number of nodes which
30+
// were evaluated during the test execution and hence are present in the program Coverage report.
31+
// - For the branch coverage, every node which has a boolean return type is considered as a branch.
32+
// The number of branches which were evaluated during the test execution and hence are present in
33+
// the program Coverage report are reported as the branch coverage percentage.
34+
func reportCoverage(t *testing.T, programs []Program) {
35+
t.Helper()
36+
for _, p := range programs {
37+
a := p.Ast.NativeRep()
38+
exprString, err := parser.Unparse(a.Expr(), a.SourceInfo(), parser.WrapOnColumn(70))
39+
if err != nil {
40+
t.Logf("Error converting AST to string for a program: %v", err)
41+
continue
42+
}
43+
rootNavigableExpr := ast.NavigateAST(p.Ast.NativeRep())
44+
// Initialize coverage metrics
45+
cr := &coverageReport{
46+
nodes: 0,
47+
coveredNodes: 0,
48+
branches: 0,
49+
coveredBooleanOutcomes: 0,
50+
unencounteredNodes: []string{},
51+
unencounteredBranches: []string{},
52+
}
53+
traverseAndCalculateCoverage(t, rootNavigableExpr, p, true, "", cr)
54+
printCoverageReport(t, exprString, cr)
55+
}
56+
}
57+
58+
type coverageReport struct {
59+
nodes int64
60+
coveredNodes int64
61+
branches int64
62+
coveredBooleanOutcomes int64
63+
unencounteredNodes []string
64+
unencounteredBranches []string
65+
}
66+
67+
func traverseAndCalculateCoverage(t *testing.T, expr ast.NavigableExpr, p Program, logUnencountered bool,
68+
preceedingTabs string, cr *coverageReport) {
69+
t.Helper()
70+
if expr == nil || len(p.CoverageStats) == 0 {
71+
return
72+
}
73+
nodeID := expr.ID()
74+
exprText, err := parser.Unparse(expr, p.Ast.NativeRep().SourceInfo(), parser.WrapOnColumn(70))
75+
if err != nil {
76+
t.Logf("Error converting AST to string for an expression: %v", err)
77+
return
78+
}
79+
cr.nodes++
80+
// Check for nodes which need to be logged for missing coverage:
81+
// * node should be of boolean type
82+
// * node should not a literal
83+
// * cel.@block type function nodes are bypassed as they are just the container for
84+
// the underlying expressions and the node itself does not offer any significant coverage information
85+
interestingBoolNode := expr.Type() == types.BoolType && expr.AsLiteral() == nil && expr.AsCall().FunctionName() != "cel.@block"
86+
// Check for node coverage
87+
if _, isCovered := p.CoverageStats[nodeID]; isCovered {
88+
cr.coveredNodes++
89+
} else if logUnencountered {
90+
if interestingBoolNode {
91+
cr.unencounteredNodes = append(cr.unencounteredNodes,
92+
fmt.Sprintf("\nExpression ID %d ('%s')", nodeID, exprText))
93+
}
94+
logUnencountered = false
95+
}
96+
// Check for Branch Coverage if the node is a boolean type
97+
if interestingBoolNode {
98+
cr.branches += 2
99+
if info, found := p.CoverageStats[nodeID]; !found {
100+
if logUnencountered {
101+
cr.unencounteredBranches = append(cr.unencounteredBranches,
102+
"\n"+preceedingTabs+fmt.Sprintf("Expression ID %d ('%s'): No coverage", nodeID, exprText))
103+
preceedingTabs = preceedingTabs + "\t\t"
104+
}
105+
} else {
106+
if _, ok := info[types.True]; ok {
107+
cr.coveredBooleanOutcomes++
108+
} else if logUnencountered {
109+
cr.unencounteredBranches = append(cr.unencounteredBranches,
110+
"\n"+preceedingTabs+fmt.Sprintf("Expression ID %d ('%s'): lacks 'true' coverage", nodeID, exprText))
111+
preceedingTabs = preceedingTabs + "\t\t"
112+
113+
}
114+
if _, ok := info[types.False]; ok {
115+
cr.coveredBooleanOutcomes++
116+
} else if logUnencountered {
117+
cr.unencounteredBranches = append(cr.unencounteredBranches,
118+
"\n"+preceedingTabs+fmt.Sprintf("Expression ID %d ('%s'): lacks 'false' coverage", nodeID, exprText))
119+
preceedingTabs = preceedingTabs + "\t\t"
120+
}
121+
}
122+
}
123+
for _, child := range expr.Children() {
124+
traverseAndCalculateCoverage(t, child.(ast.NavigableExpr), p, logUnencountered, preceedingTabs, cr)
125+
}
126+
}
127+
128+
func printCoverageReport(t *testing.T, exprString string, cr *coverageReport) {
129+
t.Helper()
130+
t.Logf("--- Start Coverage Report ---\nExpression: %s", exprString)
131+
if cr.nodes == 0 {
132+
t.Logf("No coverage stats found")
133+
return
134+
}
135+
// Log Node Coverage results
136+
nodeCoverage := float64(cr.coveredNodes) / float64(cr.nodes) * 100.0
137+
t.Logf("AST Node Coverage: %.2f%% (%d out of %d nodes covered)", nodeCoverage, cr.coveredNodes, cr.nodes)
138+
if len(cr.unencounteredNodes) > 0 {
139+
t.Logf("Interesting Unencountered Nodes:\n%s", strings.Join(cr.unencounteredNodes, "\n"))
140+
}
141+
// Log Branch Coverage results
142+
branchCoverage := 0.0
143+
if cr.branches > 0 {
144+
branchCoverage = float64(cr.coveredBooleanOutcomes) / float64(cr.branches) * 100.0
145+
}
146+
t.Logf("AST Branch Coverage: %.2f%% (%d out of %d branch outcomes covered)", branchCoverage,
147+
cr.coveredBooleanOutcomes, cr.branches)
148+
if len(cr.unencounteredBranches) > 0 {
149+
t.Logf("Interesting Unencountered Branch Paths:\n%s", strings.Join(cr.unencounteredBranches, "\n"))
150+
}
151+
t.Logf("--- End Coverage Report ---\n")
152+
}

0 commit comments

Comments
 (0)