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 <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 --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
<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.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
<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@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
<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.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