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¶
# Install helm-unittest plugin
helm plugin install https://github.com/helm-unittest/helm-unittest
# 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
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.
# 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.
Known conformance flakes¶
A few conformance subtests are statistical by nature and occasionally need the suite's built-in retry budget to pass over the real Cloudflare Tunnel. These are sampling-variance non-regressions, not implementation failures — a retry of the named subtest passes. They are listed here so a failed individual attempt in the logs is not mistaken for a regression.
HTTPRouteRequestPercentageMirror— the subtest distributes traffic and asserts the observed mirror rate lands in an 85–115% band over a 500-request sample. Over the tunnel the sampled rate occasionally lands just outside (78% and 116% observed on individual attempts) before passing within the suite's retry budget. The tolerance, sample size, and retry count are hardcoded in the upstream suite (vendor/sigs.k8s.io/gateway-api/conformance/tests/httproute-request-percentage-mirror.go), so they cannot be widened locally without forking official conformance — the flake is documented rather than tuned. Reported upstream as kubernetes-sigs/gateway-api#4933: the relative ±15% band is only ~1.7σ on the 20% subcase, so even a perfectly conforming implementation flakes ~8–9% per distribution-check by binomial sampling alone.HTTPRouteRequestMirror— theRequestMirrorfilter dispatches a fire-and-forget mirror asynchronously, so the mirrored request occasionally arrives shortly after the test's poll window closes. The subtest passes on retry.
The same sampling-variance reasoning is applied inline for weighted backend selection in the custom e2e suite (test/e2e/e2e_test.go), where a deliberately wide proportion bound avoids reintroducing the flake from the opposite tail.
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 | percentage-mirror flake is suite-side, documented upstream |
| CORS filter | conformance | SupportHTTPRouteCORS |
| Timeouts (request / backendRequest) | conformance | explicit-0s disable semantics pinned by unit tests |
| Weighted traffic splitting | conformance + e2e | |
| 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) | official gRPC client cannot dial through the tunnel |
| 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) | official suite's gRPC dialer cannot reach *.cfargotunnel.com; suite-side tests run where possible |
| 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 |
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