Skip to content

Commit e579000

Browse files
feat(ruletest): add Starlark-based test runner for rule types
Introduce pkg/ruletest, a Starlark-based test runner that lets rule authors write and execute tests for Minder rule types without needing a running server or live credentials. The runner: - Discovers *.star test files under a directory tree - Executes all test_* functions found in each file - Provides a fail() builtin that records failures without aborting (expect semantics), collecting all failures per test - Integrates with Go's testing.T for use in standard go test workflows This is Phase 1 of the rule testing framework described in the design doc (PR #6492). Future phases will add engine integration, mocking, and the mindev test CLI command.
1 parent d7c6a1a commit e579000

5 files changed

Lines changed: 304 additions & 0 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ require (
9191
go.opentelemetry.io/otel/sdk v1.44.0
9292
go.opentelemetry.io/otel/sdk/metric v1.44.0
9393
go.opentelemetry.io/otel/trace v1.44.0
94+
go.starlark.net v0.0.0-20260613233743-8ba36ccb83fb
9495
go.uber.org/mock v0.6.0
9596
golang.org/x/crypto v0.51.0
9697
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,6 +1310,8 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/
13101310
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
13111311
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
13121312
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
1313+
go.starlark.net v0.0.0-20260613233743-8ba36ccb83fb h1:NGUBN0jbH0IR3msRslALnoxlySm+6YvVKvVDjdDJrlA=
1314+
go.starlark.net v0.0.0-20260613233743-8ba36ccb83fb/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo=
13131315
go.step.sm/crypto v0.77.2 h1:qFjjei+RHc5kP5R7NW9OUWT7SqWIuAOvOkXqg4fNWj8=
13141316
go.step.sm/crypto v0.77.2/go.mod h1:W0YJb9onM5l78qgkXIJ2Up6grnwW8EtpCKIza/NCg0o=
13151317
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=

pkg/ruletest/runner.go

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
// SPDX-FileCopyrightText: Copyright 2026 The Minder Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Package ruletest provides a Starlark-based test runner for Minder rule types.
5+
package ruletest
6+
7+
import (
8+
"fmt"
9+
"io/fs"
10+
"path/filepath"
11+
"sort"
12+
"strings"
13+
"testing"
14+
15+
"go.starlark.net/starlark"
16+
"go.starlark.net/syntax"
17+
)
18+
19+
type testFailure struct {
20+
Message string
21+
}
22+
23+
const threadFailuresKey = "ruletest.failures"
24+
25+
// TestResult holds the outcome of a single Starlark test function.
26+
type TestResult struct {
27+
Name string
28+
Passed bool
29+
Failures []string
30+
}
31+
32+
// Runner loads and executes Starlark test files.
33+
type Runner struct {
34+
predeclared starlark.StringDict
35+
}
36+
37+
// NewRunner creates a new test runner with the default set of predeclared builtins.
38+
func NewRunner() *Runner {
39+
r := &Runner{}
40+
r.predeclared = starlark.StringDict{
41+
"fail": starlark.NewBuiltin("fail", r.builtinFail),
42+
}
43+
return r
44+
}
45+
46+
func (*Runner) builtinFail(
47+
thread *starlark.Thread,
48+
b *starlark.Builtin,
49+
args starlark.Tuple,
50+
kwargs []starlark.Tuple,
51+
) (starlark.Value, error) {
52+
var msg string
53+
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &msg); err != nil {
54+
return nil, err
55+
}
56+
57+
appendFailure(thread, msg)
58+
return starlark.None, nil
59+
}
60+
61+
// RunFile executes a single Starlark test file and returns the results
62+
// for each test_* function found in it.
63+
func (r *Runner) RunFile(filename string, src any) ([]TestResult, error) {
64+
thread := &starlark.Thread{
65+
Name: "ruletest",
66+
Print: func(_ *starlark.Thread, msg string) { fmt.Println(msg) },
67+
}
68+
69+
globals, err := starlark.ExecFileOptions(&syntax.FileOptions{}, thread, filename, src, r.predeclared)
70+
if err != nil {
71+
return nil, fmt.Errorf("loading %s: %w", filename, err)
72+
}
73+
74+
type namedFn struct {
75+
name string
76+
fn *starlark.Function
77+
}
78+
var testFns []namedFn
79+
for _, name := range globals.Keys() {
80+
if !strings.HasPrefix(name, "test_") {
81+
continue
82+
}
83+
fn, ok := globals[name].(*starlark.Function)
84+
if !ok {
85+
continue
86+
}
87+
if fn.NumParams() != 0 {
88+
continue // test functions must be no-arg
89+
}
90+
testFns = append(testFns, namedFn{name: name, fn: fn})
91+
}
92+
93+
sort.Slice(testFns, func(i, j int) bool {
94+
return testFns[i].name < testFns[j].name
95+
})
96+
97+
var results []TestResult
98+
for _, tf := range testFns {
99+
result := r.runOneTest(thread, tf.name, tf.fn)
100+
results = append(results, result)
101+
}
102+
103+
return results, nil
104+
}
105+
106+
func (*Runner) runOneTest(thread *starlark.Thread, name string, fn *starlark.Function) TestResult {
107+
resetFailures(thread)
108+
109+
result := TestResult{Name: name, Passed: true}
110+
111+
_, err := starlark.Call(thread, fn, nil, nil)
112+
if err != nil {
113+
result.Passed = false
114+
if evalErr, ok := err.(*starlark.EvalError); ok {
115+
result.Failures = append(result.Failures, evalErr.Backtrace())
116+
} else {
117+
result.Failures = append(result.Failures, err.Error())
118+
}
119+
}
120+
121+
failures := getFailures(thread)
122+
if len(failures) > 0 {
123+
result.Passed = false
124+
for _, f := range failures {
125+
result.Failures = append(result.Failures, f.Message)
126+
}
127+
}
128+
129+
return result
130+
}
131+
132+
// DiscoverFiles walks the given directory tree and returns the paths of
133+
// all *.star files found, in sorted order.
134+
func DiscoverFiles(root string) ([]string, error) {
135+
var files []string
136+
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
137+
if err != nil {
138+
return err
139+
}
140+
if !d.IsDir() && strings.HasSuffix(d.Name(), ".star") {
141+
files = append(files, path)
142+
}
143+
return nil
144+
})
145+
if err != nil {
146+
return nil, fmt.Errorf("walking %s: %w", root, err)
147+
}
148+
sort.Strings(files)
149+
return files, nil
150+
}
151+
152+
// RunDir discovers and executes all *.star test files under the given
153+
// directory, reporting results through t.
154+
func (r *Runner) RunDir(t *testing.T, dir string) {
155+
t.Helper()
156+
157+
files, err := DiscoverFiles(dir)
158+
if err != nil {
159+
t.Fatalf("discovering test files: %v", err)
160+
}
161+
162+
if len(files) == 0 {
163+
t.Logf("no *.star test files found in %s", dir)
164+
return
165+
}
166+
167+
for _, file := range files {
168+
rel, _ := filepath.Rel(dir, file)
169+
if rel == "" {
170+
rel = file
171+
}
172+
173+
t.Run(rel, func(t *testing.T) {
174+
results, err := r.RunFile(file, nil)
175+
if err != nil {
176+
t.Fatalf("running %s: %v", file, err)
177+
}
178+
179+
for _, result := range results {
180+
t.Run(result.Name, func(t *testing.T) {
181+
if !result.Passed {
182+
for _, msg := range result.Failures {
183+
t.Error(msg)
184+
}
185+
}
186+
})
187+
}
188+
})
189+
}
190+
}
191+
192+
func resetFailures(thread *starlark.Thread) {
193+
failures := make([]testFailure, 0)
194+
thread.SetLocal(threadFailuresKey, &failures)
195+
}
196+
197+
func appendFailure(thread *starlark.Thread, msg string) {
198+
ptr := thread.Local(threadFailuresKey)
199+
if ptr == nil {
200+
resetFailures(thread)
201+
ptr = thread.Local(threadFailuresKey)
202+
}
203+
failures := ptr.(*[]testFailure)
204+
*failures = append(*failures, testFailure{Message: msg})
205+
}
206+
207+
func getFailures(thread *starlark.Thread) []testFailure {
208+
ptr := thread.Local(threadFailuresKey)
209+
if ptr == nil {
210+
return nil
211+
}
212+
return *ptr.(*[]testFailure)
213+
}

pkg/ruletest/runner_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 The Minder Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package ruletest
5+
6+
import (
7+
"path/filepath"
8+
"testing"
9+
)
10+
11+
func TestDiscoverFiles(t *testing.T) {
12+
t.Parallel()
13+
files, err := DiscoverFiles("testdata")
14+
if err != nil {
15+
t.Fatalf("DiscoverFiles failed: %v", err)
16+
}
17+
18+
if len(files) != 1 {
19+
t.Fatalf("expected 1 file, got %d", len(files))
20+
}
21+
22+
expected := filepath.Join("testdata", "sample.star")
23+
if files[0] != expected {
24+
t.Errorf("expected %s, got %s", expected, files[0])
25+
}
26+
}
27+
28+
func TestRunFile(t *testing.T) {
29+
t.Parallel()
30+
r := NewRunner()
31+
results, err := r.RunFile(filepath.Join("testdata", "sample.star"), nil)
32+
if err != nil {
33+
t.Fatalf("RunFile failed: %v", err)
34+
}
35+
36+
if len(results) != 3 {
37+
t.Fatalf("expected 3 test functions to be discovered and run, got %d", len(results))
38+
}
39+
40+
// Because runner.go sorts the names, the order is:
41+
// test_exception, test_failing, test_passing
42+
43+
if results[0].Name != "test_exception" {
44+
t.Errorf("expected test_exception, got %s", results[0].Name)
45+
}
46+
if results[0].Passed {
47+
t.Error("expected test_exception to fail")
48+
}
49+
if len(results[0].Failures) == 0 {
50+
t.Error("expected failure message for test_exception")
51+
}
52+
53+
if results[1].Name != "test_failing" {
54+
t.Errorf("expected test_failing, got %s", results[1].Name)
55+
}
56+
if results[1].Passed {
57+
t.Error("expected test_failing to fail")
58+
}
59+
if len(results[1].Failures) == 0 || results[1].Failures[0] != "this test failed intentionally" {
60+
t.Errorf("unexpected failures for test_failing: %v", results[1].Failures)
61+
}
62+
63+
if results[2].Name != "test_passing" {
64+
t.Errorf("expected test_passing, got %s", results[2].Name)
65+
}
66+
if !results[2].Passed {
67+
t.Error("expected test_passing to pass")
68+
}
69+
}

pkg/ruletest/testdata/sample.star

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Simple test that passes
2+
def test_passing():
3+
pass
4+
5+
# Simple test that fails using the built-in fail()
6+
def test_failing():
7+
fail("this test failed intentionally")
8+
9+
# Test that throws a Starlark exception
10+
def test_exception():
11+
1 / 0
12+
13+
# A helper function, not a test (does not start with test_)
14+
def helper():
15+
pass
16+
17+
# A test with parameters should be skipped (tests must be no-arg)
18+
def test_with_params(a, b):
19+
pass

0 commit comments

Comments
 (0)