kat is a lightweight, local testing tool for Kubernetes Admission Policies (ValidatingAdmissionPolicy and MutatingAdmissionPolicy). It allows you to write test cases using standard Kubernetes manifests and verify your policies' behavior without needing a running cluster.
Given a policy like this:
my-policy/
├── policy.yaml
└── tests/
├── my-policy.good-pod.allow.object.yaml
└── my-policy.bad-pod.deny.object.yaml
The policy itself is a standard ValidatingAdmissionPolicy:
# policy.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: my-policy
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
validations:
- expression: "has(object.metadata.labels) && 'owner' in object.metadata.labels"
message: "All workloads must have an 'owner' label"
reason: InvalidThe simplest test is just a Kubernetes object:
# tests/my-policy.good-pod.allow.object.yaml
apiVersion: v1
kind: Pod
metadata:
name: good-pod
labels:
owner: platform-team# tests/my-policy.bad-pod.deny.object.yaml
apiVersion: v1
kind: Pod
metadata:
name: bad-pod
# Missing required labelRun it:
kat .ok my-policy 0.004s
That's it. kat discovers the policy, finds the tests, and evaluates them. The filename tells kat everything: which policy to test (my-policy), what to expect (allow or deny), and what the file contains (object). See File Naming Convention for the full grammar.
For deny tests you can also add a .message.txt file to assert the exact rejection message. It's optional, but recommended — it catches policies that fail for the wrong reason.
The output and flags mirror go test, so kat drops straight into existing CI. Use -v to see each test case, and note that kat exits non-zero when any test fails:
=== RUN my-policy
=== RUN my-policy/my-policy.bad-pod.deny.yaml
--- PASS: my-policy/my-policy.bad-pod.deny.yaml (0.00s)
=== RUN my-policy/my-policy.good-pod.allow.yaml
--- PASS: my-policy/my-policy.good-pod.allow.yaml (0.00s)
PASS
When a test fails, kat reports why — including a diff for mutating policies:
--- FAIL: add-default-labels/add-default-labels.no-labels.yaml (0.00s)
mutated object does not match expected:
--- Expected
+++ Actual
@@ -2,7 +2,7 @@
metadata:
labels:
- environment: dev
+ environment: development
FAIL add-default-labels 0.008s
test summary: tests failed: 2
go install github.com/zemanlx/kat@latestOr build from source:
git clone https://github.com/zemanlx/kat.git
cd kat
go installRun from the root of your repository — kat will automatically discover and execute all tests found in tests/ directories recursively:
kat .Target one or more directories (a policy directory, a tests/ directory, or any parent):
# Run tests for a specific policy
kat ./policies/my-policy
# Run several policies at once
kat ./policies/my-policy ./policies/other-policyTo run a single test case, use -run to match it by name:
kat -run "my-policy.basic-test" ./policies/my-policy-run <regex>: Run only tests matching the regex pattern.-v: Verbose output (shows detailed execution steps).-json: Emit newline-delimited JSON events, compatible withgo test -json(works with tooling likegotestsum).
kat -v -run "prod-.*-deny" .kat exits 0 when all tests pass and non-zero when any test fails, so it can be used directly as a CI gate.
kat is designed to fit naturally into existing Kubernetes repositories, including those using Kustomize.
The tool works by discovery:
- It looks for
tests/directories containing test files. - It looks for policy and binding files in the parent directory of
tests/.
Supported filenames include:
policy.yaml/policies.yamlbinding.yaml/bindings.yaml- Any file ending in
.policy.yamlor.binding.yaml
Note: You can define multiple policies and bindings in a single file (separated by ---), or split them across multiple files. The tool loads all valid policy/binding resources found in the directory.
policies/
├── team-label-policy/
│ ├── kustomization.yaml # (Optional) Kustomize file
│ ├── policy.yaml # The AdmissionPolicy definition
│ ├── binding.yaml # The AdmissionPolicyBinding
│ └── tests/ # Add this folder for kat
│ ├── team-label.has-label.allow.object.yaml
│ ├── team-label.missing.deny.object.yaml
│ └── ...
Running kat . at the root will automatically find the tests directory, associate it with the policy in the parent directory, and execute the tests.
Pattern: <policy-name>.<test-name>.<expect>.<type>.yaml
The <policy-name> prefix must match the metadata.name of the policy being tested. If a directory contains only a single policy, kat automatically associates all tests with that policy.
| Part | Values | Description |
|---|---|---|
| expect | allow, deny, warn, audit |
Expected admission outcome (validating policies; omit for mutating — see Mutating Policies) |
| type | object, oldObject, request, params, namespaceObject, authorizer, annotations, warnings |
What the file contains |
Multiple files with the same <policy-name>.<test-name>.<expect> prefix are merged into a single test case.
You can provide test inputs as a single .request.yaml file or as separate files per field — or a mix of both.
A .request.yaml file can contain the object, params, namespace context, and user info all in one place. This is the simplest way to write tests that need more than just an object.
# my-policy.dev-deploy.allow.request.yaml
operation: CREATE
namespace: development
object:
apiVersion: apps/v1
kind: Deployment
metadata:
name: dev-deployment
namespace: development
spec:
replicas: 1
selector:
matchLabels:
app: test
template:
metadata:
labels:
app: test
spec:
containers:
- name: nginx
image: nginx
namespaceObject:
apiVersion: v1
kind: Namespace
metadata:
name: development
labels:
environment: dev
params:
apiVersion: v1
kind: ConfigMap
metadata:
name: policy-config
data:
maxReplicas: "10"
userInfo:
username: "developer@example.com"Available fields in .request.yaml:
| Field | Description |
|---|---|
operation |
CREATE, UPDATE, DELETE, or CONNECT |
object |
The object being admitted |
oldObject |
Previous version (for UPDATE/DELETE) |
params |
Parameter resource (paramKind/paramRef) |
namespaceObject |
Namespace context with labels/annotations |
userInfo |
User making the request |
namespace |
Shorthand for request namespace |
name |
Shorthand for request name |
subResource |
Sub-resource being accessed (e.g., status) |
options |
Additional options for the request |
For simpler tests, you can use separate files — each containing just one piece:
my-policy.deploy-test.allow.object.yaml # The object being admitted
my-policy.deploy-test.allow.params.yaml # Policy parameters
my-policy.deploy-test.allow.request.yaml # Additional context (userInfo, namespace, etc.)
This keeps individual files small and readable. All files sharing the same base name (my-policy.deploy-test.allow) are merged into one test case.
Conflict detection: If the same field (e.g., object) is defined in both .request.yaml and a separate .object.yaml, kat reports an error.
Allow — the object is admitted:
# my-policy.good-pod.allow.object.yamlDeny — the request is rejected. Add a .message.txt to verify the exact error:
# my-policy.bad-pod.deny.message.txt
All workloads must have an 'owner' label
Warn — admitted with warnings. Add a .warnings.txt:
# my-policy.old-api.warn.warnings.txt
Deprecated API version, migrate to apps/v1
Audit — admitted with audit annotations. Add an .annotations.yaml:
# my-policy.flagged.audit.annotations.yaml
audit-annotation-key: "violation detected"For mutating policies, provide a golden file (.gold.yaml) with the expected output:
my-policy.add-labels.object.yaml # Input object
my-policy.add-labels.gold.yaml # Expected object after mutation
Mutating policies always admit the request (they mutate, never deny), so omit the <expect> token — name the case after what it mutates (here, add-labels) rather than allow/deny. The assertion is the .gold.yaml diff, not the admission outcome.
If the actual mutation result differs from the golden file, the test fails with a diff. This also works with all-in-one .request.yaml files — just place a .gold.yaml alongside it.
Mock Kubernetes Authorizer responses (SubjectAccessReview) for policies using authorizer in CEL:
# my-policy.check-perms.deny.authorizer.yaml
- group: ""
resource: "pods"
namespace: "default"
verb: "create"
decision: "allow"Any check not explicitly mocked returns "NoOpinion".
- CREATE (default): Provide
object(via.object.yamlor in.request.yaml). - UPDATE: Provide both
objectandoldObject. Operation is inferred automatically. - DELETE: Provide only
oldObject. Operation is inferred automatically. - CONNECT: Set
operation: CONNECTin.request.yaml.
Operation inference works the same way whether fields are in separate files or consolidated in .request.yaml. If you set operation: explicitly and it conflicts with what would be inferred from the fields present, kat reports an error.
Authoring a test is a short loop:
- Write — copy an object that matches the policy's
matchConstraints, name the file<policy-name>.<test-name>.<expect>.<type>.yaml, and add any companion file (.message.txt,.warnings.txt,.annotations.yaml,.gold.yaml). - Run —
kat -v <policy-dir>(orkat -run "<test-name>" <policy-dir>for one case). - Validate — confirm the run exits
0. A failure prints a diff showing exactly what to fix.
kattakes directories, not single files — use-runto target one case.- Only the
denytoken flips the expectation;warn/auditstill expect the request to be allowed and rely on their companion file for the real assertion. - Make sure the object actually matches the policy's
matchConstraints(apiGroup/version/resource/operation) — otherwise the policy never fires. - A mutating policy that mutates the object requires a
.gold.yaml. - Defining the same field in both
.request.yamland a split file is an error. - Assertions are exact: deny message equals
.message.txt(trimmed), warnings match by line/index, audit annotations match the listed keys exactly. - Only
admissionregistration.k8s.io/v1is supported;v1beta1is a hard error.
This repo ships machine-readable guidance so coding agents can author tests reliably. The links below are absolute so an agent can fetch them directly, even from another repo:
AGENTS.md— repo orientation (build/test/lint commands, discovery, conventions). Raw:https://raw.githubusercontent.com/zemanlx/kat/main/AGENTS.mdskills/write-kat-tests/SKILL.md— a portable skill with the authoritative filename grammar and copy-ready templates for every test archetype. Raw:https://raw.githubusercontent.com/zemanlx/kat/main/skills/write-kat-tests/SKILL.md
If your repository uses kat to test admission policies, point your own coding agent
at this skill so it writes correct tests without you cloning anything. Add a short note
to your repo's AGENTS.md (or the equivalent agent instructions file):
## Testing admission policies
This repo uses [kat](https://github.com/zemanlx/kat) to test Kubernetes admission
policies. When creating or editing `kat` test cases, follow the authoritative skill:
https://raw.githubusercontent.com/zemanlx/kat/main/skills/write-kat-tests/SKILL.md
(filename grammar and templates under
https://github.com/zemanlx/kat/tree/main/skills/write-kat-tests/reference).
Always finish by running `kat <dir>` and confirming exit code 0.Agents that support project skills can instead vendor the skill locally by copying
skills/write-kat-tests/ into their skills directory
(e.g. .claude/skills/, .github/skills/, or .agents/skills/).
- Standard Kubernetes YAML — no new DSL to learn
- Full CEL Support — uses official Kubernetes CEL libraries for 100% accurate evaluation
- Comprehensive Policy Support —
ValidatingAdmissionPolicyandMutatingAdmissionPolicy - All Operations — CREATE, UPDATE, DELETE, CONNECT
- Golden File Testing — verifies mutated objects against expected output
- Rich Context —
userInfo,namespaceObject,matchConditions, authorizer mocking - Parameter Testing —
paramKind/paramRefwith ConfigMaps or custom resources
Check the test-policies-pass directory for a comprehensive set of examples covering:
- Basic validation and mutation
- Parameters and ConfigMaps
- Namespace-based logic
CONNECToperations (kubectl exec)- Match conditions
- Warnings and Audit annotations