Testing Crossplane Providers: Claims, Composite Resources, and E2E Validation

Testing Crossplane Providers: Claims, Composite Resources, and E2E Validation

Crossplane lets you build a platform API on top of Kubernetes — your developers claim a PostgreSQLInstance, your platform delivers an RDS instance. But testing that pipeline, from XRD to provider to real cloud resource, requires a different approach than testing regular Kubernetes operators.

This post covers Crossplane provider testing: unit tests for compositions, E2E tests for the full provisioning pipeline, and strategies for testing claims without incurring cloud costs.

The Crossplane Testing Layers

Crossplane's architecture has several layers, each requiring different test strategies:

Developer Claim (PostgreSQLInstance)
    ↓
Composite Resource (XPostgreSQLInstance)  ← test compositions here
    ↓
Managed Resources (aws_rds_instance)      ← test provider behavior here
    ↓
Real AWS/GCP/Azure resource               ← E2E test here

Unit Testing Compositions

The crossplane-test CLI (formerly composition-test) lets you test XRD compositions by providing mock inputs and asserting on outputs, without needing a running cluster.

Installation

# Install via Homebrew
brew install crossplane/tap/crossplane

# Or download directly
curl -Lo crossplane https://releases.crossplane.io/stable/current/bin/linux_amd64/crank
chmod +x crossplane && mv crossplane /usr/local/bin/

Writing Composition Tests

Given this composition:

# apis/postgresql/composition.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: postgresql-aws
spec:
  compositeTypeRef:
    apiVersion: platform.example.com/v1alpha1
    kind: XPostgreSQLInstance
  resources:
    - name: rds-instance
      base:
        apiVersion: rds.aws.upbound.io/v1beta1
        kind: Instance
        spec:
          forProvider:
            region: us-east-1
            engine: postgres
            engineVersion: "15.4"
            instanceClass: db.t3.micro
            allocatedStorage: 20
            skipFinalSnapshot: false
      patches:
        - type: FromCompositeFieldPath
          fromFieldPath: spec.parameters.instanceClass
          toFieldPath: spec.forProvider.instanceClass
        - type: FromCompositeFieldPath
          fromFieldPath: spec.parameters.storageGB
          toFieldPath: spec.forProvider.allocatedStorage
        - type: FromCompositeFieldPath
          fromFieldPath: spec.parameters.region
          toFieldPath: spec.forProvider.region

Write a test case:

# tests/postgresql/composition-test.yaml
apiVersion: apiextensions.crossplane.io/v1alpha1
kind: CompositionTest
metadata:
  name: postgresql-basic
spec:
  compositionPath: ../../apis/postgresql/composition.yaml
  
  xrTemplate:
    apiVersion: platform.example.com/v1alpha1
    kind: XPostgreSQLInstance
    metadata:
      name: test-db
    spec:
      parameters:
        instanceClass: db.r6g.large
        storageGB: 100
        region: eu-west-1
      compositionSelector:
        matchLabels:
          provider: aws
  
  expectedComposed:
    - apiVersion: rds.aws.upbound.io/v1beta1
      kind: Instance
      metadata:
        labels:
          crossplane.io/composite: test-db
      spec:
        forProvider:
          instanceClass: db.r6g.large    # patched from claim
          allocatedStorage: 100           # patched from claim
          region: eu-west-1               # patched from claim
          engine: postgres               # from base
          engineVersion: "15.4"          # from base

Run it:

crossplane beta render \
  apis/postgresql/xr.yaml \
  apis/postgresql/composition.yaml \
  tests/postgresql/functions.yaml \
  | yq 'select(.kind == "Instance")'

Testing Functions (Composition Functions)

If you use Composition Functions (the newer approach), test them with the crossplane beta render command:

# Render the full composition pipeline
crossplane beta render \
  tests/postgresql/xr.yaml \
  apis/postgresql/composition.yaml \
  apis/postgresql/functions.yaml \
  --include-full-xr

Compare outputs in CI:

#!/bin/bash
# test-compositions.sh

EXPECTED_DIR="tests/expected"
ACTUAL_DIR="/tmp/actual"

mkdir -p $ACTUAL_DIR

for xr_file in tests/*/xr.yaml; do
  name=$(dirname $xr_file | xargs basename)
  
  crossplane beta render \
    $xr_file \
    "apis/$name/composition.yaml" \
    "apis/$name/functions.yaml" \
    > "$ACTUAL_DIR/$name.yaml"
  
  if diff -u "$EXPECTED_DIR/$name.yaml" "$ACTUAL_DIR/$name.yaml"; then
    echo "✅ $name: outputs match"
  else
    echo "❌ $name: output mismatch"
    FAILED=1
  fi
done

exit ${FAILED:-0}

Testing with a Local Kind Cluster

For integration tests that need a real Kubernetes API server (but not real cloud resources), use kind with provider mocks.

# Start kind cluster
kind create cluster --name crossplane-test

# Install Crossplane
helm repo add crossplane-stable https://charts.crossplane.io/stable
helm install crossplane \
  crossplane-stable/crossplane \
  --namespace crossplane-system \
  --create-namespace \
  --wait

# Install the AWS provider
kubectl apply -f - <<EOF
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-rds
spec:
  package: xpkg.upbound.io/upbound/provider-aws-rds:v1
EOF

Using ProviderConfig with Mock Credentials

For local testing, use a ProviderConfig that points to LocalStack or a mock endpoint:

# test/localstack-providerconfig.yaml
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
  name: localstack
spec:
  endpoint:
    url: http://localstack.localstack-service.svc.cluster.local:4566
    hostnameImmutable: true
    services:
      - rds
      - ec2
      - iam
  credentials:
    source: Secret
    secretRef:
      namespace: crossplane-system
      name: aws-mock-credentials
      key: credentials
# Create mock credentials
kubectl create secret generic aws-mock-credentials \
  -n crossplane-system \
  --from-literal=credentials="[default]
aws_access_key_id = test
aws_secret_access_key = test"

E2E Testing with Real Cloud Resources

For the full end-to-end pipeline, use a test cluster pointing at real AWS credentials (in a test account):

// e2e/postgresql_test.go
package e2e

import (
    "context"
    "testing"
    "time"
    
    "github.com/stretchr/testify/require"
    corev1 "k8s.io/api/core/v1"
    "k8s.io/apimachinery/pkg/api/errors"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    "k8s.io/apimachinery/pkg/runtime/schema"
    "sigs.k8s.io/controller-runtime/pkg/client"
)

var postgresGVR = schema.GroupVersionResource{
    Group:    "platform.example.com",
    Version:  "v1alpha1",
    Resource: "postgresqlinstances",
}

func TestPostgreSQLClaim(t *testing.T) {
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
    defer cancel()
    
    cl := getTestClient(t)
    
    // Create the claim
    claim := &unstructured.Unstructured{
        Object: map[string]interface{}{
            "apiVersion": "platform.example.com/v1alpha1",
            "kind":       "PostgreSQLInstance",
            "metadata": map[string]interface{}{
                "name":      "e2e-test-db",
                "namespace": "test-namespace",
            },
            "spec": map[string]interface{}{
                "parameters": map[string]interface{}{
                    "instanceClass": "db.t3.micro",
                    "storageGB":     20,
                    "region":        "us-east-2",
                },
                "writeConnectionSecretToRef": map[string]interface{}{
                    "name": "e2e-db-connection",
                },
            },
        },
    }
    
    err := cl.Create(ctx, claim)
    require.NoError(t, err)
    
    t.Cleanup(func() {
        _ = cl.Delete(context.Background(), claim)
    })
    
    // Wait for claim to become ready
    require.Eventually(t, func() bool {
        var current unstructured.Unstructured
        current.SetGroupVersionKind(claim.GroupVersionKind())
        
        if err := cl.Get(ctx, client.ObjectKeyFromObject(claim), &current); err != nil {
            return false
        }
        
        conditions, found, _ := unstructured.NestedSlice(current.Object, "status", "conditions")
        if !found {
            return false
        }
        
        for _, c := range conditions {
            condition := c.(map[string]interface{})
            if condition["type"] == "Ready" && condition["status"] == "True" {
                return true
            }
        }
        return false
    }, 10*time.Minute, 15*time.Second, "claim never became ready")
    
    // Verify connection secret was created
    secret := &corev1.Secret{}
    err = cl.Get(ctx, client.ObjectKey{
        Namespace: "test-namespace",
        Name:      "e2e-db-connection",
    }, secret)
    require.NoError(t, err)
    
    require.Contains(t, secret.Data, "endpoint")
    require.Contains(t, secret.Data, "port")
    require.Contains(t, secret.Data, "username")
    require.Contains(t, secret.Data, "password")
    
    // Verify the endpoint is reachable
    endpoint := string(secret.Data["endpoint"])
    require.NotEmpty(t, endpoint)
}

Testing Provider Health

Providers themselves can fail to install, reconcile slowly, or hit rate limits. Test provider health before running claim tests:

#!/bin/bash
# test-provider-health.sh

echo "Waiting for Crossplane providers to become healthy..."

TIMEOUT=180
ELAPSED=0

while [ $ELAPSED -lt $TIMEOUT ]; do
    UNHEALTHY=$(kubectl get providers.pkg.crossplane.io \
        -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.conditions[?(@.type=="Healthy")].status}{"\n"}{end}' \
        | grep -v "True" | wc -l)
    
    if [ $UNHEALTHY -eq 0 ]; then
        echo "✅ All providers healthy"
        exit 0
    fi
    
    echo "Waiting... $UNHEALTHY providers not yet healthy ($ELAPSED/${TIMEOUT}s)"
    sleep 10
    ELAPSED=$((ELAPSED + 10))
done

echo "❌ Provider health check timed out"
kubectl get providers.pkg.crossplane.io -o wide
exit 1

CI Pipeline for Crossplane Tests

# .github/workflows/crossplane-tests.yml
name: Crossplane Tests

on:
  pull_request:
    paths: ['apis/**', 'functions/**', 'tests/**']

jobs:
  composition-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Install crossplane CLI
        run: |
          curl -Lo crossplane.tar.gz https://releases.crossplane.io/stable/v1.15.0/bin/linux_amd64/crank.tar.gz
          tar xf crossplane.tar.gz
          sudo mv crank /usr/local/bin/crossplane
      
      - name: Run composition render tests
        run: ./scripts/test-compositions.sh
      
      - name: Validate XRD schemas
        run: |
          for xrd in apis/*/xrd.yaml; do
            crossplane beta validate --cache-dir /tmp/schema-cache $xrd
          done
  
  e2e-tests:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    needs: composition-tests
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Configure AWS credentials (test account)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.TEST_ACCOUNT_ROLE_ARN }}
          aws-region: us-east-2
      
      - name: Set up kubeconfig
        run: aws eks update-kubeconfig --name crossplane-test-cluster
      
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      
      - name: Run E2E tests
        run: go test ./e2e/... -v -timeout 20m
        env:
          KUBECONFIG: ${{ env.KUBECONFIG }}

Testing Composition Updates Safely

Updating a composition affects all existing composite resources. Test before applying:

#!/bin/bash
# test-composition-update.sh

# 1. Dry-run the updated composition
kubectl apply --dry-run=server -f apis/postgresql/composition.yaml

# 2. Check how many XRs would be affected
kubectl get xpostgresqlinstances.platform.example.com \
  --all-namespaces \
  -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\n"}{end}' \
  | wc -l

# 3. Apply to test namespace first
kubectl apply -f apis/postgresql/composition.yaml \
  --namespace=platform-test

# 4. Trigger reconciliation of test XR
kubectl annotate xpostgresqlinstance test-db \
  crossplane.io/paused=false --overwrite

# 5. Wait for test XR to reconcile
kubectl wait xpostgresqlinstance test-db \
  --for=condition=Ready \
  --timeout=5m

Summary

Testing Crossplane requires multiple layers:

Test Type Tool Speed Cloud Cost
Composition render crossplane beta render Seconds None
Schema validation crossplane beta validate Seconds None
Integration (LocalStack) kind + LocalStack Minutes None
E2E (real resources) Go tests + real cluster 10-20 min Low (test account)

Start with render tests for every composition change — they're instant and catch most errors. Add E2E tests in a dedicated test account for the critical provisioning paths. And always test composition updates in a staging environment before applying to production compositions.

When Crossplane provisions database infrastructure for your application, HelpMeTest can verify that the application connected and operates correctly after provisioning — closing the loop from infrastructure claim to working application.

Read more

Start now free