Crossplane Composition Testing: Unit Testing XRDs and Integration Testing with LocalEnv
Crossplane extends Kubernetes to manage cloud infrastructure resources — AWS, GCP, Azure, and others — using Kubernetes custom resources. Crossplane Compositions define how high-level platform abstractions (like "a PostgreSQL database with backup enabled") translate into provider-specific resources (like an RDSInstance and an S3Bucket for backups).
Testing Crossplane compositions has historically been difficult because the composition logic is evaluated by the Crossplane controller running in a cluster. The emergence of Composition Functions, the crossplane beta render command, and testing utilities has changed this — you can now test composition logic without running Crossplane or provisioning cloud resources.
This guide covers the current state of Crossplane testing: what's testable, what tools exist, and practical testing patterns.
What You're Testing in Crossplane
A Crossplane composition takes a Claim (the high-level request) and produces a set of Managed Resources (the actual cloud resources to create). Testing verifies that:
- The composition renders correctly: Given a specific Claim, the expected Managed Resources are produced with the correct properties
- Policy compliance: Resources produced by compositions follow organizational policies (encryption, tagging, network configuration)
- Edge cases: Different combinations of Claim inputs produce the expected Managed Resource configurations
- Composition Functions: When using Function Pipelines, each function behaves correctly
crossplane beta render
The crossplane beta render command is the primary tool for testing composition rendering locally:
# Install the Crossplane CLI
curl -sL "https://raw.githubusercontent.com/crossplane/crossplane/main/install.sh" | sh
# Render a composition
crossplane beta render \
--composition composition.yaml \
--function-credentials credentials.yaml \
xr.yaml # The Composite Resource (or Claim)This produces the set of Managed Resources that the composition would create, without connecting to any Kubernetes cluster or cloud provider.
Example structure:
# xr.yaml - The Composite Resource request
apiVersion: platform.example.com/v1alpha1
kind: PostgreSQLInstance
metadata:
name: my-database
spec:
parameters:
storageGB: 20
version: "14"
region: us-east-1
tier: standard
compositionSelector:
matchLabels:
provider: aws# composition.yaml - The Composition definition
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: postgresql-aws
labels:
provider: aws
spec:
compositeTypeRef:
apiVersion: platform.example.com/v1alpha1
kind: PostgreSQLInstance
resources:
- name: rds-instance
base:
apiVersion: rds.aws.upbound.io/v1beta1
kind: Instance
spec:
forProvider:
engine: postgres
region: us-east-1
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.storageGB
toFieldPath: spec.forProvider.allocatedStorage
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.version
toFieldPath: spec.forProvider.engineVersion
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.region
toFieldPath: spec.forProvider.regionRunning crossplane beta render outputs the rendered Managed Resources. You can validate this output manually or pipe it through validation tools.
Automated Testing with Shell Scripts and kubectl
For simple compositions, shell-based tests using crossplane beta render and yq or jq provide quick validation:
#!/bin/bash
# test-postgresql-composition.sh
PASS=0
FAIL=0
assert_equal() {
local field="$1"
local expected="$2"
local actual="$3"
if [ "$actual" == "$expected" ]; then
echo "PASS: $field = $expected"
PASS=$((PASS+1))
else
echo "FAIL: $field expected '$expected', got '$actual'"
FAIL=$((FAIL+1))
fi
}
# Render the composition
OUTPUT=$(crossplane beta render \
--composition composition.yaml \
xr.yaml 2>/dev/null)
# Extract the RDS instance resource
RDS_INSTANCE=$(echo "$OUTPUT" | yq '. | select(.kind == "Instance")')
# Test: storage is set from parameters
STORAGE=$(echo "$RDS_INSTANCE" | yq '.spec.forProvider.allocatedStorage')
assert_equal "allocatedStorage" "20" "$STORAGE"
# Test: region is passed through
REGION=$(echo "$RDS_INSTANCE" | yq '.spec.forProvider.region')
assert_equal "region" "us-east-1" "$REGION"
# Test: encryption is enabled by default (policy requirement)
ENCRYPTED=$(echo "$RDS_INSTANCE" | yq '.spec.forProvider.storageEncrypted')
assert_equal "storageEncrypted" "true" "$ENCRYPTED"
# Test: backup retention is set
BACKUP=$(echo "$RDS_INSTANCE" | yq '.spec.forProvider.backupRetentionPeriod')
[ -n "$BACKUP" ] && [ "$BACKUP" != "null" ] && {
echo "PASS: backupRetentionPeriod is set to $BACKUP"
PASS=$((PASS+1))
} || {
echo "FAIL: backupRetentionPeriod is not set"
FAIL=$((FAIL+1))
}
echo ""
echo "Results: $PASS passed, $FAIL failed"
[ $FAIL -eq 0 ] || exit 1Testing Composition Functions with function-test
Composition Functions are Go programs (or other runtimes) that implement composition logic using the Composition Functions API. The function-test framework provides unit testing for these functions:
// function/fn_test.go
package main
import (
"testing"
"github.com/crossplane/crossplane-runtime/pkg/test"
fnv1beta1 "github.com/crossplane/function-sdk-go/proto/v1beta1"
"google.golang.org/protobuf/types/known/structpb"
)
func TestRunFunction(t *testing.T) {
cases := map[string]struct {
args RunFunctionArgs
want *fnv1beta1.RunFunctionResponse
}{
"StandardTier": {
args: RunFunctionArgs{
Request: &fnv1beta1.RunFunctionRequest{
Observed: &fnv1beta1.State{
Composite: &fnv1beta1.Resource{
Resource: resource(map[string]interface{}{
"spec": map[string]interface{}{
"parameters": map[string]interface{}{
"tier": "standard",
"region": "us-east-1",
},
},
}),
},
},
},
},
want: &fnv1beta1.RunFunctionResponse{
Desired: &fnv1beta1.State{
Resources: map[string]*fnv1beta1.Resource{
"rds-instance": {
Resource: resource(map[string]interface{}{
"spec": map[string]interface{}{
"forProvider": map[string]interface{}{
"instanceClass": "db.t3.micro",
"multiAZ": false,
},
},
}),
},
},
},
},
},
"PremiumTier": {
args: RunFunctionArgs{
Request: &fnv1beta1.RunFunctionRequest{
Observed: &fnv1beta1.State{
Composite: &fnv1beta1.Resource{
Resource: resource(map[string]interface{}{
"spec": map[string]interface{}{
"parameters": map[string]interface{}{
"tier": "premium",
"region": "us-east-1",
},
},
}),
},
},
},
},
want: &fnv1beta1.RunFunctionResponse{
Desired: &fnv1beta1.State{
Resources: map[string]*fnv1beta1.Resource{
"rds-instance": {
Resource: resource(map[string]interface{}{
"spec": map[string]interface{}{
"forProvider": map[string]interface{}{
"instanceClass": "db.r5.xlarge",
"multiAZ": true,
},
},
}),
},
},
},
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
f := &Function{log: logging.NewNopLogger()}
resp, err := f.RunFunction(context.Background(), tc.args.Request)
if err != nil {
t.Fatalf("RunFunction(...): unexpected error: %v", err)
}
// Compare desired state
if diff := cmp.Diff(tc.want, resp, cmpopts.IgnoreFields(fnv1beta1.RunFunctionResponse{}, "Meta")); diff != "" {
t.Errorf("RunFunction(...): -want, +got:\n%s", diff)
}
})
}
}Policy Testing with Kyverno
Kyverno policies can validate Crossplane Managed Resources before they're reconciled, applying organizational policies:
# kyverno-policy.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: crossplane-rds-encryption
spec:
validationFailureAction: enforce
rules:
- name: require-rds-encryption
match:
resources:
kinds:
- rds.aws.upbound.io/*/Instance
validate:
message: "RDS instances must have storage encryption enabled"
pattern:
spec:
forProvider:
storageEncrypted: true
- name: require-rds-backup
match:
resources:
kinds:
- rds.aws.upbound.io/*/Instance
validate:
message: "RDS instances must have backup retention >= 7 days"
deny:
conditions:
all:
- key: "{{ request.object.spec.forProvider.backupRetentionPeriod }}"
operator: LessThan
value: 7Test Kyverno policies against rendered output:
# Test the policy against rendered manifests
kyverno apply kyverno-policy.yaml \
--resource <(crossplane beta render --composition composition.yaml xr.yaml)Integration Testing with a Local Crossplane Environment
For integration testing that verifies Crossplane's actual reconciliation behavior, run a minimal local environment:
# Create kind cluster
kind create cluster --name crossplane-test
# Install Crossplane
helm install crossplane crossplane-stable/crossplane \
--namespace crossplane-system \
--create-namespace
# Install the provider (using provider-nop for testing without real cloud resources)
cat <<EOF | kubectl apply -f -
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-nop
spec:
package: xpkg.upbound.io/crossplane-contrib/provider-nop:v0.3.0
EOF
# Wait for provider to be healthy
kubectl wait --for=condition=Healthy provider/provider-nop --timeout=2mWith provider-nop, Crossplane simulates cloud resource management without making real API calls. Resources are created and marked as ready without provisioning anything.
# Apply your composition and XRD
kubectl apply -f xrd.yaml
kubectl apply -f composition.yaml
# Create a test claim
kubectl apply -f test-claim.yaml
# Wait for it to be ready
kubectl wait --for=condition=Ready postgresqlinstance/my-test-db --timeout=5m
# Verify the composition produced the expected managed resources
kubectl get instances.rds.aws.upbound.io
kubectl get buckets.s3.aws.upbound.io
# Clean up
kubectl delete -f test-claim.yamlCI/CD Integration
# GitLab CI pipeline for Crossplane
test-compositions:
stage: test
image: alpine:3.18
script:
- apk add --no-cache curl bash yq
- curl -sL "https://raw.githubusercontent.com/crossplane/crossplane/main/install.sh" | sh
- bash tests/test-postgresql-composition.sh
- bash tests/test-network-composition.sh
artifacts:
when: always
paths:
- test-results/
validate-with-kyverno:
stage: test
needs: [test-compositions]
image: ghcr.io/kyverno/kyverno-cli:latest
script:
- |
for COMPOSITION in compositions/*.yaml; do
for XR in test-xrs/*.yaml; do
crossplane beta render --composition $COMPOSITION $XR > /tmp/rendered.yaml
kyverno apply policies/ --resource /tmp/rendered.yaml
done
doneSchema Validation for XRDs
Validate that your XRD schema is well-formed and that example Claims validate against it:
# Validate XRD itself
kubectl apply --dry-run=client -f xrd.yaml
# If you have a running cluster with Crossplane:
kubectl apply --dry-run=server -f xrd.yaml
# Validate a claim against the XRD schema
# (requires kubeconform or similar JSON schema validator)
pip install check-jsonschema
# Extract schema from XRD
yq '.spec.versions[0].schema.openAPIV3Schema' xrd.yaml > schema.json
# Validate claim against schema
check-jsonschema --schemafile schema.json test-claims/postgresql-claim.yamlTesting Composition Patches
Patches are the most error-prone part of compositions — a mistyped field path silently produces incorrect Managed Resources. Test patches explicitly:
# test-xrs/test-all-patch-paths.yaml
apiVersion: platform.example.com/v1alpha1
kind: PostgreSQLInstance
metadata:
name: patch-test
spec:
parameters:
storageGB: 50 # Should patch allocatedStorage
version: "15" # Should patch engineVersion
region: eu-west-1 # Should patch region AND security group region
tier: premium # Should patch instanceClass AND enable multiAZ
backupRetention: 14 # Should patch backupRetentionPeriod# Render with all non-default values
crossplane beta render --composition composition.yaml test-xrs/test-all-patch-paths.yaml | \
yq '. | select(.kind == "Instance") | .spec.forProvider'
# Expected output verification
OUTPUT=$(crossplane beta render --composition composition.yaml test-xrs/test-all-patch-paths.yaml)
echo "--- Verifying patches ---"
echo "allocatedStorage: $(echo $OUTPUT | yq '. | select(.kind == "Instance") | .spec.forProvider.allocatedStorage')"
echo "engineVersion: $(echo $OUTPUT | yq '. | select(.kind == "Instance") | .spec.forProvider.engineVersion')"
echo "region: $(echo $OUTPUT | yq '. | select(.kind == "Instance") | .spec.forProvider.region')"
echo "instanceClass: $(echo $OUTPUT | yq '. | select(.kind == "Instance") | .spec.forProvider.instanceClass')"
echo "multiAZ: $(echo $OUTPUT | yq '. | select(.kind == "Instance") | .spec.forProvider.multiAZ')"Crossplane composition testing is evolving rapidly. The crossplane beta render command and Composition Functions have made local testing significantly more practical than it was even a year ago — and testing complex composition logic before deploying to a cluster is increasingly viable as these tools mature.