Validation tests¶
Overview¶
Validation tests render your templates against fixture resources and assert on the output — broken templates and invalid HAProxy config fail before they reach a cluster. Tests are embedded in the HAProxyTemplateConfig CRD. You run them locally with the CLI (this page), and the controller also runs them automatically before any config reaches HAProxy.
Beyond running the controller (haptic run), the controller binary provides validate (this page) and benchmark (template render timing). To audit another controller's Ingresses before switching to HAPTIC, use the migration report in the playground — see Migrating: Step 0.
Tests also run automatically before deployment
The same suite runs at two gates besides the CLI, so a config whose tests fail never reaches HAProxy:
- Live config change — the controller re-runs the suite whenever a config changes. A change whose tests fail is refused,
haptic_config_rejected_total{validator="validationtests"}increments, and the last-good config keeps serving. The budget scales with suite size (a 25s floor plus ~100 ms per test), so a large suite isn't cut off mid-run. - Startup load gate — the suite also runs on every fresh or upgraded controller pod, with a much larger budget since there is no scatter-gather deadline. A failing initial config crash-loops the pod rather than serving untested config, and the reason is stamped on
status.conditions[Validated]with reasonLoadGateFailed.
There is no admission webhook for HAProxyTemplateConfig — a configuration is a set (the config plus its libraryRefs libraries), and admission sees one object at a time, so a per-object webhook would deny change sets whose end state is correct. To gate a config before it reaches the cluster, run haptic preflight in your pipeline.
The validate CLI, preflight, and both in-cluster gates run the identical suite through the same runner, so a passing local validate run predicts a clean load.
Quick start¶
-
Add a
validationTestssection to your HAProxyTemplateConfig:apiVersion: haproxy-haptic.org/v1alpha1 kind: HAProxyTemplateConfig metadata: name: my-config spec: # ... template configuration ... validationTests: test-basic-frontend: description: Frontend should be created with correct settings fixtures: services: - apiVersion: v1 kind: Service metadata: name: my-service namespace: default spec: ports: - port: 80 assertions: - type: haproxy_valid description: Configuration must be syntactically valid - type: contains target: haproxy.cfg pattern: "frontend.*default" description: Must have default frontend -
Download the
hapticbinary for your platform from the releases page. Thevalidatesubcommand is the controller binary running in validation mode. -
Run the tests:
To validate the config currently deployed in your cluster instead of a local file:
haptic config view --input --namespace haptic > /tmp/haptic-config.yaml
haptic validate -f /tmp/haptic-config.yaml
A Helm install spreads the configuration across one object per enabled template
library, so dumping a single object would only give you part of it — config view
--input merges the set the same way the controller does. Each library's tests
ship in that library's object and all of them run together as one suite against
the merged config, exactly as before.
Or run tests right here — this is a complete config with a validationTests block. Press Run live, then open the tests tab to see each assertion pass or fail:
Test structure¶
Each test consists of:
| Component | Description |
|---|---|
| Name | Unique identifier (kebab-case, for example test-ingress-tls-routing) |
| Description | What the test verifies |
| Fixtures | Simulated Kubernetes resources |
| Assertions | Checks on rendered output |
HTTP fixtures (httpResources) |
Optional — mocked responses for http.Fetch() URLs (see HTTP Fixtures) |
Min HAProxy version (minHAProxyVersion) |
Optional — skip the test unless the HAProxy version under test is at least this (for version-gated features) |
Extra context (extraContext) |
Optional — per-test values deep-merged into the global templatingSettings.extraContext: nested maps merge key by key with per-test leaves winning, so overriding one key keeps its siblings. Pin every value your assertions depend on — a sibling you leave unset keeps its deployment-configured value. To pin an exact key set instead of merging, give the nested map __replace__: true: it replaces the deployment's map at that key wholesale, and the sentinel is stripped from the result |
Current servers (currentServers) |
Optional — the servers a previous deployment had, keyed by backend and server name, exposed to templates as currentConfig.ServerIndex; use it to exercise slot-preservation logic (see Current servers) |
Current config (currentConfig) |
Deprecated — a raw haproxy.cfg the runner parses down to the same server index. Use currentServers |
Current files (currentFiles) |
Optional — filename → content of the general files already deployed, exposed to templates as currentFiles; use it for templates that read their own prior output, such as self-rotating TLS session-ticket keys |
Requires (requires / requiresFields) |
Optional — strip the test when a watched resource or schema field is unavailable (see Conditional Tests) |
Fixtures¶
Fixtures simulate Kubernetes resources:
fixtures:
services:
- apiVersion: v1
kind: Service
metadata:
name: api
namespace: production
spec:
ports:
- port: 80
ingresses:
- apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: main
namespace: production
spec:
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api
port:
number: 80
HTTP fixtures¶
Mock HTTP responses for templates using http.Fetch():
httpResources:
- url: "http://blocklist.example.com/list.txt"
content: |
blocked-value-1
blocked-value-2
Templates calling http.Fetch() for unmocked URLs fail with an error. Define shared HTTP fixtures in the _global test to make them available to all tests.
Current servers¶
currentServers gives the render a previous deployment to reason about, keyed by backend name and then by server name (the pod name, ADR-0011). Templates read it as currentConfig.ServerIndex, so it's what you use to test that a rolling deployment keeps existing pods on their named server lines:
currentServers:
default_api_svc_api_80:
api-pod-1: {address: 10.0.0.1, port: 8080}
api-pod-2: {address: 10.0.0.2, port: 8080}
Without currentServers, currentConfig is nil — the first-deployment case.
The older currentConfig field takes a raw haproxy.cfg instead and parses it down to the same server index. It's deprecated: the parse reads far more of the file than templates can see, and a later release removes it. Setting both fields fails the test.
Fixture keys¶
Fixture keys name watchedResources entries, with one reserved exception: haproxy-pods populates the auto-injected HAProxy pod store that templates read as controller.haproxy_pods. Its entries default to apiVersion: v1 / kind: Pod and are indexed by namespace and name. Any other key fails the test with resource type "<key>" in fixtures not found in watched resources.
The reserved _global entry¶
A test named _global is a shared baseline rather than a test. Its fixtures, httpResources and extraContext feed every test in the suite, and its own assertions are never executed — so it's the one place to put a fixture set several tests need. It's also the one test name that more than one object of a merged set may each contribute to; every other name must be unique across the merged set.
Conditional Tests (requires and requiresFields)¶
requires lists watchedResources keys the test depends on. When an optional
watched resource named there is unavailable (no candidate API version served
by the cluster), the test is stripped from the effective configuration at load
time — the same mechanism templateSnippets use.
requiresFields goes one level deeper: a list of schema field paths in the
form <watchedResourceKey>.<field.path>:
validationTests:
test-httproute-cors-filter:
requires: [httproutes]
requiresFields: [httproutes.spec.rules.filters.cors]
# ...
When any listed field is absent from the resolved schema generation of its
watched resource, the test is stripped at load time. This covers clusters
that serve the resource at the same API version as newer releases but with
an older schema generation lacking the field (for example, Gateway API v1.1
serves httproutes at v1 without the Cross-Origin Resource Sharing (CORS) filter — the apiserver prunes
the field from fixtures, the feature never activates, and without stripping
the test would fail the fail-closed load gate). The first dot-segment must
name a watchedResources key; array levels in the remaining path are
descended transparently (spec.rules.filters.cors matches the field inside
the rules[] / filters[] items). The current stripping outcome is visible
at /debug/vars/effectiveConfigResolution.
Assertion types¶
Assertion Targets¶
The contains, not_contains, match_count, equals, and match_order assertion types share a target field selecting which rendered output to check (resolved by pkg/controller/testrunner/assertion_helpers.go):
| Target | What's checked |
|---|---|
haproxy.cfg (or empty) |
The rendered main HAProxy configuration |
map:<name> |
A rendered map file. <name> matches against either the full path or the basename |
file:<name> |
A rendered general file (error pages, etc.), matched by filename |
cert:<name> |
A rendered SSL certificate, matched by basename |
crt-list:<name> |
A rendered crt-list file (registered by a template via fileRegistry.Register("crt-list", …)), matched by basename |
k8s:<template-name> |
The rendered YAML of a spec.k8sResources template (potentially multi-doc with ---), so you can assert on emitted Kubernetes resources |
status:<ns>/<name>:<phase> |
The JSON status payload a statusPatch() call emitted for resource <ns>/<name> in the given pipeline phase (rendered, deployed, renderFailed, or deployFailed) — the way to test status-patch templates |
events |
The Kubernetes Events the templates recorded via recordEvent(), one per line as <Type> <Reason> <apiVersion> <Kind> <ns>/<name>: <message> |
rendering_error |
The simplified render error string, populated only when the render itself failed. Use this on negative tests where you expect rendering to be rejected |
Unknown targets fall back to haproxy.cfg silently
Typos in target: won't error — they'll just match the wrong content. Sanity-check via --dump-rendered if an assertion behaves unexpectedly.
haproxy_valid¶
Validates HAProxy configuration syntax using the HAProxy binary:
Every test should include this assertion.
contains¶
Verifies target content matches a regex pattern:
- type: contains
target: haproxy.cfg
pattern: "backend api-production"
description: Must create backend for API service
not_contains¶
Verifies target content doesn't match a pattern:
- type: not_contains
target: haproxy.cfg
pattern: "ssl-verify none"
description: Must not disable SSL verification
equals¶
Checks entire content matches exactly:
- type: equals
target: map:hostnames.map
expected: |
api.example.com backend-api
www.example.com backend-web
description: Hostname map must match exactly
Use for small, deterministic files. Not recommended for large configs.
jsonpath¶
Evaluates a JSONPath expression against the template rendering context and compares the single result to expected. JSONPath reads plain values from the context — it can't invoke the store methods templates use (resources.services.List()), so it fits scalar context values (for example a spec.templatingSettings.extraContext key, which is injected into the context by name):
- type: jsonpath
jsonpath: "{.environment}" # set via spec.templatingSettings.extraContext.environment
expected: "production"
description: extraContext.environment is wired through
To assert on watched resources or rendered output, use contains, match_count, or equals against haproxy.cfg (or a map: / file: target) instead.
match_count¶
Asserts that a regex pattern matches an exact number of times in the target. Useful for catching duplicate or missing entries:
- type: match_count
target: haproxy.cfg
pattern: "^backend "
expected: "3" # string — parsed as integer
description: Exactly 3 backends must be generated
match_order¶
Asserts that multiple patterns appear in the target in the listed order. Critical for HAProxy first-match-wins constructs (Gateway API route precedence, ordering of ACLs):
- type: match_order
target: map:path-prefix.map
patterns:
- "^/api/v2/users" # must come before /api/v2
- "^/api/v2" # must come before /api
- "^/api"
description: Path map entries must be sorted most-specific-first
deterministic¶
Renders the templates a second time with the same inputs and asserts the output is byte-for-byte identical. Catches unstable map ordering, time-dependent values, and other sources of non-determinism:
The check covers haproxy.cfg and every auxiliary file the template produced; no target or pattern is needed.
Running tests¶
# Run all tests
haptic validate -f config.yaml
# Run specific test
haptic validate -f config.yaml --test test-basic-routing
# Output formats
haptic validate -f config.yaml --output json
haptic validate -f config.yaml --output yaml
# Parallelism (0=auto-detect CPUs, 1=sequential)
haptic validate -f config.yaml --workers 4
# Typed watched-resource access — point at a directory of schemas
haptic validate -f config.yaml --schema-dir tests/schemas
# Equivalent: HAPTIC_SCHEMA_DIR=tests/schemas haptic validate ...
The haptic validate command shells out to the haproxy binary on your PATH — both to detect the HAProxy version during setup (haproxy -v) and for the haproxy_valid assertions (haproxy -c). Install HAProxy locally (for example via your package manager) and ensure it's on PATH; if no haproxy is found, validate fails fast with a clear error (it doesn't silently fall back to a syntax-only check). To validate against a specific HAProxy version, run the matching per-version controller image, which bundles that version.
Templates that use typed watched-resource access need --schema-dir (or HAPTIC_SCHEMA_DIR); without it they fail at engine compile time with a "no schema for X" error, while untyped dig()-based templates validate fine — see Templating — Typed Resource Access for where schemas come from and what the repo's bundled tests/schemas/ directory covers.
Exit code 0 means all tests passed.
Run in CI¶
Run validate as a pipeline step to block a broken config before it merges. The job fails when validate exits non-zero, so a template error or a failing test stops the pipeline. Use the per-version controller image: it bundles both the haptic binary and the matching haproxy binary, so haproxy_valid assertions run with no extra setup. Pick the tag whose HAProxy version matches your deployment (see HAProxy Versions).
GitLab CI (.gitlab-ci.yml) — override the image entrypoint so the job's script shell runs:
validate-haptic-config:
image:
name: registry.gitlab.com/haproxy-haptic/haptic:0.2.0-alpha.1-haproxy3.4
entrypoint: [""]
script:
- haptic validate -f config.yaml
GitHub Actions (.github/workflows/validate.yml):
jobs:
validate:
runs-on: ubuntu-latest
container:
image: registry.gitlab.com/haproxy-haptic/haptic:0.2.0-alpha.1-haproxy3.4
steps:
- uses: actions/checkout@v4
- run: haptic validate -f config.yaml
Output example¶
✓ test-basic-routing (0.125s)
✓ HAProxy configuration must be syntactically valid
✓ Must have frontend
✗ test-tls-config (0.089s)
✗ Must have SSL certificate
Error: pattern "ssl crt" not found in haproxy.cfg
Tests: 1 passed, 1 failed, 2 total (0.214s)
Debugging failed tests¶
--verbose¶
Shows content preview for failed assertions:
✗ test-gateway-routing
✗ Path map must have correct weight
Error: pattern "MULTIBACKEND:100:" not found in map:path-prefix.map
Content preview:
split.example.com/app MULTIBACKEND:0:default_split-route_0/
--dump-rendered¶
Shows all rendered content after test results:
--trace-templates¶
Shows top-level template execution order and timing:
Rendering: haproxy.cfg
Completed: haproxy.cfg (0.007ms)
Rendering: path-prefix.map
Completed: path-prefix.map (3.347ms)
Note
This shows only top-level template renders. To see the full call tree including
render_glob, render, and macro invocations, combine with --profile-includes:
--profile-includes¶
Lists the slowest 20 render / render_glob / macro invocations with cumulative timing — useful when --trace-templates shows a slow top-level template and you need to find which include is responsible:
--debug-filters¶
Logs every comparison made by sort filters (sort_by) and similar operations, with the input types and the comparison result. Useful when route precedence or map ordering doesn't match what you expected:
Combining flags¶
# Comprehensive end-to-end debugging
haptic validate -f config.yaml --verbose --dump-rendered --trace-templates --profile-includes
Workflow: start with --verbose to see what failed, add --dump-rendered to see the full content you produced, add --trace-templates (and optionally --profile-includes) to see where time is spent, and reach for --debug-filters only when sort behaviour itself is suspect.
Testing strategies¶
Test organization¶
Group tests by feature:
validationTests:
# Basic functionality
test-basic-http-routing:
description: HTTP routing for simple service
# TLS/SSL
test-tls-termination:
description: TLS termination with certificate
# Edge cases
test-empty-services:
description: Handle case with no backend services
Testing template errors¶
A negative test passes when the render fails as expected. Assert on the rendering_error target (see Assertion Targets) so the deliberate fail() is treated as the pass condition — without it, the failed render marks the whole test red:
test-no-services-error:
description: Should fail when no services exist
fixtures:
services: []
assertions:
- type: contains
target: rendering_error
pattern: "no services configured"
description: Render is rejected with the expected fail() message
rendering_error can't accept a resource read, ambiguous GetSingle, or typed conversion failure. Those failures always fail the test.
Testing auxiliary files¶
test-hostname-map:
description: Hostname map should contain all ingress hosts
fixtures:
ingresses:
- metadata:
name: main
spec:
rules:
- host: api.example.com
assertions:
- type: contains
target: map:hostnames.map
pattern: "api.example.com"
Best practices¶
- Test early: Add tests as you develop templates
- Keep tests fast: Use minimal fixtures
- Be descriptive: Name each test after the behaviour it checks and write the description as the requirement being verified
- Test edge cases: Empty inputs, many inputs, invalid data
# Good
test-ingress-tls-routing:
description: Ingress with TLS should create HTTPS frontend
# Bad
test1:
description: Test
Troubleshooting¶
| Problem | Solution |
|---|---|
| "haproxy: command not found" | Install HAProxy locally (the validator invokes haproxy -c on your PATH) |
| "template rendering failed" | Check for undefined variables, missing filters |
| Pattern not matching | Escape regex chars, check whitespace, use simpler patterns |
| JSONPath returns no results | Check the path; jsonpath reads scalar context values (for example extraContext keys), not the resource stores — assert on resources with contains / match_count |
Complete example¶
A full Ingress → Service routing config with its tests. Press Run live, then open the tests tab to watch every assertion evaluate:
See also¶
- Templating Guide - Template syntax
- Supported Configuration - HAProxy directives
- Troubleshooting - Common issues