Writing testable Rego
opa test, fixtures, and coverage.
In short. Writing testable Rego: opa test, fixtures, and coverage; Policy-as-code at scale guidance.
A Rego policy without tests is a bouncer you have hired but never watched work the door. You wrote the instruction sheet — no fake IDs, no weapons, VIPs on this list — but you have no idea whether anyone actually gets turned away until the night goes wrong. A unit test is the rehearsal: you send actors dressed as every kind of troublemaker at the door and check that each one is refused, then send the regulars through and check that they are not. In policy-as-code terms, the instruction sheet is Rego (the declarative language OPA evaluates, from the previous lesson), the door is your admission webhook or CI gate, and the actors are hand-built JSON inputs.
The failure this prevents is silent and binary. A policy broken in the *permissive* direction admits everything — the privileged pod ships to prod and nobody is notified, because a Rego rule whose body can never match does not error, it simply never fires. One misspelled JSON path (securitycontext for securityContext) is enough. Broken in the *restrictive* direction, it denies everything and takes deployments down cluster-wide. Both modes look identical at review time: the code reads plausibly. At scale — hundreds of rules maintained by dozens of teams — eyeballing is not a strategy; the only thing standing between a typo and an incident is an executable test suite.
How opa test works
opa test is built into the OPA binary. It compiles every .rego file under the paths you give it into a single in-memory bundle, finds every rule whose name starts with test_, and evaluates each one as a query. A test that evaluates to true passes; false, undefined, or a runtime error fails. There is no cluster, no webhook, no network: evaluation is a pure function from input plus data to a decision, which is why thousands of tests finish in seconds. The with keyword is the mocking mechanism — it rebinds input, any data path, or even built-in functions like http.send and time.now_ns for the duration of a single expression.
package kubernetes.admissiondeny contains msg if {some container in input.request.object.spec.containerscontainer.securityContext.privileged == truemsg := 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.ownermsg := "prod workloads require an owner label"}
deny is a partial set rule: each contains body that matches contributes one message, and the decision is the union. Small bodies that each check exactly one thing are the unit of testability — you assert on individual messages, not on a monolithic boolean.
package kubernetes.admission_testimport data.kubernetes.admissionprivileged_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_podmsgs == {`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 with input as safe) == 0}
Three habits to notice. The fixture is a named document, not inline noise, so every test reads as *scenario in, decision out*. The assertion checks the exact message set, not just a count, so a second rule firing accidentally fails the test. And the passing case is derived from the failing case with json.patch, flipping one field — the pair can never drift apart, and each proves the other is testing what you think it is.
$ opa test . -vdata.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 so they can be tested
Testable Rego is a design constraint, not an afterthought. Keep each deny body to one predicate and extract shared logic into helper rules or functions (is_privileged(container)) that get their own focused tests. Never hard-code environment-specific values — exception lists, allowed registries, thresholds — into rule bodies; put them under data and let tests inject variants with with data.exceptions as {"legacy-ns"}. Anything nondeterministic must be frozen: with time.now_ns as 1767225600000000000 pins the clock, and if a rule genuinely must call http.send (avoid this on admission paths — it adds a network dependency to every API request), mock it with with http.send as mock_response. A rule you cannot evaluate deterministically on a laptop is a rule you cannot test, page on, or debug at 3 a.m.
not deny proves nothingnot undefined succeeds. A test like test_allows if { not violation with input as pod } passes even when pod is misshapen — wrong nesting, misspelled key — because the rule never matched *anything*, including your intent. Derive every allow-case fixture from a deny-case fixture you have proven fires (the json.patch pattern above); a negative test is only trustworthy next to its positive twin.Gate merges on coverage and lint
$ 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 gate catches a class of defect the previous one cannot. Coverage marks lines no test ever evaluated — a deny body at zero coverage is a rule that has never once fired, in tests or anywhere else. opa check --strict promotes unused imports and shadowed variables to compile errors. opa fmt --fail keeps diffs mechanical so reviews stay about logic. Regal, the Rego linter maintained by Styra, ships over a hundred rules for defects tests miss by construction: constant conditions, input references inside functions that should take arguments, deprecated built-ins. Run all four on every merge request, and build the policy bundle only from a commit where all four pass.
What tests cannot tell you
The suite is only as truthful as its fixtures. The dominant failure mode at scale is fixture drift: the API server starts defaulting a new field, a controller injects a sidecar, and your hand-written AdmissionReview no longer resembles anything the policy will actually see. Mitigate it by harvesting fixtures from reality — OPA decision logs record the exact input of every real decision, and replaying a sampled set as regression fixtures is the highest-leverage testing habit in mature deployments. Unit tests also say nothing about evaluation latency under load, or about two policies interacting to block each other's exceptions; those surface only in staged rollout, which is the policy-lifecycle lesson's territory. And 100% coverage means every line ran — not that any line is right.
Everything here assumed the engine speaks Rego and ships a first-class test runner. That assumption is exactly what is at stake next: Gatekeeper embeds this same Rego-and-opa test workflow inside Kubernetes CRDs, while Kyverno abandons Rego for YAML and brings its own kyverno test harness — and how each one lets you rehearse a policy before enforcement is one of the sharpest criteria for choosing between them.
test_allows if { not deny with input as pod } and it passes on the first run. Why is this test untrustworthy even though it is green?not undefined returns true, not because errors are swallowed.with rebinds the entire input document including all nested paths; the failure is undefined-on-missing-key, not partial mocking.