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 hereUnit 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.regionWrite 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 baseRun 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-xrCompare 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
EOFUsing 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), ¤t); 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 1CI 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=5mSummary
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.