Testing Crossplane Compositions: Validate Infrastructure Claims Before Applying

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 toFieldPath silently sets the wrong value on a managed resource.
  • A missing fromFieldPath causes the managed resource to be created with an empty field, which might pass validation but fail at the cloud provider.
  • A delete policy 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.go

crossplane 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 --version

Writing 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-conn

And 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.endpoint

Run the render:

crossplane beta render \
  compositions/database/examples/minimal-claim.yaml \
  compositions/database/composition.yaml \
  functions.yaml \
  --include-full-xr

Automating 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.yaml

Commit 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@latest

Writing 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.sh

Nightly 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-test

Testing 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.yaml

Full 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-test

Conclusion

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

Read more

Start now free