CoursesPolicy-as-code at scaleWriting testable Rego

Writing testable Rego

opa test, fixtures, and coverage.

Advanced35 min · lesson 3 of 13

A Rego policy with no tests is like a bouncer you hired and then never watched work the door. You handed over the rules on a sheet of paper: no fake IDs, no weapons, these names get in. Whether anyone actually gets turned away stays a mystery until the night goes badly. A unit test (a small check that runs one rule against one made-up scenario) is the rehearsal. You send actors dressed as every kind of troublemaker at the door and confirm each one gets refused, then walk the regulars past and confirm they get in. In policy-as-code terms, the sheet of paper is Rego (the language you write OPA rules in, where you state what counts as a violation instead of writing step-by-step code; OPA is the Open Policy Agent, the engine that evaluates it, from the previous lesson). The door is your admission webhook or your CI gate (continuous integration, the automated checks that run on every merge request). The actors are JSON payloads (JavaScript Object Notation, the plain-text data format Kubernetes speaks) that you build by hand.

The failure you are guarding against is quiet, and it goes one of two ways. Break a policy in the *permissive* direction and it lets everything through. The privileged pod ships to prod and nobody hears a thing, because a Rego rule whose body can never match does not raise an error. It never fires, and never firing looks identical to nothing being wrong. One misspelled path does it: securitycontext where you meant securityContext. Break it in the *restrictive* direction and it denies everything, and deployments stop cluster-wide. At review time both versions read perfectly fine. Nobody catches the difference by eye. Multiply that by hundreds of rules owned by dozens of teams and careful reading stops being a strategy. An executable test suite is the only thing standing between a typo and an incident.

How opa test actually runs

opa test lives inside the OPA binary, so there is nothing extra to install. You point it at a directory. It compiles every .rego file it finds under those paths into a single in-memory bundle, picks out every rule whose name starts with test_, and runs each one as a query. Evaluate to true and the test passes. Evaluate to false, to undefined (Rego's word for "that path does not exist"), or blow up at runtime, and the test fails. No cluster, no webhook, no network. Evaluation is a pure function from input plus data to a decision, which is why a few thousand tests finish in seconds. The mocking comes from one keyword, with. It swaps in a fake value for input, for any path under data, or even for built-in functions like http.send and time.now_ns, and the swap lasts for exactly one expression.

kubernetes/admission.rego
package kubernetes.admission
deny contains msg if {
some container in input.request.object.spec.containers
container.securityContext.privileged == true
msg := sprintf("container %q must not run privileged", [container.name])
}
deny contains msg if {
input.request.object.metadata.namespace == "prod"
not input.request.object.metadata.labels.owner
msg := "prod workloads require an owner label"
}

deny here is a partial set rule. Every contains body that matches drops one message into a set, and the final decision is all those messages together. Keep each body small, checking exactly one thing, and you get something you can test a piece at a time. You assert on individual messages instead of on one giant true-or-false.

kubernetes/admission_test.rego
package kubernetes.admission_test
import data.kubernetes.admission
privileged_pod := {"request": {"object": {
"metadata": {
"name": "web",
"namespace": "prod",
"labels": {"owner": "payments"}
},
"spec": {"containers": [{
"name": "app",
"securityContext": {"privileged": true}
}]}
}}}
test_denies_privileged_container if {
msgs := admission.deny with input as privileged_pod
msgs == {`container "app" must not run privileged`}
}
test_allows_unprivileged_pod if {
safe := json.patch(privileged_pod, [{
"op": "replace",
"path": "/request/object/spec/containers/0/securityContext/privileged",
"value": false
}])
count(admission.deny) == 0 with input as safe
}

Three habits in there are worth stealing. The fixture is a named document rather than inline noise, so each test reads as *scenario in, decision out*. The assertion compares the exact set of messages, not a count, so a second rule firing by accident breaks the test instead of hiding inside it. And the passing case is built from the failing case with json.patch, flipping a single field. Those two fixtures can never drift apart, and each one proves the other is measuring what you think it is.

shell
$ opa test . -v
data.kubernetes.admission_test.test_denies_privileged_container: PASS (521.3µs)
data.kubernetes.admission_test.test_allows_unprivileged_pod: PASS (312.9µs)
--------------------------------------------------------------------------------
PASS: 2/2

Design rules that can be tested in the first place

Testability is a decision you make while writing the rule, not a chore you bolt on afterwards. Keep each deny body down to one condition, and pull shared logic out into helper rules or functions like is_privileged(container) that get focused tests of their own. Never bake environment-specific values into a rule body, meaning exception lists, allowed registries, size thresholds. Put those under data and let each test inject its own variant with with data.exceptions as {"legacy-ns"}. Anything that moves on its own has to be frozen. with time.now_ns as 1767225600000000000 pins the clock to one fixed instant. If a rule genuinely has to call http.send (avoid that on admission paths, because it hangs a network call off every API request), mock it with with http.send as mock_response. A rule you cannot evaluate the same way twice on a laptop is a rule you cannot test, cannot page on, and cannot debug at 3 a.m.

A green allow-case test can be lying to you
Rego has no "no such field" error. Ask for a path that is not there and you get undefined, and a body that hits undefined never matches, so nothing lands in the set. That means a test like test_allows if { count(admission.deny) == 0 with input as pod } goes green even when pod is misshapen, with wrong nesting or a misspelled key, because no deny body matched *anything* at all, including the case you meant to write. Reaching for not admission.deny instead does not rescue you: a partial set rule is always defined, and an empty set counts as true in Rego, so that version stays red even when the policy is perfect. Build every allow-case fixture out of a deny-case fixture you have already watched fire, using the json.patch pattern above. A negative test is only worth trusting when it sits next to its positive twin.

Put four gates in front of every merge

shell
$ opa test . --coverage --threshold 90 | jq '.coverage'
95.83
$ opa check --strict kubernetes/ # exit 0, no output when clean
$ opa fmt --fail --diff kubernetes/ # non-zero exit if formatting drifts
$ regal lint kubernetes/
2 files linted. No violations found.

Each of those catches a class of defect the others are blind to. Coverage marks lines that no test ever evaluated, and a deny body sitting at zero coverage is a rule your own suite has never once fired. It measures the test run and nothing else, so a rule can sit at zero here while an admission webhook evaluates it a thousand times a day. opa check --strict turns unused imports and shadowed variables into compile errors instead of quiet weirdness. opa fmt --fail keeps diffs mechanical, so review time goes to logic rather than indentation. Regal, the Rego linter maintained by Styra, ships well over a hundred rules for problems tests cannot catch by construction: conditions that are always true, input referenced inside a function that should be taking an argument, deprecated built-ins. Run all four on every merge request, and build the policy bundle only from a commit where all four came back clean.

The red-green gate for policy changes
1Red
failing test from a real payload
2Green
opa test . -v passes
3Lint & fmt
regal, opa check --strict
4Coverage gate
--threshold 90 in CI
5Bundle
publish only if all gates pass
Every rule change starts life as a failing test. CI builds a bundle only from a commit where every gate is green.

What a green suite still cannot tell you

Your suite is only as honest as its fixtures. The failure that bites hardest at scale is fixture drift. The API server starts filling in a new field by default, a controller injects a sidecar container, and the AdmissionReview object you typed out by hand months ago (the JSON the cluster sends a policy engine before accepting a change) stops resembling anything the policy will actually meet. The fix is to stop inventing fixtures and start harvesting them. OPA decision logs record the exact input of every real decision, and replaying a sampled set of those as regression fixtures is the highest-value testing habit in mature deployments. Unit tests also say nothing about how slow evaluation gets under load, or about two policies fighting each other over the same exception. Those only surface during a staged rollout, which the policy-lifecycle lesson covers. And 100% coverage means every line ran. It says nothing about whether any line is right.

All of this assumed your engine speaks Rego and ships a proper test runner. That assumption does not hold evenly across engines. Gatekeeper wraps this same Rego and opa test workflow inside Kubernetes CRDs (custom resource definitions, the way you add your own object types to the cluster API). Kyverno drops Rego entirely in favour of YAML (a plain-text config format) and brings its own kyverno test harness. How each one lets you rehearse a policy before you switch on enforcement turns out to be one of the sharpest ways to choose between them.

Try this

No cluster needed for this one. Save the two files above into an empty directory, run the suite once to watch both tests go green, then misspell a single letter in the policy: securitycontext where the pod object says securityContext. Run it again.

terminal
mkdir -p /tmp/policy && cd /tmp/policy
# save admission.rego and admission_test.rego from above into this directory
opa test . -v
# now edit admission.rego and change securityContext to securitycontext
opa test .
output
data.kubernetes.admission_test.test_denies_privileged_container: FAIL (1.1ms)
--------------------------------------------------------------------------------
PASS: 1/2
FAIL: 1/2

The allow-case test stays green through all of that. A rule body that reaches for a path nobody spelled correctly is undefined, it never matches, and never matching denies nothing. That is the permissive break from the top of this lesson, reproduced on your laptop in about a minute.

Takeaway

An untested policy is untested code that can block every deploy, or worse, quietly wave every deploy through. Once you are past a handful of rules, fixtures in CI stop being optional.

Next up: deny-by-default patterns, and how to keep a policy library readable once the rules start multiplying.

Quick check
01You strip the owner label out of the privileged_pod fixture and change nothing else. opa test . -v now reports test_denies_privileged_container as FAIL. What happened?
Incorrect — Rego has no missing field error. An absent path is undefined, and not input.request.object.metadata.labels.owner succeeds on undefined, which is exactly why the second rule starts firing instead of erroring.
Incorrect — That is the shape of the other test in the file. This one compares against the exact set of messages, which is the habit worth stealing: a second rule firing by accident breaks the test instead of hiding inside a number.
Correct — The fixture sits in namespace prod, so removing the owner label satisfies the second deny body as well. The set comes back with both messages, and comparing against the exact set is what surfaces it.
Incorrect — with replaces the whole input document, nested paths included, and there is nothing underneath it to merge with. opa test runs with no cluster and no network, so the fixture is all the rule ever sees.
02A test pins the clock with with time.now_ns as 1767225600000000000 on one line, then a later line in the same body calls a helper that reads the clock again. The suite is green today. What have you actually built?
Incorrect — No such limit exists. You can hang several with clauses off one expression and mock the same built-in again on the next one. Nothing errors here, and that is the trap: it passes.
Correct — A with swap reaches exactly the expression it hangs off. The helper on the following line calls the real time.now_ns, so the test holds only until the wall clock crosses whatever boundary the rule cares about. Every expression that needs a fake value has to carry its own clause.
Incorrect — This is the common misread of with, and it is how frozen-time tests leak. The scope is per expression, not per body, so the later line quietly falls back to the real clock.
Incorrect — Mocking a built-in writes nothing into data. with substitutes a value for the duration of one expression and leaves nothing behind for later lines to pick up.
03You inherit a policy repo where the allow-case test builds its fixture by hand instead of patching a deny fixture, and count(admission.deny) == 0 with input as pod has been green since the day it was written. What earns you trust in that green?
Correct — That is the pattern in the lesson's test file. The two fixtures are identical apart from the field under test, so they cannot drift apart, and the deny twin has already proved that this shape reaches the rule.
Incorrect — That version never goes green. deny is a partial set rule, so it is always defined, and an empty set counts as true in Rego, which leaves not admission.deny false even when the policy is perfect.
Incorrect — Coverage marks lines your tests evaluated, not whether a fixture was shaped right. The lesson's own caveat applies here: every line running tells you nothing about whether any line is right.
Incorrect — Each of the four gates catches a class of defect the others are blind to. Strict compilation and lint read the policy source; neither one runs your fixture, so neither can notice that a fixture missed the rule it was aimed at.

Related