Files
releases/cmd/test.go
T
Sebastian Spaink a87219e6cd Enable sorting JSON test results by duration (#8260)
Fixes #7444

Signed-off-by: Sebastian Spaink <sebastianspaink@gmail.com>
2026-01-29 11:41:09 +01:00

604 lines
18 KiB
Go

// Copyright 2017 The OPA Authors. All rights reserved.
// Use of this source code is governed by an Apache2
// license that can be found in the LICENSE file.
package cmd
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
goRuntime "runtime"
"strings"
"syscall"
"testing"
"time"
"github.com/fsnotify/fsnotify"
"github.com/open-policy-agent/opa/internal/pathwatcher"
initload "github.com/open-policy-agent/opa/internal/runtime/init"
"github.com/spf13/cobra"
"github.com/open-policy-agent/opa/cmd/formats"
"github.com/open-policy-agent/opa/cmd/internal/env"
"github.com/open-policy-agent/opa/internal/runtime"
"github.com/open-policy-agent/opa/v1/ast"
"github.com/open-policy-agent/opa/v1/bundle"
"github.com/open-policy-agent/opa/v1/compile"
"github.com/open-policy-agent/opa/v1/cover"
"github.com/open-policy-agent/opa/v1/loader"
"github.com/open-policy-agent/opa/v1/storage"
"github.com/open-policy-agent/opa/v1/storage/inmem"
"github.com/open-policy-agent/opa/v1/tester"
"github.com/open-policy-agent/opa/v1/topdown"
"github.com/open-policy-agent/opa/v1/topdown/lineage"
"github.com/open-policy-agent/opa/v1/util"
)
type testCommandParams struct {
verbose bool
explain *util.EnumFlag
errLimit int
outputFormat *util.EnumFlag
coverage bool
threshold float64
timeout time.Duration
ignore []string
bundleMode bool
benchmark bool
benchMem bool
runRegex string
sortTests *util.EnumFlag
count int
target *util.EnumFlag
skipExitZero bool
capabilities *capabilitiesFlag
schema *schemaFlags
watch bool
stopChan chan os.Signal
output io.Writer
errOutput io.Writer
v0Compatible bool
v1Compatible bool
varValues bool
parallel int
failOnEmpty bool
}
func newTestCommandParams() testCommandParams {
return testCommandParams{
sortTests: formats.Flag(formats.SortNone, formats.SortDuration),
outputFormat: formats.Flag(formats.Pretty, formats.JSON, formats.GoBench),
explain: newExplainFlag([]string{explainModeFails, explainModeFull, explainModeNotes, explainModeDebug}),
target: util.NewEnumFlag(compile.TargetRego, []string{compile.TargetRego, compile.TargetWasm}),
capabilities: newCapabilitiesFlag(),
schema: &schemaFlags{},
output: os.Stdout,
errOutput: os.Stderr,
stopChan: make(chan os.Signal, 1),
parallel: goRuntime.NumCPU(),
}
}
func (p *testCommandParams) RegoVersion() ast.RegoVersion {
// v0 takes precedence over v1
if p.v0Compatible {
return ast.RegoV0
}
if p.v1Compatible {
return ast.RegoV1
}
return ast.DefaultRegoVersion
}
func opaTest(args []string, testParams testCommandParams) int {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if testParams.outputFormat.String() == formats.GoBench && !testParams.benchmark {
errMsg := "cannot use output format %s without running benchmarks (--bench)\n"
_, _ = fmt.Fprintf(testParams.errOutput, errMsg, formats.GoBench)
return 0
}
if !isThresholdValid(testParams.threshold) {
_, _ = fmt.Fprintln(testParams.errOutput, "Code coverage threshold must be between 0 and 100")
return 1
}
var modules map[string]*ast.Module
var bundles map[string]*bundle.Bundle
var store storage.Store
popts := ast.ParserOptions{
RegoVersion: testParams.RegoVersion(),
Capabilities: testParams.capabilities.C,
ProcessAnnotation: true,
}
var err error
if testParams.bundleMode {
bundles, store, err = tester.LoadBundlesWithParserOptions(args, ignored(testParams.ignore).Apply, popts)
} else {
modules, store, err = tester.LoadWithParserOptions(args, ignored(testParams.ignore).Apply, popts)
}
if err != nil {
_, _ = fmt.Fprintln(testParams.errOutput, err)
return 1
}
txn, err := store.NewTransaction(ctx, storage.WriteParams)
if err != nil {
_, _ = fmt.Fprintln(testParams.errOutput, err)
return 1
}
runner, reporter, err := compileAndSetupTests(ctx, testParams, store, txn, modules, bundles)
if err != nil {
store.Abort(ctx, txn)
_, _ = fmt.Fprintln(testParams.errOutput, err)
return 1
}
success := true
for range testParams.count {
exitCode, _ := runTests(ctx, txn, runner, reporter, testParams)
if exitCode != 0 {
success = false
store.Abort(ctx, txn)
if testParams.watch {
break
}
return exitCode
}
}
if success {
store.Abort(ctx, txn)
}
if !testParams.watch {
return 0
}
done := make(chan struct{})
go func() {
var store storage.Store
if bundle.BundleExtStore != nil {
store = bundle.BundleExtStore()
} else {
store = inmem.NewWithOpts(inmem.OptRoundTripOnWrite(false))
}
startWatcher(ctx, testParams, args, store, done)
}()
signal.Notify(testParams.stopChan, syscall.SIGINT, syscall.SIGTERM)
<-testParams.stopChan
done <- struct{}{}
return 0
}
func runTests(ctx context.Context, txn storage.Transaction, runner *tester.Runner, reporter tester.Reporter, testParams testCommandParams) (int, error) {
var err error
var ch chan *tester.Result
if testParams.benchmark {
// Initialize testing package for benchmarking. This is needed to set default values for some flags that may
// otherwise be dereferenced on some code paths causing panics, as reported in:
// https://github.com/open-policy-agent/opa/issues/7205
testing.Init()
benchOpts := tester.BenchmarkOptions{
ReportAllocations: testParams.benchMem,
}
ch, err = runner.RunBenchmarks(ctx, txn, benchOpts)
} else {
ch, err = runner.RunTests(ctx, txn)
}
if err != nil {
_, _ = fmt.Fprintln(testParams.errOutput, err)
return 1, err
}
testRan := false
exitCode := 0
dup := make(chan *tester.Result)
go func() {
defer close(dup)
for tr := range ch {
testRan = true
if !tr.Pass() {
if !(tr.Skip && testParams.skipExitZero) {
exitCode = 2
}
}
tr.Trace = filterTrace(&testParams, tr.Trace)
dup <- tr
}
}()
if err := reporter.Report(dup); err != nil {
_, _ = fmt.Fprintln(testParams.errOutput, err)
if !testParams.benchmark {
var coverageThresholdError *cover.CoverageThresholdError
if errors.As(err, &coverageThresholdError) {
return 2, err
}
}
return 1, err
}
if !testRan && testParams.failOnEmpty {
_, _ = fmt.Fprintln(testParams.errOutput, "no tests were run")
return 1, err
}
return exitCode, err
}
func filterTrace(params *testCommandParams, trace []*topdown.Event) []*topdown.Event {
// If an explain mode was specified, filter based
// on the mode. If no explain mode was specified,
// default to show both notes and fail events
showDefault := !params.explain.IsSet() && params.verbose
if showDefault {
return lineage.Filter(trace, func(event *topdown.Event) bool {
return event.Op == topdown.NoteOp || event.Op == topdown.FailOp
})
}
mode := params.explain.String()
switch mode {
case explainModeNotes:
return lineage.Notes(trace)
case explainModeFull:
return lineage.Full(trace)
case explainModeFails:
return lineage.Fails(trace)
case explainModeDebug:
return lineage.Debug(trace)
default:
return nil
}
}
func isThresholdValid(t float64) bool {
return 0 <= t && t <= 100
}
func startWatcher(ctx context.Context, testParams testCommandParams, paths []string, store storage.Store, done chan struct{}) {
watcher, err := pathwatcher.CreatePathWatcher(paths)
if err != nil {
_, _ = fmt.Fprintln(testParams.errOutput, "Error creating path watcher: ", err)
os.Exit(1)
}
readWatcher(ctx, testParams, watcher, paths, store, done)
}
func readWatcher(ctx context.Context, testParams testCommandParams, watcher *fsnotify.Watcher, paths []string, store storage.Store, done chan struct{}) {
for {
_, _ = fmt.Fprintln(testParams.output, strings.Repeat("*", 80))
_, _ = fmt.Fprintln(testParams.output, "Watching for changes ...")
select {
case evt := <-watcher.Events:
removalMask := fsnotify.Remove | fsnotify.Rename
mask := fsnotify.Create | fsnotify.Write | removalMask
if (evt.Op & mask) != 0 {
removed := ""
if (evt.Op & removalMask) != 0 {
removed = evt.Name
}
processWatcherUpdate(ctx, testParams, paths, removed, store)
}
case <-done:
_ = watcher.Close()
return
}
}
}
func processWatcherUpdate(ctx context.Context, testParams testCommandParams, paths []string, removed string, store storage.Store) {
filter := ignored(testParams.ignore).Apply
var loadResult *initload.LoadPathsResult
err := pathwatcher.ProcessWatcherUpdateForRegoVersion(ctx, testParams.RegoVersion(), paths, removed, store, filter, testParams.bundleMode, false,
func(ctx context.Context, txn storage.Transaction, loaded *initload.LoadPathsResult) error {
if len(loaded.Files.Documents) > 0 || removed != "" {
if err := store.Write(ctx, txn, storage.AddOp, storage.RootPath, loaded.Files.Documents); err != nil {
return fmt.Errorf("storage error: %w", err)
}
}
loadResult = loaded
return nil
})
if err != nil {
_, _ = fmt.Fprintln(testParams.output, err)
return
}
modules := map[string]*ast.Module{}
for id, module := range loadResult.Files.Modules {
modules[id] = module.Parsed
}
err = storage.Txn(ctx, store, storage.WriteParams, func(txn storage.Transaction) error {
runner, reporter, err := compileAndSetupTests(ctx, testParams, store, txn, modules, loadResult.Bundles)
if err != nil {
return err
}
for range testParams.count {
exitCode, err := runTests(ctx, txn, runner, reporter, testParams)
if exitCode != 0 {
return err
}
}
return nil
})
if err != nil {
_, _ = fmt.Fprintln(testParams.output, err)
}
}
func compileAndSetupTests(ctx context.Context, testParams testCommandParams, store storage.Store, txn storage.Transaction, modules map[string]*ast.Module, bundles map[string]*bundle.Bundle) (*tester.Runner, tester.Reporter, error) {
var capabilities *ast.Capabilities
// if capabilities are not provided as a cmd flag,
// then ast.CapabilitiesForThisVersion must be called
// within checkModules to ensure custom builtins are properly captured
if testParams.capabilities.C != nil {
capabilities = testParams.capabilities.C
} else {
capabilities = ast.CapabilitiesForThisVersion()
}
// -s {file} (one input schema file)
// -s {directory} (one schema directory with input and data schema files)
schemaSet, err := loader.Schemas(testParams.schema.path)
if err != nil {
return nil, nil, err
}
compiler := ast.NewCompiler().
SetErrorLimit(testParams.errLimit).
WithPathConflictsCheck(storage.NonEmpty(ctx, store, txn)).
WithEnablePrintStatements(!testParams.benchmark).
WithCapabilities(capabilities).
WithSchemas(schemaSet).
WithUseTypeCheckAnnotations(true).
WithRewriteTestRules(testParams.varValues)
info, err := runtime.Term(runtime.Params{})
if err != nil {
return nil, nil, err
}
if testParams.threshold > 0 && !testParams.coverage {
testParams.coverage = true
}
var cov *cover.Cover
var coverTracer topdown.QueryTracer
if testParams.coverage {
if testParams.benchmark {
errMsg := "coverage reporting is not supported when benchmarking tests"
_, _ = fmt.Fprintln(testParams.errOutput, errMsg)
return nil, nil, errors.New(errMsg)
}
cov = cover.New()
coverTracer = cov
}
timeout := testParams.timeout
if timeout == 0 { // unset
timeout = 5 * time.Second
if testParams.benchmark {
timeout = 30 * time.Second
}
}
runner := tester.NewRunner().
SetCompiler(compiler).
SetStore(store).
CapturePrintOutput(true).
EnableTracing(testParams.verbose || testParams.varValues).
SetCoverageQueryTracer(coverTracer).
SetRuntime(info).
SetModules(modules).
SetBundles(bundles).
SetTimeout(timeout).
Filter(testParams.runRegex).
SetParallel(testParams.parallel)
if testParams.target.IsSet() {
runner = runner.Target(testParams.target.String())
}
var reporter tester.Reporter
goBench := false
if !testParams.coverage {
switch testParams.outputFormat.String() {
case formats.JSON:
reporter = tester.JSONReporter{
Output: testParams.output,
Sort: testParams.sortTests.String(),
}
case formats.GoBench:
goBench = true
fallthrough
default:
if testParams.sortTests.String() != formats.SortNone {
_, _ = fmt.Fprintln(testParams.errOutput, "warning: --sort is only supported with JSON format")
}
reporter = tester.PrettyReporter{
Verbose: testParams.verbose,
Output: testParams.output,
BenchmarkResults: testParams.benchmark,
BenchMarkShowAllocations: testParams.benchMem,
BenchMarkGoBenchFormat: goBench,
FailureLine: testParams.varValues,
LocalVars: testParams.varValues,
}
}
} else {
reporter = tester.JSONCoverageReporter{
Cover: cov,
Modules: modules,
Output: testParams.output,
Threshold: testParams.threshold,
Verbose: testParams.verbose,
}
}
return runner, reporter, nil
}
func initTest(root *cobra.Command, brand string) {
executable := root.Name()
var testParams = newTestCommandParams()
var testCommand = &cobra.Command{
Use: "test <path> [path [...]]",
Short: "Execute Rego test cases",
Long: `Execute Rego test cases.
The 'test' command takes a file or directory path as input and executes all
test cases discovered in matching files. Test cases are rules whose names have the prefix "test_".
If the '--bundle' option is specified the paths will be treated as policy bundles
and loaded following standard bundle conventions. The path can be a compressed archive
file or a directory which will be treated as a bundle. Without the '--bundle' flag OPA
will recursively load ALL *.rego, *.json, and *.yaml files for evaluating the test cases.
Test cases under development may be prefixed "todo_" in order to skip their execution,
while still getting marked as skipped in the test results.
Example policy (example/authz.rego):
package authz
allow if {
input.path == ["users"]
input.method == "POST"
}
allow if {
input.path == ["users", input.user_id]
input.method == "GET"
}
Example test (example/authz_test.rego):
package authz_test
import data.authz.allow
test_post_allowed if {
allow with input as {"path": ["users"], "method": "POST"}
}
test_get_denied if {
not allow with input as {"path": ["users"], "method": "GET"}
}
test_get_user_allowed if {
allow with input as {"path": ["users", "bob"], "method": "GET", "user_id": "bob"}
}
test_get_another_user_denied if {
not allow with input as {"path": ["users", "bob"], "method": "GET", "user_id": "alice"}
}
todo_test_user_allowed_http_client_data if {
false # Remember to test this later!
}
Example test run:
$ ` + executable + ` test ./example/
If used with the '--bench' option then tests will be benchmarked.
Example benchmark run:
$ ` + executable + ` test --bench ./example/
The optional "gobench" output format conforms to the Go Benchmark Data Format.
The --watch flag can be used to monitor policy and data file-system changes. When a change is detected, ` + brand + ` reloads
the policy and data and then re-runs the tests. Watching individual files (rather than directories) is generally not
recommended as some updates might cause them to be dropped by OPA.
`,
PreRunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("specify at least one file")
}
// If an --explain flag was set, turn on verbose output
if testParams.explain.IsSet() {
testParams.verbose = true
}
return env.CmdFlags.CheckEnvironmentVariables(cmd)
},
RunE: func(cmd *cobra.Command, args []string) error {
cmd.SilenceErrors = true
cmd.SilenceUsage = true
exit := opaTest(args, testParams)
if exit != 0 {
return newExitError(exit)
}
return nil
},
}
// Test specific flags
testCommand.Flags().BoolVarP(&testParams.skipExitZero, "exit-zero-on-skipped", "z", false, "skipped tests return status 0")
testCommand.Flags().BoolVarP(&testParams.verbose, "verbose", "v", false, "set verbose reporting mode")
testCommand.Flags().DurationVar(&testParams.timeout, "timeout", 0, "set test timeout (default 5s, 30s when benchmarking)")
testCommand.Flags().BoolVarP(&testParams.coverage, "coverage", "c", false, "report coverage (overrides debug tracing)")
testCommand.Flags().Float64VarP(&testParams.threshold, "threshold", "", 0, "set coverage threshold and exit with non-zero status if coverage is less than threshold %")
testCommand.Flags().BoolVar(&testParams.benchmark, "bench", false, "benchmark the unit tests")
testCommand.Flags().StringVarP(&testParams.runRegex, "run", "r", "", "run only test cases matching the regular expression")
testCommand.Flags().BoolVarP(&testParams.watch, "watch", "w", false, "watch command line files for changes")
testCommand.Flags().BoolVar(&testParams.varValues, "var-values", false, "show local variable values in test output")
testCommand.Flags().IntVarP(&testParams.parallel, "parallel", "p", goRuntime.NumCPU(), "the number of tests that can run in parallel, defaulting to the number of CPUs (explicitly set with 0). Benchmarks are always run sequentially.")
testCommand.Flags().BoolVar(&testParams.failOnEmpty, "fail-on-empty", false, "Whether to fail the test when no test was run")
testCommand.Flags().Var(testParams.sortTests, "sort", "sort the JSON formatted test output")
// Shared flags
addOutputFormat(testCommand.Flags(), testParams.outputFormat)
addBundleModeFlag(testCommand.Flags(), &testParams.bundleMode, false)
addBenchmemFlag(testCommand.Flags(), &testParams.benchMem, true)
addCountFlag(testCommand.Flags(), &testParams.count, "test")
addMaxErrorsFlag(testCommand.Flags(), &testParams.errLimit)
addIgnoreFlag(testCommand.Flags(), &testParams.ignore)
setExplainFlag(testCommand.Flags(), testParams.explain)
addTargetFlag(testCommand.Flags(), testParams.target)
addCapabilitiesFlag(testCommand.Flags(), testParams.capabilities)
addSchemaFlags(testCommand.Flags(), testParams.schema)
addV0CompatibleFlag(testCommand.Flags(), &testParams.v0Compatible, false)
addV1CompatibleFlag(testCommand.Flags(), &testParams.v1Compatible, false)
root.AddCommand(testCommand)
}