Skip to content

Commit 77b4baf

Browse files
authored
feat(test): add utilities for JSONPath-like access to unstructured Kubernetes objects (#519)
* feat(test): add utilities for JSONPath-like access to unstructured Kubernetes objects Signed-off-by: Marc Nuri <marc@marcnuri.com> * feat(test): add utilities for JSONPath-like access to unstructured Kubernetes objects (review) Signed-off-by: Marc Nuri <marc@marcnuri.com> --------- Signed-off-by: Marc Nuri <marc@marcnuri.com>
1 parent 6aee8d3 commit 77b4baf

File tree

5 files changed

+811
-159
lines changed

5 files changed

+811
-159
lines changed

AGENTS.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,134 @@ The test suite relies on the `setup-envtest` tooling from `sigs.k8s.io/controlle
113113
The first run downloads a Kubernetes `envtest` environment from the internet, so network access is required.
114114
Without it some tests will fail during setup.
115115

116+
### Testing Patterns and Guidelines
117+
118+
This project follows specific testing patterns to ensure consistency, maintainability, and quality. When writing tests, adhere to the following guidelines:
119+
120+
#### Test Framework
121+
122+
- **Use `testify/suite`** for organizing tests into suites
123+
- Tests should be structured using test suites that embed `suite.Suite`
124+
- Each test file should have a corresponding suite struct (e.g., `UnstructuredSuite`, `KubevirtSuite`)
125+
- Use the `suite.Run()` function to execute test suites
126+
127+
Example:
128+
```go
129+
type MyTestSuite struct {
130+
suite.Suite
131+
}
132+
133+
func (s *MyTestSuite) TestSomething() {
134+
s.Run("descriptive scenario name", func() {
135+
// test implementation
136+
})
137+
}
138+
139+
func TestMyFeature(t *testing.T) {
140+
suite.Run(t, new(MyTestSuite))
141+
}
142+
```
143+
144+
#### Behavior-Based Testing
145+
146+
- **Test the public API only** - tests should be black-box and not access internal/private functions
147+
- **No mocks** - use real implementations and integration testing where possible
148+
- **Behavior over implementation** - test what the code does, not how it does it
149+
- Focus on observable behavior and outcomes rather than internal state
150+
151+
#### Test Organization
152+
153+
- **Use nested subtests** with `s.Run()` to organize related test cases
154+
- **Descriptive names** - subtest names should clearly describe the scenario being tested
155+
- Group related scenarios together under a parent test (e.g., "edge cases", "with valid input")
156+
157+
Example structure:
158+
```go
159+
func (s *MySuite) TestFeature() {
160+
s.Run("valid input scenarios", func() {
161+
s.Run("handles simple case correctly", func() {
162+
// test code
163+
})
164+
s.Run("handles complex case with nested data", func() {
165+
// test code
166+
})
167+
})
168+
s.Run("edge cases", func() {
169+
s.Run("returns error for nil input", func() {
170+
// test code
171+
})
172+
s.Run("handles empty input gracefully", func() {
173+
// test code
174+
})
175+
})
176+
}
177+
```
178+
179+
#### Assertions
180+
181+
- **One assertion per test case** - each `s.Run()` block should ideally test one specific behavior
182+
- Use `testify` assertion methods: `s.Equal()`, `s.True()`, `s.False()`, `s.Nil()`, `s.NotNil()`, etc.
183+
- Provide clear assertion messages when the failure reason might not be obvious
184+
185+
Example:
186+
```go
187+
s.Run("returns expected value", func() {
188+
result := functionUnderTest()
189+
s.Equal("expected", result, "function should return the expected string")
190+
})
191+
```
192+
193+
#### Coverage
194+
195+
- **Aim for high test coverage** of the public API
196+
- Add edge case tests to cover error paths and boundary conditions
197+
- Common edge cases to consider:
198+
- Nil/null inputs
199+
- Empty strings, slices, maps
200+
- Negative numbers or invalid indices
201+
- Type mismatches
202+
- Malformed input (e.g., invalid paths, formats)
203+
204+
#### Error Handling
205+
206+
- **Never ignore errors** in production code
207+
- Always check and handle errors from functions that return them
208+
- In tests, use `s.Require().NoError(err)` for operations that must succeed for the test to be valid
209+
- Use `s.Error(err)` or `s.NoError(err)` for testing error conditions
210+
211+
Example:
212+
```go
213+
s.Run("returns error for invalid input", func() {
214+
result, err := functionUnderTest(invalidInput)
215+
s.Error(err, "expected error for invalid input")
216+
s.Nil(result, "result should be nil when error occurs")
217+
})
218+
```
219+
220+
#### Test Helpers
221+
222+
- Create reusable test helpers in `internal/test/` for common testing utilities
223+
- Test helpers should be generic and reusable across multiple test files
224+
- Document test helpers with clear godoc comments explaining their purpose and usage
225+
226+
Example from this project:
227+
```go
228+
// FieldString retrieves a string field from an unstructured object using JSONPath-like notation.
229+
// Examples:
230+
// - "spec.runStrategy"
231+
// - "spec.template.spec.volumes[0].containerDisk.image"
232+
func FieldString(obj *unstructured.Unstructured, path string) string {
233+
// implementation
234+
}
235+
```
236+
237+
#### Examples from the Codebase
238+
239+
Good examples of these patterns can be found in:
240+
- `internal/test/unstructured_test.go` - demonstrates proper use of testify/suite, nested subtests, and edge case testing
241+
- `pkg/mcp/kubevirt_test.go` - shows behavior-based testing of the MCP layer
242+
- `pkg/kubernetes/manager_test.go` - illustrates testing with proper setup/teardown and subtests
243+
116244
## Linting
117245

118246
Static analysis is performed with `golangci-lint`:

internal/test/unstructured.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Package test provides utilities for testing unstructured Kubernetes objects.
2+
//
3+
// The primary functionality is JSONPath-like field access for unstructured.Unstructured objects,
4+
// making test assertions more readable and maintainable.
5+
package test
6+
7+
import (
8+
"fmt"
9+
10+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
11+
)
12+
13+
// FieldString retrieves a string field from an unstructured object using JSONPath-like notation.
14+
// Returns the string value, or empty string if not found or not a string.
15+
//
16+
// IMPORTANT: This function cannot distinguish between "field doesn't exist", "field is nil",
17+
// and "field exists with empty string value". When asserting empty string values (""),
18+
// you should also verify the field exists using FieldExists:
19+
//
20+
// s.True(test.FieldExists(obj, "spec.emptyField"), "field should exist")
21+
// s.Equal("", test.FieldString(obj, "spec.emptyField"), "field should be empty string")
22+
//
23+
// Examples:
24+
// - "spec.runStrategy"
25+
// - "spec.template.spec.volumes[0].containerDisk.image"
26+
// - "spec.dataVolumeTemplates[0].spec.sourceRef.kind"
27+
func FieldString(obj *unstructured.Unstructured, path string) string {
28+
if obj == nil {
29+
return ""
30+
}
31+
value, _ := Field(obj.Object, path)
32+
if str, ok := value.(string); ok {
33+
return str
34+
}
35+
return ""
36+
}
37+
38+
// FieldExists checks if a field exists at the given JSONPath-like path.
39+
func FieldExists(obj *unstructured.Unstructured, path string) bool {
40+
if obj == nil {
41+
return false
42+
}
43+
_, found := Field(obj.Object, path)
44+
return found
45+
}
46+
47+
// FieldInt retrieves an integer field from an unstructured object using JSONPath-like notation.
48+
// Returns the integer value (int64), or 0 if not found or not an integer type (int, int64, int32).
49+
//
50+
// IMPORTANT: This function cannot distinguish between "field doesn't exist", "field is nil",
51+
// and "field exists with value 0". When asserting zero values (0), you should also verify
52+
// the field exists using FieldExists:
53+
//
54+
// s.True(test.FieldExists(obj, "spec.zeroValue"), "field should exist")
55+
// s.Equal(int64(0), test.FieldInt(obj, "spec.zeroValue"), "field should be 0")
56+
//
57+
// Examples:
58+
// - "spec.replicas"
59+
// - "spec.ports[0].containerPort"
60+
func FieldInt(obj *unstructured.Unstructured, path string) int64 {
61+
if obj == nil {
62+
return 0
63+
}
64+
value, _ := Field(obj.Object, path)
65+
switch v := value.(type) {
66+
case int64:
67+
return v
68+
case int:
69+
return int64(v)
70+
case int32:
71+
return int64(v)
72+
default:
73+
return 0
74+
}
75+
}
76+
77+
// FieldValue retrieves any field value from an unstructured object using JSONPath-like notation.
78+
// Returns nil if the field is not found. This is useful when you need the raw value
79+
// without type conversion.
80+
// Examples:
81+
// - "spec.template.spec.containers[0]" - returns map[string]interface{}
82+
// - "metadata.labels" - returns map[string]interface{}
83+
func FieldValue(obj *unstructured.Unstructured, path string) interface{} {
84+
if obj == nil {
85+
return nil
86+
}
87+
value, _ := Field(obj.Object, path)
88+
return value
89+
}
90+
91+
// Field is the core helper that traverses an unstructured object using JSONPath-like notation.
92+
// It supports both dot notation (foo.bar) and array indexing (foo[0].bar).
93+
// Returns (nil, false) if any intermediate field is nil, as we cannot traverse through nil.
94+
func Field(obj interface{}, path string) (interface{}, bool) {
95+
if obj == nil || path == "" {
96+
return nil, false
97+
}
98+
99+
// Parse the path into segments
100+
segments := parsePath(path)
101+
current := obj
102+
103+
for i, segment := range segments {
104+
if segment.isArray {
105+
// Handle array indexing
106+
slice, ok := current.([]interface{})
107+
if !ok {
108+
return nil, false
109+
}
110+
if segment.index >= len(slice) || segment.index < 0 {
111+
return nil, false
112+
}
113+
current = slice[segment.index]
114+
} else {
115+
// Handle map field access
116+
m, ok := current.(map[string]interface{})
117+
if !ok {
118+
return nil, false
119+
}
120+
val, exists := m[segment.field]
121+
if !exists {
122+
return nil, false
123+
}
124+
// If this is an intermediate field and value is nil, we can't traverse further
125+
if val == nil && i < len(segments)-1 {
126+
return nil, false
127+
}
128+
current = val
129+
}
130+
}
131+
132+
return current, true
133+
}
134+
135+
type pathSegment struct {
136+
field string
137+
isArray bool
138+
index int
139+
}
140+
141+
// parsePath converts a JSONPath-like string into segments.
142+
// Examples:
143+
// - "spec.runStrategy" -> [{field: "spec"}, {field: "runStrategy"}]
144+
// - "spec.volumes[0].name" -> [{field: "spec"}, {field: "volumes"}, {isArray: true, index: 0}, {field: "name"}]
145+
func parsePath(path string) []pathSegment {
146+
var segments []pathSegment
147+
current := ""
148+
inBracket := false
149+
indexStr := ""
150+
151+
for i := 0; i < len(path); i++ {
152+
ch := path[i]
153+
switch ch {
154+
case '.':
155+
if inBracket {
156+
indexStr += string(ch)
157+
} else if current != "" {
158+
segments = append(segments, pathSegment{field: current})
159+
current = ""
160+
}
161+
case '[':
162+
if current != "" {
163+
segments = append(segments, pathSegment{field: current})
164+
current = ""
165+
}
166+
inBracket = true
167+
indexStr = ""
168+
case ']':
169+
if inBracket {
170+
// Parse the index
171+
var idx int
172+
if _, err := fmt.Sscanf(indexStr, "%d", &idx); err != nil {
173+
// If parsing fails, use -1 as invalid index
174+
idx = -1
175+
}
176+
segments = append(segments, pathSegment{isArray: true, index: idx})
177+
inBracket = false
178+
indexStr = ""
179+
}
180+
default:
181+
if inBracket {
182+
indexStr += string(ch)
183+
} else {
184+
current += string(ch)
185+
}
186+
}
187+
}
188+
189+
if current != "" {
190+
segments = append(segments, pathSegment{field: current})
191+
}
192+
193+
return segments
194+
}

0 commit comments

Comments
 (0)