Testing Crossplane Compositions: Validate Infrastructure Claims Before Applying
Crossplane Compositions are Kubernetes-native infrastructure templates that expand a simple XR claim into tens of managed resources. A bug in a Composition can delete a production RDS instance when someone creates a new database claim. This guide covers how to test Composition logic with crossplane beta render, unit-test patch-and-transform functions, and run integration tests against real cloud providers safely.
Key Takeaways
crossplane beta render is your composition unit test. It runs the full Composition pipeline locally — including function calls — and outputs what managed resources would be created, without touching a real API. This is fast enough to run on every commit.
Test every patch source and destination. Crossplane patches can fail silently if a source field doesn't exist on the claim. Test with both complete and minimal XR payloads to find patches that assume non-required fields.
uptest runs integration tests against real providers. It creates real cloud resources, asserts on their readiness state, and cleans up. Use it in nightly CI runs with a dedicated test account, not in PR pipelines.
Composition revisions require migration tests. When you publish a new CompositionRevision, existing claims migrate automatically. Test that the new revision produces equivalent output for existing claim payloads before releasing it.
Function-patch-and-transform logic is Go code — unit test it as Go. If you write custom Composition Functions in Go, they're testable with standard Go testing. Don't wait for render to catch function bugs.
Why Crossplane Compositions Need Testing
A Crossplane Composition maps an abstract XR claim (like XDatabase) to concrete managed resources (RDSInstance, DBSubnetGroup, SecurityGroup). The mapping is powerful — but mistakes are costly:
- A wrong
toFieldPathsilently sets the wrong value on a managed resource. - A missing
fromFieldPathcauses the managed resource to be created with an empty field, which might pass validation but fail at the cloud provider. - A
deletepolicy misconfiguration causes a managed resource to be deleted when the claim is deleted — taking production data with it.
Crossplane has no built-in dry-run for compositions. Until crossplane beta render was added in v1.14, the only way to test was to apply to a live cluster and watch what happened.
Project Layout for Testable Compositions
Organize your compositions for testability from the start:
compositions/
database/
composition.yaml # The Composition definition
xrd.yaml # XRD (XDatabaseRequirement)
examples/
minimal-claim.yaml # XR with only required fields
full-claim.yaml # XR with all optional fields set
tests/
render/
minimal-expected.yaml # Expected managed resources for minimal claim
full-expected.yaml # Expected managed resources for full claim
uptest/
00-create.yaml # uptest: create the claim
01-assert.yaml # uptest: assert managed resource state
02-teardown.yaml # uptest: delete the claim
functions/
patch-and-transform/ # if using composition functions
main.go
main_test.gocrossplane beta render — Unit Testing Compositions
crossplane beta render simulates what a Composition would produce given an XR claim, without requiring a real cluster.
Installation
# Install the Crossplane CLI
curl -sL "https://raw.githubusercontent.com/crossplane/crossplane/main/install.sh" | sh
sudo mv crossplane /usr/local/bin/
# Verify
crossplane --versionWriting a Render Test
Given this minimal XR claim:
# compositions/database/examples/minimal-claim.yaml
apiVersion: platform.example.com/v1alpha1
kind: XDatabase
metadata:
name: payments-db
spec:
parameters:
engine: postgres
engineVersion: "15.4"
instanceClass: db.t3.micro
storageGB: 20
compositionRef:
name: database-aws
writeConnectionSecretToRef:
namespace: crossplane-system
name: payments-db-connAnd this Composition:
# compositions/database/composition.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: database-aws
spec:
compositeTypeRef:
apiVersion: platform.example.com/v1alpha1
kind: XDatabase
mode: Pipeline
pipeline:
- step: patch-and-transform
functionRef:
name: function-patch-and-transform
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: rds-instance
base:
apiVersion: rds.aws.upbound.io/v1beta1
kind: Instance
spec:
forProvider:
region: us-east-1
skipFinalSnapshot: true
deletionProtection: 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.engine
toFieldPath: spec.forProvider.engine
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.engineVersion
toFieldPath: spec.forProvider.engineVersion
- type: ToCompositeFieldPath
fromFieldPath: status.atProvider.endpoint
toFieldPath: status.endpointRun the render:
crossplane beta render \
compositions/database/examples/minimal-claim.yaml \
compositions/database/composition.yaml \
functions.yaml \
--include-full-xrAutomating Render Tests
Capture the expected output and diff against it in CI:
#!/usr/bin/env bash
# scripts/test-render.sh
set -e
PASS=0
FAIL=0
run_render_test() {
local name=$1
local claim=$2
local composition=$3
local expected=$4
actual=$(crossplane beta render "$claim" "$composition" functions.yaml 2>/dev/null)
if diff <(echo "$actual") "$expected" > /dev/null 2>&1; then
echo "✓ $name"
PASS=$((PASS + 1))
else
echo "✗ $name — diff:"
diff <(echo "$actual") "$expected" || true
FAIL=$((FAIL + 1))
fi
}
run_render_test \
"database/minimal" \
compositions/database/examples/minimal-claim.yaml \
compositions/database/composition.yaml \
compositions/database/tests/render/minimal-expected.yaml
run_render_test \
"database/full" \
compositions/database/examples/full-claim.yaml \
compositions/database/composition.yaml \
compositions/database/tests/render/full-expected.yaml
echo ""
echo "Render tests: $PASS passed, $FAIL failed"
[ $FAIL -eq 0 ]Generating Expected Output
The first time you run render tests, generate the expected output from a known-good composition:
crossplane beta render \
compositions/database/examples/minimal-claim.yaml \
compositions/database/composition.yaml \
functions.yaml \
> compositions/database/tests/render/minimal-expected.yamlCommit this file. Future changes to the composition will cause the render output to diff — which is your signal that something changed.
Testing Patch-and-Transform Functions
function-patch-and-transform is the most common Composition Function. It's written in Go and is fully unit-testable.
Unit Tests for Patch Logic
// functions/patch-and-transform/patches_test.go
package main
import (
"testing"
"github.com/crossplane/crossplane-runtime/pkg/test"
"github.com/google/go-cmp/cmp"
fnv1beta1 "github.com/crossplane/function-sdk-go/proto/v1beta1"
)
func TestDatabaseStoragePatch(t *testing.T) {
cases := map[string]struct {
claim *fnv1beta1.Resource
want map[string]interface{}
wantErr bool
}{
"MinimalClaim_SetsAllocatedStorage": {
claim: makeXR(map[string]interface{}{
"spec": map[string]interface{}{
"parameters": map[string]interface{}{
"storageGB": float64(20),
},
},
}),
want: map[string]interface{}{
"spec": map[string]interface{}{
"forProvider": map[string]interface{}{
"allocatedStorage": float64(20),
},
},
},
},
"MissingStorageGB_UsesDefault": {
claim: makeXR(map[string]interface{}{
"spec": map[string]interface{}{
"parameters": map[string]interface{}{},
},
}),
want: map[string]interface{}{
"spec": map[string]interface{}{
"forProvider": map[string]interface{}{
"allocatedStorage": float64(10), // default
},
},
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got, err := applyStoragePatch(tc.claim)
if tc.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("patch result mismatch (-want +got):\n%s", diff)
}
})
}
}Testing Transform Functions
Transforms (CombineString, Convert, Math) are easy to unit test:
func TestTagTransform(t *testing.T) {
cases := map[string]struct {
claimName string
env string
wantTags map[string]string
}{
"ProductionTag": {
claimName: "payments-db",
env: "production",
wantTags: map[string]string{
"Name": "payments-db",
"Environment": "production",
"ManagedBy": "crossplane",
},
},
"StagingTag": {
claimName: "test-db",
env: "staging",
wantTags: map[string]string{
"Name": "test-db",
"Environment": "staging",
"ManagedBy": "crossplane",
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := buildResourceTags(tc.claimName, tc.env)
if diff := cmp.Diff(tc.wantTags, got); diff != "" {
t.Errorf("tags mismatch (-want +got):\n%s", diff)
}
})
}
}Testing XRD Validation
XRDs define the schema for your XR claims. Test that your XRD accepts valid claims and rejects invalid ones using kubectl apply --dry-run:
#!/usr/bin/env bash
# scripts/test-xrd-validation.sh
# Requires a cluster with the XRD installed (use a local kind cluster)
echo "Testing XRD validation..."
# Valid minimal claim should be accepted
if kubectl apply --dry-run=server -f compositions/database/examples/minimal-claim.yaml &>/dev/null; then
echo "✓ Valid minimal claim accepted"
else
echo "✗ Valid minimal claim rejected (should have been accepted)"
exit 1
fi
# Claim with invalid engine should be rejected
cat > /tmp/invalid-engine.yaml <<EOF
apiVersion: platform.example.com/v1alpha1
kind: XDatabase
metadata:
name: test-invalid
spec:
parameters:
engine: mysql-invalid # not in enum
engineVersion: "8.0"
instanceClass: db.t3.micro
storageGB: 20
compositionRef:
name: database-aws
writeConnectionSecretToRef:
namespace: crossplane-system
name: test-conn
EOF
if kubectl apply --dry-run=server -f /tmp/invalid-engine.yaml &>/dev/null; then
echo "✗ Invalid engine accepted (should have been rejected)"
exit 1
else
echo "✓ Invalid engine correctly rejected"
fi
# Claim with storageGB below minimum should be rejected
cat > /tmp/invalid-storage.yaml <<EOF
apiVersion: platform.example.com/v1alpha1
kind: XDatabase
metadata:
name: test-invalid-storage
spec:
parameters:
engine: postgres
engineVersion: "15.4"
instanceClass: db.t3.micro
storageGB: 1 # below minimum of 5
compositionRef:
name: database-aws
writeConnectionSecretToRef:
namespace: crossplane-system
name: test-conn
EOF
if kubectl apply --dry-run=server -f /tmp/invalid-storage.yaml &>/dev/null; then
echo "✗ Invalid storage accepted (should have been rejected)"
exit 1
else
echo "✓ Below-minimum storage correctly rejected"
fi
echo "All XRD validation tests passed"Integration Testing with uptest
uptest is the official Crossplane integration testing tool. It creates real cloud resources, asserts on their state, and cleans up. Use it in nightly CI with a dedicated test account.
Installing uptest
go install github.com/crossplane/uptest/cmd/uptest@latestWriting uptest Test Cases
# compositions/database/tests/uptest/00-create.yaml
apiVersion: platform.example.com/v1alpha1
kind: XDatabase
metadata:
name: uptest-database
annotations:
uptest.upbound.io/timeout: "1800" # 30 min timeout for RDS creation
uptest.upbound.io/post-delete-wait: "60" # 60s after delete for cleanup
spec:
parameters:
engine: postgres
engineVersion: "15.4"
instanceClass: db.t3.micro
storageGB: 20
compositionRef:
name: database-aws
writeConnectionSecretToRef:
namespace: crossplane-system
name: uptest-database-conn# compositions/database/tests/uptest/01-assert.yaml
apiVersion: platform.example.com/v1alpha1
kind: XDatabase
metadata:
name: uptest-database
status:
conditions:
- type: Ready
status: "True"
endpoint: "" # must be non-empty (uptest checks non-empty string)Running uptest
uptest e2e compositions/database/tests/uptest/*.yaml \
--setup-script scripts/setup.sh \
--teardown-script scripts/teardown.shNightly CI with uptest
# .github/workflows/uptest-nightly.yaml
name: Crossplane Integration Tests (nightly)
on:
schedule:
- cron: '0 2 * * *' # 2am UTC daily
jobs:
uptest:
runs-on: ubuntu-latest
environment: integration-tests # requires manual approval for first run
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID_INTEGRATION }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY_INTEGRATION }}
aws-region: us-east-1
- name: Set up kind cluster with Crossplane
run: |
kind create cluster --name crossplane-test
helm install crossplane crossplane-stable/crossplane \
--namespace crossplane-system \
--create-namespace
kubectl wait --for=condition=available deployment/crossplane \
-n crossplane-system --timeout=120s
- name: Install AWS provider
run: |
kubectl apply -f providers/provider-aws.yaml
kubectl wait --for=condition=healthy provider/upbound-provider-aws \
--timeout=120s
- name: Configure AWS credentials for provider
run: |
kubectl create secret generic aws-creds \
-n crossplane-system \
--from-literal=creds="[default]
aws_access_key_id=${{ secrets.AWS_ACCESS_KEY_ID_INTEGRATION }}
aws_secret_access_key=${{ secrets.AWS_SECRET_ACCESS_KEY_INTEGRATION }}"
kubectl apply -f providers/provider-config-integration.yaml
- name: Install XRDs and Compositions
run: |
kubectl apply -f compositions/database/xrd.yaml
kubectl apply -f compositions/database/composition.yaml
kubectl apply -f compositions/database/functions/
- name: Run uptest
run: |
go install github.com/crossplane/uptest/cmd/uptest@latest
uptest e2e compositions/database/tests/uptest/*.yaml
- name: Cleanup
if: always()
run: kind delete cluster --name crossplane-testTesting CompositionRevisions and Migrations
When you publish a new CompositionRevision, existing claims migrate. Test the migration:
#!/usr/bin/env bash
# scripts/test-composition-revision.sh
# Apply the old composition and create a claim
kubectl apply -f compositions/database/composition-v1.yaml
kubectl apply -f compositions/database/examples/minimal-claim.yaml
# Wait for claim to be ready
kubectl wait --for=condition=Ready xdatabase/payments-db --timeout=300s
# Capture current managed resource state
OLD_STATE=$(kubectl get rdsinstance -l crossplane.io/claim-name=payments-db -o json)
# Apply the new composition revision
kubectl apply -f compositions/database/composition-v2.yaml
# Trigger migration by annotating the claim
kubectl annotate xdatabase payments-db \
crossplane.io/composition-resource-version=v2 --overwrite
# Wait for reconciliation
sleep 30
kubectl wait --for=condition=Ready xdatabase/payments-db --timeout=120s
# Assert no destructive changes occurred
NEW_STATE=$(kubectl get rdsinstance -l crossplane.io/claim-name=payments-db -o json)
OLD_INSTANCE_COUNT=$(echo "$OLD_STATE" | jq '.items | length')
NEW_INSTANCE_COUNT=$(echo "$NEW_STATE" | jq '.items | length')
if [ "$OLD_INSTANCE_COUNT" != "$NEW_INSTANCE_COUNT" ]; then
echo "FAIL: Instance count changed during migration ($OLD_INSTANCE_COUNT → $NEW_INSTANCE_COUNT)"
exit 1
fi
echo "PASS: CompositionRevision migration preserved managed resources"Linting Compositions
Before running render or integration tests, catch obvious YAML errors:
# Validate YAML structure
kubectl apply --dry-run=client -f compositions/database/composition.yaml
# Validate against the Crossplane OpenAPI schema
kubectl apply --dry-run=server -f compositions/database/xrd.yaml
kubectl apply --dry-run=server -f compositions/database/composition.yaml
# Check for common Crossplane mistakes with kubeconform
kubeconform \
-schema-location https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json \
compositions/database/composition.yamlFull Testing Pipeline
# .github/workflows/crossplane-tests.yaml
name: Crossplane Composition Tests
on: [push, pull_request]
jobs:
lint-and-render:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Crossplane CLI
run: |
curl -sL "https://raw.githubusercontent.com/crossplane/crossplane/main/install.sh" | sh
sudo mv crossplane /usr/local/bin/
- name: Lint compositions
run: |
for f in compositions/**/composition.yaml; do
kubectl apply --dry-run=client -f "$f"
done
- name: Run render tests
run: ./scripts/test-render.sh
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- run: go test ./functions/...
xrd-validation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up kind cluster
run: |
kind create cluster --name xrd-test
helm install crossplane crossplane-stable/crossplane \
-n crossplane-system --create-namespace
kubectl wait --for=condition=available deployment/crossplane \
-n crossplane-system --timeout=120s
- name: Install XRDs
run: kubectl apply -f compositions/database/xrd.yaml
- name: Run XRD validation tests
run: ./scripts/test-xrd-validation.sh
- name: Cleanup
if: always()
run: kind delete cluster --name xrd-testConclusion
Crossplane Composition testing requires a layered approach: crossplane beta render for fast local feedback on patch logic, Go unit tests for custom functions, XRD server-side dry-runs for schema validation, and uptest for nightly integration tests against real cloud providers.
The render tests are the most valuable addition to a PR pipeline — they run in seconds, require no cloud credentials, and catch the majority of composition bugs before they ever reach a cluster.
HelpMeTest can monitor your platform engineering pipelines automatically — sign up free