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 <span class="hljs-string">"https://raw.githubusercontent.com/crossplane/crossplane/main/install.sh" <span class="hljs-pipe">| sh
<span class="hljs-built_in">sudo <span class="hljs-built_in">mv crossplane /usr/local/bin/
<span class="hljs-comment"># 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
<span class="hljs-comment"># scripts/test-render.sh
<span class="hljs-built_in">set -e
PASS=0
FAIL=0
<span class="hljs-function">run_render_test() {
<span class="hljs-built_in">local name=<span class="hljs-variable">$1
<span class="hljs-built_in">local claim=<span class="hljs-variable">$2
<span class="hljs-built_in">local composition=<span class="hljs-variable">$3
<span class="hljs-built_in">local expected=<span class="hljs-variable">$4
actual=$(crossplane beta render <span class="hljs-string">"$claim" <span class="hljs-string">"$composition" functions.yaml 2>/dev/null)
<span class="hljs-keyword">if diff <(<span class="hljs-built_in">echo <span class="hljs-string">"$actual") <span class="hljs-string">"$expected" > /dev/null 2>&1; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"✓ $name"
PASS=$((PASS + <span class="hljs-number">1))
<span class="hljs-keyword">else
<span class="hljs-built_in">echo <span class="hljs-string">"✗ $name — diff:"
diff <(<span class="hljs-built_in">echo <span class="hljs-string">"$actual") <span class="hljs-string">"$expected" <span class="hljs-pipe">|| <span class="hljs-literal">true
FAIL=$((FAIL + <span class="hljs-number">1))
<span class="hljs-keyword">fi
}
run_render_test \
<span class="hljs-string">"database/minimal" \
compositions/database/examples/minimal-claim.yaml \
compositions/database/composition.yaml \
compositions/database/tests/render/minimal-expected.yaml
run_render_test \
<span class="hljs-string">"database/full" \
compositions/database/examples/full-claim.yaml \
compositions/database/composition.yaml \
compositions/database/tests/render/full-expected.yaml
<span class="hljs-built_in">echo <span class="hljs-string">""
<span class="hljs-built_in">echo <span class="hljs-string">"Render tests: $PASS passed, <span class="hljs-variable">$FAIL failed"
[ <span class="hljs-variable">$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
<span class="hljs-comment"># scripts/test-xrd-validation.sh
<span class="hljs-comment"># Requires a cluster with the XRD installed (use a local kind cluster)
<span class="hljs-built_in">echo <span class="hljs-string">"Testing XRD validation..."
<span class="hljs-comment"># Valid minimal claim should be accepted
<span class="hljs-keyword">if kubectl apply --dry-run=server -f compositions/database/examples/minimal-claim.yaml &>/dev/null; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"✓ Valid minimal claim accepted"
<span class="hljs-keyword">else
<span class="hljs-built_in">echo <span class="hljs-string">"✗ Valid minimal claim rejected (should have been accepted)"
<span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi
<span class="hljs-comment"># Claim with invalid engine should be rejected
<span class="hljs-built_in">cat > /tmp/invalid-engine.yaml <<<span class="hljs-string">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
<span class="hljs-keyword">if kubectl apply --dry-run=server -f /tmp/invalid-engine.yaml &>/dev/null; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"✗ Invalid engine accepted (should have been rejected)"
<span class="hljs-built_in">exit 1
<span class="hljs-keyword">else
<span class="hljs-built_in">echo <span class="hljs-string">"✓ Invalid engine correctly rejected"
<span class="hljs-keyword">fi
<span class="hljs-comment"># Claim with storageGB below minimum should be rejected
<span class="hljs-built_in">cat > /tmp/invalid-storage.yaml <<<span class="hljs-string">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
<span class="hljs-keyword">if kubectl apply --dry-run=server -f /tmp/invalid-storage.yaml &>/dev/null; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"✗ Invalid storage accepted (should have been rejected)"
<span class="hljs-built_in">exit 1
<span class="hljs-keyword">else
<span class="hljs-built_in">echo <span class="hljs-string">"✓ Below-minimum storage correctly rejected"
<span class="hljs-keyword">fi
<span class="hljs-built_in">echo <span class="hljs-string">"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
<span class="hljs-comment"># scripts/test-composition-revision.sh
<span class="hljs-comment"># 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
<span class="hljs-comment"># Wait for claim to be ready
kubectl <span class="hljs-built_in">wait --<span class="hljs-keyword">for=condition=Ready xdatabase/payments-db --<span class="hljs-built_in">timeout=300s
<span class="hljs-comment"># Capture current managed resource state
OLD_STATE=$(kubectl get rdsinstance -l crossplane.io/claim-name=payments-db -o json)
<span class="hljs-comment"># Apply the new composition revision
kubectl apply -f compositions/database/composition-v2.yaml
<span class="hljs-comment"># Trigger migration by annotating the claim
kubectl annotate xdatabase payments-db \
crossplane.io/composition-resource-version=v2 --overwrite
<span class="hljs-comment"># Wait for reconciliation
<span class="hljs-built_in">sleep 30
kubectl <span class="hljs-built_in">wait --<span class="hljs-keyword">for=condition=Ready xdatabase/payments-db --<span class="hljs-built_in">timeout=120s
<span class="hljs-comment"># Assert no destructive changes occurred
NEW_STATE=$(kubectl get rdsinstance -l crossplane.io/claim-name=payments-db -o json)
OLD_INSTANCE_COUNT=$(<span class="hljs-built_in">echo <span class="hljs-string">"$OLD_STATE" <span class="hljs-pipe">| jq <span class="hljs-string">'.items | length')
NEW_INSTANCE_COUNT=$(<span class="hljs-built_in">echo <span class="hljs-string">"$NEW_STATE" <span class="hljs-pipe">| jq <span class="hljs-string">'.items | length')
<span class="hljs-keyword">if [ <span class="hljs-string">"$OLD_INSTANCE_COUNT" != <span class="hljs-string">"$NEW_INSTANCE_COUNT" ]; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"FAIL: Instance count changed during migration ($OLD_INSTANCE_COUNT → <span class="hljs-variable">$NEW_INSTANCE_COUNT)"
<span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi
<span class="hljs-built_in">echo <span class="hljs-string">"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
<span class="hljs-comment"># 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
<span class="hljs-comment"># 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