How do you unit-test a Rego rule that calls http.send or reads from data?
answer
- One keyword swaps context for one expression
- Test rules are just rules with a prefix
- Undefined counts as a failing test
- You can replace a built-in too
- Assert the empty case too
basics
~20 sUse the with keyword to replace evaluation context: with input as {...} supplies a fake document, with data.x as {...} replaces a data subtree, and with http.send as {...} mocks the built-in, so opa test runs offline and deterministically.
solid answer
~50 sA Rego test is an ordinary rule whose name starts with `test_`, and `opa test` runs it; it passes when the rule evaluates to true and fails when it is false or undefined. The `with` keyword is what makes a rule that depends on external facts testable: `with input as {...}` supplies the artifact, `with data.licences as {...}` replaces a subtree of the loaded document, and `with http.send as {...}` mocks the built-in so every call to it in that expression returns the value you gave. You can chain several `with` clauses onto one expression. Mock with a plain value when one response is enough, or with a function when the answer must vary by URL. Always assert the negative case too — that a permitted artifact produces no denials — because a rule that has gone undefined for the wrong reason also produces none.
code
rego · 15 linespackage licence_test
import data.licence
test_restricted_licence_is_denied if {
count(licence.deny) == 1
with input as {"components": [{"name": "libfoo", "licence": "AGPL-3.0"}]}
with http.send as {"status_code": 200, "body": {"class": "restricted"}}
}
test_lookup_failure_is_not_silent if {
count(licence.deny) == 1
with input as {"components": [{"name": "libfoo", "licence": "AGPL-3.0"}]}
with http.send as {"error": {"message": "connection refused"}}
}go deeper
Know that a test is a rule named with a test prefix, that a runner executes it, and that you feed it a fake document rather than a real one.
Show the three substitutions — the document, a data subtree, and the built-in itself — and explain that clauses attach to one expression and can be chained.
Argue for the failure-path tests: the errored lookup, the unexpected status, the permitted artifact. Explain why a green suite of happy-path tests hides a control that has become a no-op.
Own the contract risk: mocks freeze your assumption about an external service's response shape, so decide how a change in that shape is detected and who is responsible for propagating it.
## What a test is There is no separate test framework to learn. A test is a rule in a package, conventionally in a `_test.rego` file, whose name begins with `test_`: ```rego test_restricted_licence_is_denied if { count(deny) == 1 with input as {"components": [{"name": "libfoo", "licence": "AGPL-3.0"}]} } ``` `opa test policy/ -v` evaluates every such rule. **A test passes when it evaluates to true.** False fails, and — this is the trap — undefined also fails, which is the behaviour you want, because an undefined test is a test that did not actually check anything. ## `with` replaces context for one expression `with` attaches to a single expression and swaps out part of what that expression sees while it is evaluated. It has three uses that matter here. **`with input as {...}`** — supply the document under evaluation. This is the everyday case: you hand the rule a small hand-written SBOM fragment, not a real one. **`with data.licences as {...}`** — replace a subtree of the global document. Your production `data` is loaded from files or a bundle; in a test you do not want to depend on that, so you substitute a fixture at the exact path the rule reads. Substituting at `data.licences` leaves the rest of `data`, including your other rules, intact. **`with http.send as {...}`** — replace the built-in itself. Mocked with a value, every call to `http.send` in the scope of that expression returns that value, regardless of the request object passed. Mocked with a function instead, the mock receives the arguments, so it can return different responses for different URLs — which is how you test that a restricted licence denies and an approved one does not, in the same evaluation. Clauses chain onto one expression, and they apply only to that expression's evaluation, so two tests in the same file can mock the same call differently. ## Why mocking the call is not optional A policy repo's own CI runs on every pull request to a rule. If a test genuinely reaches a licence service, three bad things follow. The suite needs network access and credentials from wherever CI runs. The suite becomes flaky, red for reasons that have nothing to do with the change under review, which trains people to rerun rather than read. And the suite's assertions silently depend on the current contents of a system nobody in the review can see — a licence gets reclassified and yesterday's passing test fails today with no diff to explain it. Mocking removes all three. The test asserts what the rule does *given* a response, which is the only thing the rule is responsible for. ## Test the failure branches, not just the happy one The reason to be systematic here is the same reason this whole area is dangerous: in Rego, absence produces nothing rather than an error. So write at least these cases for a rule that fetches: - **Restricted licence denies.** The obvious one, and assert the *count* or the specific message rather than merely that `deny` is non-empty. - **Approved licence produces no denial.** `count(deny) == 0`. This catches a rule that denies everything. - **The lookup errors.** Mock a response carrying an `error` field, and assert that you get whatever you decided the correct outcome is — usually a denial or a warning, never silence. This is the test that would have caught a control turning into a no-op the day the dependency broke. - **An unexpected status.** Mock a 500 with an HTML body and assert the rule does not treat it as a fact. The negative assertions matter more than they look. `count(deny) == 0` passes both when the rule correctly permits and when the rule is broken in a way that makes it produce nothing at all. That is why the positive test has to exist alongside it: together they pin the rule down, and either one alone can be satisfied by a rule that does nothing. ## A caveat on mocks A mock encodes your belief about the response shape. If the real service returns `{"classification": ...}` and your fixture says `{"class": ...}`, every test passes and the production rule silently never matches. Mocks make tests deterministic; they do not verify the contract. Pin the fixture to a response you actually captured from the real service, and treat a change in that service's shape as a change that must reach the policy repo.
- Your rule must deny one licence and permit another in the same test. How do you mock that?Mock `http.send` with a function rather than a value. A value mock returns the same response for every call; a function mock receives the request object, so it can branch on the URL and return a restricted classification for one licence and an approved one for the other. That lets a single evaluation exercise both paths and assert the exact set of messages.
- A test asserts count(deny) == 0 and passes. Why is that weak evidence on its own?Because a rule that is broken produces no denials either. An undefined reference, a mistyped `data` path or a mock whose shape does not match all yield an empty set, which is indistinguishable from a correct permit. Pair every negative assertion with a positive one over the same rule, so the pair can only pass if the rule actually discriminates.
- Does substituting data.licences in a test replace the whole data document?No. `with data.licences as {...}` replaces that subtree only; the rest of `data`, including the virtual documents your other rules produce, is untouched. That is what lets a test in one package reference rules in another while still controlling the specific facts under test.
saying these in an interview costs you the question
- Thinks a Rego test needs a separate framework or runner
- Believes an undefined test rule counts as passing
- Lets tests make real network calls for realism
- Only asserts that deny is non-empty
- Assumes a value mock can vary its response by URL