Testing policies
opa test and table-driven tests.
You wrote a rule that decides who gets through the door. Tomorrow somebody edits it. How do you know it still decides the same way? A smoke alarm earns its place on the ceiling because you press the button and hear it shriek. Policy rules earn trust the same way, by being made to fire on purpose before anything real depends on them. OPA (Open Policy Agent, the engine that answers policy questions) ships a test runner inside the same binary, so there is no framework to install and nothing extra to wire up. You write assertions as ordinary Rego rules whose names start with test_, feed each one a fake request using the with keyword, and run opa test. Green means the policy still means what you think it means. Red means you caught the regression here, instead of at a Kubernetes admission webhook or an API (application programming interface) gateway with live traffic behind it.
Your tests are ordinary Rego
A test is a rule whose name starts with test_. That prefix is the whole registration mechanism. opa test walks every .rego file it can reach, finds those rules, and evaluates each one. Discovery goes by rule name, not by filename, though people still name the file authz_test.rego out of habit, and it is a habit worth keeping. A test passes when it evaluates to true. It fails when it evaluates to false, and it also fails when it comes out undefined, which is Rego's way of saying it has no answer at all. Use with input as {...} to swap a fake request in for the length of one expression. Keep the tests in the same package as the policy so you can name allow directly with nothing in front of it. To assert that something gets refused, write not allow with input as {...}.
cat > authz.rego <<'EOF'package authzdefault allow := falseallow if {input.method == "GET"input.path == "/public"}allow if input.user.role == "admin"EOFcat > authz_test.rego <<'EOF'package authztest_public_get_allowed if {allow with input as {"method": "GET", "path": ["/public"]}}test_anonymous_post_denied if {not allow with input as {"method": "POST", "path": ["/private"]}}EOF
# files written
opa test . -v
data.authz.test_public_get_allowed: PASS (1.1ms)data.authz.test_anonymous_post_denied: PASS (0.9ms)----------------------------------------------------------------------PASS: 2/2
One rule, many cases, using every
Writing one test_ rule per case gets old fast, and boredom is exactly where coverage gaps hide. A restaurant does not print a fresh menu for every table. It prints one menu and hands it round. Do the same with your cases. Put them in a list of objects, each carrying an input and the answer you want back, then walk the list with every, which succeeds only if the body holds for all elements. Because with input as accepts a variable and not only a literal, each row's fake request binds in turn. Adding a case becomes adding a line, and the assertion logic stays in one place where you can read it.
cat >> authz_test.rego <<'EOF'cases := [{"name": "public GET", "input": {"method": "GET", "path": ["/public"]}, "want": true},{"name": "admin", "input": {"user": {"role": "admin"}}, "want": true},{"name": "anon POST", "input": {"method": "POST", "path": ["/private"]}, "want": false},]test_allow_table if {every c in cases {allow == c.want with input as c.input}}EOFopa test . -v --run test_allow_table
data.authz.test_allow_table: PASS (1.4ms)PASS: 1/1
Testing a deny set, not a boolean
An authorization policy answers with one boolean called allow. A validation policy answers with a deny set instead: a bag of message strings, one for each thing that is wrong, empty when nothing is. So the assertions look different. For a violation, check that the exact message you expect is a member of deny. For a clean input, check count(deny) == 0. Then cover every branch. A deny rule that no test ever satisfies is a rule you are trusting blind.
cat > k8s_test.rego <<'EOF'package maintest_privileged_denied if {deny["container app is privileged"] with input as {"spec": {"containers": [{"name": "app", "securityContext": {"privileged": true}}]}}}test_compliant if {count(deny) == 0 with input as {"spec": {"containers": [{"name": "app", "securityContext": {"privileged": false}}]}}}EOFopa test . -v
data.main.test_privileged_denied: PASSdata.main.test_compliant: PASSPASS: 2/2
Running the whole suite and gating the pipeline
Point opa test at a directory and it recurses into everything below it. Add -v to see each test by name. Add --run with a regular expression when you want to iterate on one test without waiting for the rest. Add --format=json when a CI (continuous integration, the service that builds and checks every change) job needs machine-readable output to keep as an artifact. Load a compiled bundle with -b and you are testing the exact artifact you ship rather than the loose files sitting next to it. The command exits non-zero the second any test fails, and that exit code is the entire CI integration.
opa test policy/ --format=json | jq '.[].pass'
truetruetrue
Test the fields that are missing
Here is the trap that catches almost everyone. In Rego, reading a field that is not there does not raise an error. The expression goes undefined, the rule body stops where it stands, and the rule quietly never fires. Your policy looks healthy and waves through every object that happens to omit the field you were checking. So write tests whose inputs leave things out on purpose: no securityContext block, no labels, no annotations. If the only case you ever test is the obvious violation, you can ship a policy that never fires on the half-filled YAML (the indented text format Kubernetes manifests are written in) that real teams actually apply.
cat >> k8s_test.rego <<'EOF'test_missing_security_context_not_crash if {count(deny) == 0 with input as {"spec": {"containers": [{"name": "app"}]}}}EOFopa test . -v --run missing
data.main.test_missing_security_context_not_crash: PASS# if this should deny missing context, the test FAILS and you fix the policy
Name tests the way you would write a specification. test_denies_privileged_container, never test_1. Assert the exact deny message whenever Gatekeeper (the Kubernetes admission controller that runs OPA policies) or Conftest (the tool that checks config files against Rego) shows that string to a human, because at that point the wording is part of the contract. When only the fact of failure matters, count(deny) > 0 is enough. Whichever you pick, make it obvious in the test name which one you meant.
Test the bundle you actually publish
The .rego files in your working tree can drift away from the compiled bundle your sidecars download. A recipe taped to the fridge is no proof of what went into the pot. opa test -b bundle.tar.gz runs the suite against the exact bytes that came out of opa build, so the thing you tested and the thing you publish are the same thing.
opa test -b bundle.tar.gz -v
PASS: 12/12
Run opa test in a pre-commit hook alongside opa fmt -w so formatting and tests never drift apart on main. In GitHub Actions, cache the OPA binary keyed on the hash of your pinned version, since re-downloading it every job buys latency and no safety. Order the checks cheapest first: fmt, then check --strict, then test. Compilation errors should surface before you pay for fixtures.
opa fmt -w policy/ && opa test policy/ -v
PASS: 15/15
Faking the clock and the data document
Some policies depend on things that move. A rule about business hours, or one that refuses a certificate expiring in under thirty days, gives a different answer next Tuesday than it does today. with time.now_ns as ... pins the clock so the test keeps meaning the same thing forever. with data.company.holidays as [...] does the same job for a lookup table, letting you assert holiday behavior without keeping a separate data file for every case. You will not reach for these often. The day you need them, they rescue the whole suite.
When a test fails in CI but passes on your laptop, check two things, in this order. First the OPA version, because syntax and built-in functions do move between releases. Then the set of files each side loaded. opa test . picks up every .rego file underneath it, so one stray scratch file that also declares package main can redefine a rule and flip the outcome. Use --run to bisect until you find the test that disagrees.
Keep fixtures next to the tests that use them: test/fixtures/good-pod.yaml and bad-pod.yaml, fed to conftest in the same job that runs opa test. Then someone clones one repository and reproduces the pipeline with two commands. Friction is what decides whether policy checks actually get run before a push, so spend your effort there.
A policy test that passes and fails on alternate runs is nearly always sharing state through data.json that an earlier test changed. with input as isolates the input document and nothing else. Loaded data stays loaded unless you override that too. Give each test its own data, or reset the files between cases.
Three layers buy you three different kinds of confidence. Unit tests with opa test say the logic is right. Conftest runs against real fixture files say the shapes you meet in the wild parse the way you assumed. Coverage says which lines nobody exercised at all. Skip a layer and you still feel confident, you have just stopped collecting the evidence for it.
Train reviewers to ask one question on every pull request that adds a deny rule: where is the test for undefined input? Put it on the checklist right beside "does this authorization change still carry default allow := false?"
Sets in Rego have no order you can lean on, and Gatekeeper can surface messages in a different sequence than your local run does. If you are asserting on a whole group of messages, compare sorted arrays rather than one serialized string. Or assert membership instead, with every msg in expected, which checks that each message you wanted is present without caring where it landed.
opa test is fast. Splitting the run by package directory is worth doing only once total time crosses a couple of minutes, and most policy repositories finish in seconds. Keep one job until you have a stopwatch reading that says otherwise.
In a monorepo with hundreds of cases, a wall of output hides the one line you need. Wrap opa test --format=json in a small script that prints the failing test_ names at the top of the CI log. Whoever reads that log at two in the morning should see the rule name first and the detail second.
Rewriting Rego from the v0 syntax to v1 is where tests coupled tightly to rule names let you down. Keep golden pairs of input and expected output as JSON (JavaScript Object Notation, the data format OPA reads and writes) files, and loop a small script over them with opa eval. That catches meaning drifting even when every rule name underneath has changed.
One step further, if you want it. Generate random inputs that fit your schema and assert invariants rather than specific answers: deny never contains an empty string, allow is never undefined once a default is set. Treat that as optional hardening on top of table tests, not as a place to start.
A first-week exercise for new joiners
Give every new engineer one deny rule, two tests and one conftest fixture to write in their first week. Typing Rego and watching it fail teaches faster than reading the language reference cover to cover. Have a mentor review the undefined cases in particular, because that is the part nobody gets right on intuition alone.
Store the expected deny sets as JSON files in the repository. A reviewer then sees a changed message as a one-line diff, instead of reading Rego and working out the output in their head.
Try this
Write two tiny tests using with input as, run opa test -v, then turn on a coverage threshold. If either test goes red, fix the policy or fix the fixture. Lowering the threshold to get a green build is the one move that is never available to you.
cat > authz.rego <<'EOF'package authzdefault allow := falseallow if input.user.role == "admin"EOFcat > authz_test.rego <<'EOF'package authztest_admin_allowed if allow with input as {"user":{"role":"admin"}}test_guest_denied if not allow with input as {"user":{"role":"guest"}}EOFopa test . -vopa test . --coverage --threshold 80
PASS: 2/2Coverage: 100.0%PASS: 2/2
Takeaway
An untested policy is an opinion. Treat Rego the way you treat the rest of your code: a table of cases for allow, fixtures that assert the exact strings inside deny, tests built from inputs that left fields out, and a coverage gate that turns the build red.
Next you will work through deny-set idioms, so these tests can assert on the message a person reads and not only on a bare true or false.