Skip to content

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-tunnel created

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.io API or clusterset.local DNS; resolution logic is unit-tested (serviceimport_builder_test.go). Revisit if a multi-cluster test rig ever exists.
  • wss backends (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

  1. Fast tests: Unit tests should run in milliseconds
  2. Isolated tests: No shared state between tests
  3. Deterministic tests: Same input = same output
  4. Readable tests: Test name describes behavior
  5. Minimal mocking: Only mock what's necessary
  6. Error testing: Test error paths, not just happy paths