Skip to content

Commit abb9b24

Browse files
sourcehawkclaude
andcommitted
test(examples): controller-level component golden tests
Extract Build*Component() methods from the reconcile loops in the component-prerequisites, extraction-and-guards, and grace-inconsistency examples so tests can build components through the same assembly path the controller uses. Add controller_test.go + testdata/ golden snapshots for each example using golden.AssertComponentYAML. Update resource tests in all three examples (and custom-resource) to call the actual factory functions rather than duplicating builder construction, so the golden files reflect real factory output. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6f3c796 commit abb9b24

16 files changed

Lines changed: 366 additions & 110 deletions

File tree

examples/component-prerequisites/app/controller.go

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -48,40 +48,55 @@ func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) (err erro
4848
}()
4949

5050
// --- Infra component: no prerequisites ---
51-
cmResource, err := r.NewConfigMapResource(owner)
51+
infra, err := r.BuildInfraComponent(owner)
5252
if err != nil {
5353
return err
5454
}
5555

56-
infra, err := component.NewComponentBuilder().
57-
WithName("infra").
58-
WithConditionType("InfraReady").
59-
WithResource(cmResource).
60-
Build()
61-
if err != nil {
56+
if err := infra.Reconcile(ctx, recCtx); err != nil {
6257
return err
6358
}
6459

65-
if err := infra.Reconcile(ctx, recCtx); err != nil {
60+
// --- App component: depends on InfraReady ---
61+
app, err := r.BuildAppComponent(owner)
62+
if err != nil {
6663
return err
6764
}
6865

69-
// --- App component: depends on InfraReady ---
66+
return app.Reconcile(ctx, recCtx)
67+
}
68+
69+
// BuildInfraComponent assembles the infra component: a single ConfigMap reporting
70+
// the InfraReady condition, with no prerequisites. The controller and tests share
71+
// this so the reconciled component and the golden snapshot stay in lockstep.
72+
func (r *Controller) BuildInfraComponent(owner *ExampleApp) (*component.Component, error) {
73+
cmResource, err := r.NewConfigMapResource(owner)
74+
if err != nil {
75+
return nil, err
76+
}
77+
78+
return component.NewComponentBuilder().
79+
WithName("infra").
80+
WithConditionType("InfraReady").
81+
WithResource(cmResource).
82+
Build()
83+
}
84+
85+
// BuildAppComponent assembles the app component: a Deployment reporting the
86+
// AppReady condition, gated behind the InfraReady prerequisite. The DependsOn
87+
// prerequisite is the point of this example, so the controller and tests build
88+
// the component the same way.
89+
func (r *Controller) BuildAppComponent(owner *ExampleApp) (*component.Component, error) {
7090
deployResource, err := r.NewDeploymentResource(owner)
7191
if err != nil {
72-
return err
92+
return nil, err
7393
}
7494

75-
app, err := component.NewComponentBuilder().
95+
return component.NewComponentBuilder().
7696
WithName("app").
7797
WithConditionType("AppReady").
7898
WithResource(deployResource).
7999
WithPrerequisite(component.DependsOn("InfraReady")).
80100
Suspend(owner.Spec.Suspended).
81101
Build()
82-
if err != nil {
83-
return err
84-
}
85-
86-
return app.Reconcile(ctx, recCtx)
87102
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package app_test
2+
3+
import (
4+
"flag"
5+
"testing"
6+
7+
"github.com/sourcehawk/operator-component-framework/examples/component-prerequisites/app"
8+
"github.com/sourcehawk/operator-component-framework/examples/component-prerequisites/resources"
9+
"github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
10+
"github.com/stretchr/testify/require"
11+
appsv1 "k8s.io/api/apps/v1"
12+
corev1 "k8s.io/api/core/v1"
13+
"k8s.io/apimachinery/pkg/runtime"
14+
)
15+
16+
var update = flag.Bool("update", false, "update golden files")
17+
18+
func testOwner() *app.ExampleApp {
19+
owner := &app.ExampleApp{
20+
Spec: app.ExampleAppSpec{Version: "1.0.0"},
21+
}
22+
owner.Name = "my-app"
23+
owner.Namespace = "default"
24+
return owner
25+
}
26+
27+
func testController() *app.Controller {
28+
return &app.Controller{
29+
NewConfigMapResource: resources.NewConfigMapResource,
30+
NewDeploymentResource: resources.NewDeploymentResource,
31+
}
32+
}
33+
34+
func testScheme(t *testing.T) *runtime.Scheme {
35+
t.Helper()
36+
scheme := runtime.NewScheme()
37+
require.NoError(t, appsv1.AddToScheme(scheme))
38+
require.NoError(t, corev1.AddToScheme(scheme))
39+
return scheme
40+
}
41+
42+
// TestInfraComponentShape goldens the infra component the controller reconciles.
43+
// It builds through BuildInfraComponent, the same assembly the controller uses,
44+
// so the snapshot reflects the real desired state.
45+
func TestInfraComponentShape(t *testing.T) {
46+
comp, err := testController().BuildInfraComponent(testOwner())
47+
require.NoError(t, err)
48+
49+
golden.AssertComponentYAML(t, "testdata/infra-component.yaml", comp,
50+
golden.WithScheme(testScheme(t)), golden.Update(*update))
51+
}
52+
53+
// TestAppComponentShape goldens the app component the controller reconciles.
54+
// The DependsOn("InfraReady") prerequisite is the point of this example; the
55+
// component is built through BuildAppComponent so the controller and the
56+
// snapshot stay in lockstep.
57+
func TestAppComponentShape(t *testing.T) {
58+
comp, err := testController().BuildAppComponent(testOwner())
59+
require.NoError(t, err)
60+
61+
golden.AssertComponentYAML(t, "testdata/app-component.yaml", comp,
62+
golden.WithScheme(testScheme(t)), golden.Update(*update))
63+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
apiVersion: apps/v1
2+
kind: Deployment
3+
metadata:
4+
labels:
5+
app: my-app
6+
name: my-app-app
7+
namespace: default
8+
spec:
9+
selector:
10+
matchLabels:
11+
app: my-app
12+
strategy: {}
13+
template:
14+
metadata:
15+
labels:
16+
app: my-app
17+
spec:
18+
containers:
19+
- image: my-app:1.0.0
20+
name: app
21+
resources: {}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
apiVersion: v1
2+
data:
3+
cluster-dns: 10.96.0.10
4+
kind: ConfigMap
5+
metadata:
6+
labels:
7+
app: my-app
8+
name: my-app-infra-config
9+
namespace: default

examples/component-prerequisites/resources/configmap_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66

77
"github.com/sourcehawk/operator-component-framework/examples/component-prerequisites/resources"
88
sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app"
9-
"github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap"
109
"github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
1110
"github.com/stretchr/testify/require"
1211
corev1 "k8s.io/api/core/v1"
@@ -24,16 +23,17 @@ func testOwner(version string) *sharedapp.ExampleApp {
2423
return owner
2524
}
2625

27-
// TestConfigMapShape pins the infra component's ConfigMap baseline. Changes
28-
// to the base object surface as a diff against the golden file.
26+
// TestConfigMapShape pins the infra component's ConfigMap as built by its
27+
// factory. The factory registers no mutations, so the golden file captures the
28+
// full desired state. Changes to the base object surface as a diff.
2929
func TestConfigMapShape(t *testing.T) {
3030
scheme := runtime.NewScheme()
3131
require.NoError(t, corev1.AddToScheme(scheme))
3232

3333
owner := testOwner("1.0.0")
34-
res, err := configmap.NewBuilder(resources.BaseConfigMap(owner)).Build()
34+
res, err := resources.NewConfigMapResource(owner)
3535
require.NoError(t, err)
3636

37-
golden.AssertYAML(t, "testdata/configmap.yaml", res,
37+
golden.AssertYAML(t, "testdata/configmap.yaml", res.(golden.Previewer),
3838
golden.WithScheme(scheme), golden.Update(*update))
3939
}
Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,39 @@
11
package resources_test
22

33
import (
4-
"fmt"
54
"testing"
65

76
"github.com/sourcehawk/operator-component-framework/examples/component-prerequisites/resources"
8-
"github.com/sourcehawk/operator-component-framework/pkg/feature"
9-
"github.com/sourcehawk/operator-component-framework/pkg/mutation/editors"
10-
"github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors"
11-
"github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment"
7+
"github.com/sourcehawk/operator-component-framework/pkg/component/concepts"
128
"github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
9+
"github.com/stretchr/testify/assert"
1310
"github.com/stretchr/testify/require"
1411
appsv1 "k8s.io/api/apps/v1"
1512
"k8s.io/apimachinery/pkg/runtime"
1613
)
1714

18-
// TestDeploymentShape verifies the app component's Deployment for each version.
19-
// The baseline carries the latest container layout; the version mutation sets
20-
// the image tag. Golden files for each version catch regressions when the
21-
// baseline or mutation logic changes.
15+
// TestDeploymentMutations verifies the factory registers the Version mutation
16+
// and that it fires at every version. The version gate has no constraint, so
17+
// the mutation always applies and rewrites the image tag.
18+
func TestDeploymentMutations(t *testing.T) {
19+
owner := testOwner("1.0.0")
20+
res, err := resources.NewDeploymentResource(owner)
21+
require.NoError(t, err)
22+
23+
inspector, ok := res.(concepts.MutationInspector)
24+
require.True(t, ok)
25+
26+
assert.ElementsMatch(t, []string{"Version"}, inspector.RegisteredMutations())
27+
28+
firing, err := inspector.FiringSet()
29+
require.NoError(t, err)
30+
assert.ElementsMatch(t, []string{"Version"}, firing)
31+
}
32+
33+
// TestDeploymentShape verifies the app component's Deployment as built by its
34+
// factory for each version. The baseline carries the latest container layout;
35+
// the Version mutation sets the image tag. Golden files for each version catch
36+
// regressions when the baseline or mutation logic changes.
2237
func TestDeploymentShape(t *testing.T) {
2338
scheme := runtime.NewScheme()
2439
require.NoError(t, appsv1.AddToScheme(scheme))
@@ -44,22 +59,11 @@ func TestDeploymentShape(t *testing.T) {
4459
t.Run(tt.name, func(t *testing.T) {
4560
owner := testOwner(tt.version)
4661

47-
res, err := deployment.NewBuilder(resources.BaseDeployment(owner)).
48-
WithMutation(deployment.Mutation{
49-
Name: "Version",
50-
Feature: feature.NewVersionGate(tt.version, nil),
51-
Mutate: func(m *deployment.Mutator) error {
52-
m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error {
53-
ce.Raw().Image = fmt.Sprintf("my-app:%s", tt.version)
54-
return nil
55-
})
56-
return nil
57-
},
58-
}).
59-
Build()
62+
res, err := resources.NewDeploymentResource(owner)
6063
require.NoError(t, err)
6164

62-
golden.AssertYAML(t, tt.golden, res, golden.WithScheme(scheme), golden.Update(*update))
65+
golden.AssertYAML(t, tt.golden, res.(golden.Previewer),
66+
golden.WithScheme(scheme), golden.Update(*update))
6367
})
6468
}
6569
}

examples/custom-resource/resources/certificate_test.go

Lines changed: 24 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import (
66

77
"github.com/sourcehawk/operator-component-framework/examples/custom-resource/resources"
88
sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app"
9-
"github.com/sourcehawk/operator-component-framework/pkg/mutation/editors"
10-
unstruct "github.com/sourcehawk/operator-component-framework/pkg/primitives/unstructured"
9+
"github.com/sourcehawk/operator-component-framework/pkg/component/concepts"
1110
"github.com/sourcehawk/operator-component-framework/pkg/primitives/unstructured/static"
1211
"github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
12+
"github.com/stretchr/testify/assert"
1313
"github.com/stretchr/testify/require"
1414
uns "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
1515
)
@@ -25,41 +25,31 @@ func testOwner() *sharedapp.ExampleApp {
2525
return owner
2626
}
2727

28-
// TestCertificateShape verifies the CertificateRequest's shape after mutations
29-
// set spec fields (issuerRef, dnsNames) and metadata labels. The golden file
30-
// catches regressions when the mutation logic or base object changes.
31-
func TestCertificateShape(t *testing.T) {
32-
owner := testOwner()
28+
// TestCertificateMutations verifies the factory registers the certificate-spec
29+
// mutation and that it fires. The mutation has no gate, so it always applies.
30+
func TestCertificateMutations(t *testing.T) {
31+
res, err := resources.NewCertificateResource(testOwner())
32+
require.NoError(t, err)
33+
34+
inspector, ok := res.(concepts.MutationInspector)
35+
require.True(t, ok)
36+
37+
assert.ElementsMatch(t, []string{"certificate-spec"}, inspector.RegisteredMutations())
3338

34-
res, err := static.NewBuilder(resources.BaseCertificateRequest(owner)).
35-
WithMutation(unstruct.Mutation{
36-
Name: "certificate-spec",
37-
Mutate: func(m *unstruct.Mutator) error {
38-
m.EditContent(func(e *editors.UnstructuredContentEditor) error {
39-
if err := e.SetNestedString("letsencrypt-prod", "spec", "issuerRef", "name"); err != nil {
40-
return err
41-
}
42-
if err := e.SetNestedString("ClusterIssuer", "spec", "issuerRef", "kind"); err != nil {
43-
return err
44-
}
45-
return e.SetNestedSlice(
46-
[]interface{}{owner.Name + ".example.com"},
47-
"spec", "dnsNames",
48-
)
49-
})
50-
51-
m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error {
52-
meta.EnsureLabel("app", owner.Name)
53-
return nil
54-
})
55-
56-
return nil
57-
},
58-
}).
59-
Build()
39+
firing, err := inspector.FiringSet()
40+
require.NoError(t, err)
41+
assert.ElementsMatch(t, []string{"certificate-spec"}, firing)
42+
}
43+
44+
// TestCertificateShape verifies the CertificateRequest as built by its factory,
45+
// after the certificate-spec mutation sets spec fields (issuerRef, dnsNames)
46+
// and metadata labels. The golden file catches regressions when the mutation
47+
// logic or base object changes.
48+
func TestCertificateShape(t *testing.T) {
49+
res, err := resources.NewCertificateResource(testOwner())
6050
require.NoError(t, err)
6151

62-
golden.AssertYAML(t, "testdata/certificate.yaml", res, golden.Update(*update))
52+
golden.AssertYAML(t, "testdata/certificate.yaml", res.(golden.Previewer), golden.Update(*update))
6353
}
6454

6555
// TestCertificateBaseShape pins the bare base object before any mutations.

examples/extraction-and-guards/app/controller.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,28 +44,37 @@ func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) (err erro
4444
}
4545
}()
4646

47+
comp, err := r.BuildComponent(owner)
48+
if err != nil {
49+
return err
50+
}
51+
52+
return comp.Reconcile(ctx, recCtx)
53+
}
54+
55+
// BuildComponent assembles the database component: a ConfigMap registered before
56+
// a Secret, both wired to a shared dbHost pointer. The ConfigMap extractor writes
57+
// the pointer and the Secret guard reads it, so registration order matters. The
58+
// controller and tests share this assembly so the reconciled component and the
59+
// golden snapshot stay in lockstep.
60+
func (r *Controller) BuildComponent(owner *ExampleApp) (*component.Component, error) {
4761
// Shared state: the ConfigMap extractor writes here, the Secret guard reads it.
4862
var dbHost string
4963

5064
cmResource, err := r.NewConfigMapResource(owner, &dbHost)
5165
if err != nil {
52-
return err
66+
return nil, err
5367
}
5468

5569
secretResource, err := r.NewSecretResource(owner, &dbHost)
5670
if err != nil {
57-
return err
71+
return nil, err
5872
}
5973

60-
comp, err := component.NewComponentBuilder().
74+
return component.NewComponentBuilder().
6175
WithName("database").
6276
WithConditionType("DatabaseReady").
6377
WithResource(cmResource).
6478
WithResource(secretResource).
6579
Build()
66-
if err != nil {
67-
return err
68-
}
69-
70-
return comp.Reconcile(ctx, recCtx)
7180
}

0 commit comments

Comments
 (0)