Testing¶
This guide covers testing standards and practices for the Cloudflare Tunnel Gateway Controller.
Running Tests¶
Unit Tests¶
# Run all tests
go test -v ./...
# Run with race detector
go test -race ./...
# Run specific package
go test -v -race ./internal/controller/...
# Run specific test
go test -v -race ./internal/controller/... -run TestHTTPRouteReconciler
Coverage¶
# Generate coverage report
go test -coverprofile=coverage.out ./...
# View coverage in browser
go tool cover -html=coverage.out
# View coverage in terminal
go tool cover -func=coverage.out
Helm Chart Tests¶
CI is pinned to Helm v4.2.0. The helm-unittest plugin install below uses Helm 4 syntax (--verify=false); the test run and chart install themselves work on Helm 3+ as well.
# Install helm-unittest plugin (matches CI).
# helm-unittest installs from a .git source, which Helm 4's installer
# cannot verify — signature verification only applies to .tgz archives.
# Helm 4 defaults plugin install to --verify=true, so a .git source
# hard-errors unless you opt out. Plugin verification (and the --verify
# flag) did not exist before Helm 4.
helm plugin install https://github.com/helm-unittest/helm-unittest.git --verify=false
# Run chart tests
helm unittest charts/cloudflare-tunnel-gateway-controller
# Lint chart
helm lint charts/cloudflare-tunnel-gateway-controller
# Template locally (for debugging)
helm template test charts/cloudflare-tunnel-gateway-controller \
--values charts/cloudflare-tunnel-gateway-controller/examples/basic-values.yaml
Test Patterns¶
Table-Driven Tests¶
Use table-driven tests with named test cases:
func TestFeature(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input InputType
expected OutputType
wantErr bool
}{
{
name: "valid input",
input: InputType{...},
expected: OutputType{...},
wantErr: false,
},
{
name: "invalid input",
input: InputType{...},
expected: OutputType{},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result, err := DoSomething(tt.input)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
Parallel Execution¶
Always use t.Parallel() at test and subtest level:
func TestSomething(t *testing.T) {
t.Parallel() // Mark test as parallel
t.Run("subtest", func(t *testing.T) {
t.Parallel() // Mark subtest as parallel
// ...
})
}
Fake Client Setup¶
Use controller-runtime fake client for unit tests:
func TestController(t *testing.T) {
// Create scheme with all required types
scheme := runtime.NewScheme()
_ = clientgoscheme.AddToScheme(scheme)
_ = gatewayv1.Install(scheme)
// Create fake client with initial objects
client := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(
&gatewayv1.Gateway{...},
&gatewayv1.HTTPRoute{...},
).
Build()
// Create reconciler with fake client
reconciler := &HTTPRouteReconciler{
Client: client,
Scheme: scheme,
}
// Test reconciliation
result, err := reconciler.Reconcile(ctx, ctrl.Request{...})
require.NoError(t, err)
}
Test Libraries¶
| Library | Usage |
|---|---|
github.com/stretchr/testify/assert | Soft assertions (test continues) |
github.com/stretchr/testify/require | Hard assertions (test stops) |
sigs.k8s.io/controller-runtime/pkg/client/fake | Fake Kubernetes client |
sigs.k8s.io/controller-runtime/pkg/envtest | Integration tests |
Assert vs Require¶
// Use require for setup and critical checks (stops test on failure)
require.NoError(t, err, "setup should succeed")
// Use assert for multiple checks (test continues)
assert.Equal(t, expected.Name, actual.Name)
assert.Equal(t, expected.Port, actual.Port)
Test Organization¶
File Naming¶
| Pattern | Description |
|---|---|
*_test.go | Test files (standard Go convention) |
*_internal_test.go | Tests for unexported functions (same package) |
Test Helpers¶
Extract common setup into helper functions:
func setupFakeClient(t *testing.T, objs ...client.Object) client.Client {
t.Helper()
scheme := runtime.NewScheme()
require.NoError(t, clientgoscheme.AddToScheme(scheme))
require.NoError(t, gatewayv1.Install(scheme))
return fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(objs...).
Build()
}
What to Test¶
Unit Tests¶
- Business logic functions
- Input validation
- Error handling paths
- Edge cases (empty inputs, nil values)
Integration Tests¶
- Controller reconciliation loops
- Kubernetes API interactions
- Cloudflare API interactions (mocked)
Not Tested¶
- Generated code (CRD types, mocks)
- Third-party library internals
Mocking¶
External Services¶
Mock external services (Cloudflare API) in unit tests:
type mockCloudflareClient struct {
tunnelConfig *cloudflare.TunnelConfiguration
err error
}
func (m *mockCloudflareClient) UpdateTunnelConfiguration(
ctx context.Context,
config cloudflare.TunnelConfiguration,
) error {
m.tunnelConfig = &config
return m.err
}
Time-Dependent Tests¶
Use injectable time for deterministic tests:
type Clock interface {
Now() time.Time
}
// In production
type RealClock struct{}
func (RealClock) Now() time.Time { return time.Now() }
// In tests
type FakeClock struct {
CurrentTime time.Time
}
func (c FakeClock) Now() time.Time { return c.CurrentTime }
CI Integration¶
Tests run automatically in CI:
# .github/workflows/pr.yaml
- name: Run tests
run: go test -v -race -tags=envtest -coverprofile=coverage.out -covermode=atomic ./...
- name: Upload coverage
uses: codecov/codecov-action@v6
with:
files: coverage.out
Gateway API Conformance Tests¶
Conformance tests validate that the controller implements the Gateway API specification correctly. These tests require a real Kubernetes cluster with a working Cloudflare Tunnel.
Prerequisites¶
- Kubernetes cluster (kind, k3s, or real cluster)
- Controller installed and running
- Cloudflare Tunnel configured and working
- GatewayClass
cloudflare-tunnelcreated
hack/conformance-setup.sh builds that cluster for you. It needs docker, kind, helm, kubectl and go; on macOS it also needs colima, which is where the docker daemon comes from there. Other hosts use their native daemon and are not asked for it.
Deploying a Pull Request's CI Build¶
hack/conformance-setup.sh --use-ci-images <PR> deploys what that PR's CI already built instead of building locally. It additionally needs gh (authenticated for this repository) and jq.
The chart comes from the CI run's own artifacts and the images are pulled by the digest that run recorded, so nothing on this path is addressed by a tag. That matters because CI also publishes to ttl.sh, an anonymous registry where the PR tag is writable by anyone, and the cluster this script builds holds real Cloudflare credentials.
A digest binds content, not trust. On a fork PR the artifacts are produced by the fork's own copy of pr.yaml, so the recorded digest is exactly as trustworthy as the PR author. Read the fork's diff before any un-sandboxed run that holds real credentials, the same as before.
The run is chosen by the PR's current head commit, and only a successful pull_request run for that exact commit is accepted — a run for an earlier head is refused rather than silently deployed. hack/verify-ci-bundle.sh then checks the downloaded artifacts before anything reaches the cluster: both image references must be digest-pinned, the recorded commit must match the head being verified, and the chart must carry that PR's version. Any mismatch aborts before the cluster is created.
Two independent clocks limit how long a PR stays deployable this way. The chart and the recorded digests are GitHub Actions artifacts, kept for one day. The images live on ttl.sh, which derives a tag's lifetime from the tag itself and only when the whole tag is a bare duration; pr-<N>-1d is not, so those images expire on whatever default the service applies, which this repository does not control. The artifacts outliving the images would not help either way, because controller.ref records a digest rather than the image: once ttl.sh drops the blobs, the reference points at nothing. Whichever runs out first, the fix is the same: re-run the PR's CI.
Running E2E Tests¶
E2E tests run against a live kind cluster with Cloudflare Tunnel and L7 proxy deployed. E2E_TUNNEL_HOSTNAME is required — the suite fails fast without it. hack/conformance-setup.sh threads it automatically from .env or the exported environment (CF_TUNNEL_HOSTNAME); set it explicitly when running go test by hand.
# Run all E2E tests
E2E_TUNNEL_HOSTNAME=<your-tunnel-hostname> \
go test -v -race -tags e2e -count=1 -timeout=15m ./test/e2e/...
# Run a single test
E2E_TUNNEL_HOSTNAME=<your-tunnel-hostname> \
go test -v -race -tags e2e -count=1 -timeout=15m ./test/e2e/... \
-run TestHTTPRouteConformance/Core/HTTPRouteSimpleSameNamespace
E2E Environment Variables¶
| Variable | Fallback | Default | Description |
|---|---|---|---|
E2E_TUNNEL_HOSTNAME | CONFORMANCE_TUNNEL_HOSTNAME | none (required) | Edge hostname routing to the test tunnel; see .env.example |
E2E_KUBE_CONTEXT | CONFORMANCE_KUBE_CONTEXT | kind-v2-test | kubectl context |
E2E_NAMESPACE | CONFORMANCE_NAMESPACE | cloudflare-tunnel-system | Controller namespace |
E2E_TEST_NAMESPACE | CONFORMANCE_TEST_NAMESPACE | e2e-test | Test resources namespace |
E2E_GATEWAY_NAME | CONFORMANCE_GATEWAY_NAME | e2e-gateway | Gateway resource name |
E2E_SKIP_CLEANUP_ON_FAILURE | (none) | unset | When non-empty, retains test resources (HTTPRoutes, Services) after a failed test for post-mortem kubectl inspection. CI leaves it unset so resources never accumulate. Caveat: pair this with -run TestName/SubtestName to isolate the failing case. The cleanup helpers wipe the entire test namespace, so in a full-suite run a passing sibling that comes after the failing subtest will delete its retained state -- only the last failing subtest after the final passing sibling actually survives. Retention is also scoped to a single go test run; the initial wipeAllRoutesInNamespace at the start of TestHTTPRouteConformance wipes leftover state from a previous invocation, so kubectl-inspect happens between runs, not across them. |
E2E Test Coverage (25 tests)¶
Tests cover both Cloudflare Tunnel and L7 proxy features:
- Core (4): SimpleSameNamespace, PathPrefixMatching, ExactPathMatching, MatchingAcrossRoutes
- Extended (19): HeaderMatching, MethodMatching, QueryParamMatching, Weight, RequestHeaderModifier, ResponseHeaderModifier, RequestRedirect, RegexPathMatching, RegexHeaderMatching, RegexQueryParamMatching, PathMatchOrder, URLRewritePath, URLRewriteHost, RequestMirror, RedirectPort, RedirectPath, RedirectSchemeProbe, CombinedMatching, MultipleMatchesOR
- Gateway (2): AcceptedCondition, ObservedGenerationBump
Official Gateway API Conformance Suite¶
The project integrates the official sigs.k8s.io/gateway-api/conformance suite with a custom TunnelRoundTripper that routes requests through Cloudflare edge.
CONFORMANCE_TUNNEL_HOSTNAME is required — the suite fails fast without it. hack/conformance-setup.sh threads it automatically from .env or the exported environment (CF_TUNNEL_HOSTNAME); set it explicitly when running go test by hand.
The deployment under test must also set proxy.allowXOriginalHost=true (hack/conformance-setup.sh does). The suite's domains are not registered on the Cloudflare account, so its round-tripper sends the intended Host in X-Original-Host while addressing the edge hostname; the proxy strips that header by default because a client can set it freely, and trusting it would let a request pick which configured hostname serves it. Against a deployment without the flag every hostname-matching test fails — that is the gate working.
# Run conformance tests (requires deployed controller + tunnel)
CONFORMANCE_TUNNEL_HOSTNAME=<your-tunnel-hostname> \
go test -v -tags conformance -count=1 -timeout=30m ./test/conformance/...
# Generate conformance report
CONFORMANCE_TUNNEL_HOSTNAME=<your-tunnel-hostname> \
CONFORMANCE_REPORT_OUTPUT=./conformance-report.yaml \
go test -v -tags conformance -count=1 -timeout=30m ./test/conformance/...
| Variable | Default | Description |
|---|---|---|
CONFORMANCE_TUNNEL_HOSTNAME | none (required) | Edge hostname routing to the test tunnel; see .env.example |
CONFORMANCE_GATEWAY_CLASS | cloudflare-tunnel | GatewayClass name |
CONFORMANCE_REPORT_OUTPUT | (none) | Path for YAML conformance report |
CONTROLLER_VERSION | dev | Version for report metadata |
Profiles: GATEWAY-HTTP, GATEWAY-GRPC.
Live-Tunnel Coverage Matrix¶
What actually gets exercised against a real Cloudflare Tunnel, and by which suite. "Conformance" is the official Gateway API suite (test/conformance, both profiles); "e2e" is the custom suite (test/e2e). Features marked unit-only are the deliberate residue — each carries a reason.
| Feature | Live coverage | Notes |
|---|---|---|
| HTTPRoute core + extended matching (path/header/query/method, regex, ordering) | conformance + e2e | e2e re-checks through the real edge hostname (no X-Original-Host rewrite) |
| Filters: header modifiers, redirects (301/302/303/307/308, port/scheme/path), rewrites, mirrors (multiple, percentage) | conformance + e2e | |
| CORS filter | conformance | SupportHTTPRouteCORS |
| Timeouts (request / backendRequest) | conformance | explicit-0s disable semantics pinned by unit tests |
| Weighted traffic splitting | conformance + e2e | e2e asserts a deliberately wide proportion bound (test/e2e/e2e_test.go) — weighted selection is binomial sampling, so tight bounds flake by variance alone |
| Service types: ClusterIP, headless, ExternalName | conformance (HTTPRouteServiceTypes) | headless targetPort resolution covered |
| BackendTLSPolicy (CA ConfigMap, SNI hostname, DNS + URI SANs) — HTTP path | conformance | SupportBackendTLSPolicy + SANValidation |
| BackendTLSPolicy — gRPC path | e2e (TestGRPCRouteOverTLSBackend) | the upstream suite has no BackendTLSPolicy-over-GRPCRoute conformance test |
| Gateway client certificate (backend mTLS) | conformance | multi-parent edge case unit-only (documented in limitations) |
| GRPCRoute matching + header modifiers through the tunnel transport | e2e (TestGRPCRouteEndToEnd) | conformance gRPC tests dial the Cloudflare edge via the injectable client; the e2e adds the production pattern — a real registered hostname with no X-Original-Host header |
| WebSocket upgrade through the tunnel (+ response filters) | conformance + e2e | ws cleartext; wss (TLS WebSocket backend) is unit-only, see below |
appProtocol semantics: kubernetes.io/h2c | conformance | |
appProtocol TLS hint without BackendTLSPolicy (fail-closed 502) | e2e (TestBackendAppProtocolTLSWithoutPolicyFailsClosed) | spec SHOULD: never silently dial cleartext |
| ExternalBackend CRD (direct-dial URL, base path) | e2e (TestExternalBackendEndToEnd) | proxy dials the URL directly, no Service resolution |
| ListenerSet (attach, conditions, AttachedRoutes, routing) | conformance + e2e | |
| ReferenceGrant (cross-namespace backends) | conformance | |
| Gateway / route status conditions, observedGeneration | conformance + e2e | |
ListenerSet allowedListeners Selector delegation | e2e (TestListenerSetSelectorDelegation) | matching namespace accepted, non-matching rejected NotAllowed |
| Hostname-ownership ValidatingAdmissionPolicy | e2e (TestHostnameOwnershipPolicyEndToEnd) | chart-rendered artifact; shared vector table with the controller-layer unit tests |
| Hostname-ownership controller layer | unit (internal/hostnameownership, route_syncer_ownership_test.go) | same vector table as the admission e2e — drift guard |
Proxy data-plane metrics (/metrics, merged cloudflared exposition) | e2e (TestProxyMetricsEndpoint) | counters asserted after live tunnel traffic |
| Per-Gateway data plane (render, Programmed gating, traffic, GC) | e2e (TestPerGatewayDataPlaneEndToEnd) | reuses the suite tunnel (same-tunnel union path); distinct-tunnel isolation is unit-tested (route_syncer_partition_sync_test.go) |
Route shadow condition (cf.k8s.lex.la/RouteShadowed) | unit (shadow_test.go, route_status_shadowed_test.go) | deterministic detection over the flattened config; no live signal beyond status writes |
| Graceful connector drain on SIGTERM | unit (grace_internal_test.go, main_test.go) | needs connector-level fault injection for a live check; drain plumbing pinned by unit contracts |
Unit-only by design (each needs infrastructure a kind cluster does not have, or is not data-plane behaviour):
- ServiceImport (MCS) — kind has no
multicluster.x-k8s.ioAPI orclusterset.localDNS; resolution logic is unit-tested (serviceimport_builder_test.go). Revisit if a multi-cluster test rig ever exists. wssbackends (TLS WebSocket) — needs a TLS-terminating WebSocket echo; the transport decision (policy → TLS, ALPN) is shared with the tested HTTPS path, so the marginal live value is low.- GatewayClass finalizer, status writers, sync skip internals — control-plane behaviour with no data-plane signal; envtest/unit cover them, and a wrongly-skipped push would fail the routing e2e immediately.
When adding a user-visible feature, add its row here and decide the live-coverage story explicitly — an empty cell is a decision, not an oversight.
Best Practices¶
- Fast tests: Unit tests should run in milliseconds
- Isolated tests: No shared state between tests
- Deterministic tests: Same input = same output
- Readable tests: Test name describes behavior
- Minimal mocking: Only mock what's necessary
- Error testing: Test error paths, not just happy paths