This file provides context for AI agents working with the HyperFleet E2E testing framework.
Black-box E2E testing framework for HyperFleet cluster lifecycle management. Built with Go, Ginkgo, and OpenAPI-generated clients. Tests create ephemeral clusters for complete isolation.
make buildBinary output: bin/hyperfleet-e2e
make checkThis runs: format check, vet, lint, and unit tests.
make fmt # Format code
make fmt-check # Verify formatting
make vet # Run go vet
make lint # Run golangci-lint
make test # Run unit testsRequired after OpenAPI schema updates:
make generateExtracts schema from hyperfleet-api-spec Go module (pinned via hack/tools.go) and regenerates pkg/api/openapi/.
export HYPERFLEET_API_URL=https://api.hyperfleet.example.com
make build
./bin/hyperfleet-e2e test --label-filter=tier0make cleanBefore submitting changes, verify:
- Format:
make fmt - Generate:
make generate(if OpenAPI schema or config changed) - Lint:
make lint(must pass with zero errors) - Vet:
make vet(must pass) - Unit Tests:
make test(all tests must pass) - Build:
make build(binary must compile) - E2E Tests: Optional, but recommended for test changes
- Extension: Use
.goNOT_test.go - Location:
e2e/{resource-type}/descriptive-name.go - Package: Match directory name (e.g., package
clusterfore2e/cluster/) - Test Name: Format as
[Suite: component] Description(e.g.,[Suite: cluster] Create Cluster via API)
Example:
package cluster
var testName = "[Suite: cluster] Create Cluster via API"Every test MUST have exactly one severity label:
import "github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/labels"
var _ = ginkgo.Describe(testName,
ginkgo.Label(labels.Tier0), // Critical severity
func() { ... }
)Available labels:
- Severity (required):
Tier0,Tier1,Tier2 - Optional:
Negative,Performance,Upgrade,Disruptive,Slow
Required structure:
var _ = ginkgo.Describe(testName, ginkgo.Label(...), func() {
var h *helper.Helper
var resourceID string
ginkgo.BeforeEach(func() {
h = helper.New()
})
ginkgo.It("description", func(ctx context.Context) {
ginkgo.By("step description")
// test logic
})
ginkgo.AfterEach(func(ctx context.Context) {
if h == nil || resourceID == "" {
return
}
if err := h.CleanupTestCluster(ctx, resourceID); err != nil {
ginkgo.GinkgoWriter.Printf("Warning: cleanup failed: %v\n", err)
}
})
})Use ginkgo.By() for major steps ONLY. Do NOT use inside Eventually closures:
// CORRECT
ginkgo.By("waiting for cluster to become Reconciled")
Eventually(h.PollCluster(ctx, clusterID), timeout, h.Cfg.Polling.Interval).
Should(helper.HaveResourceCondition(client.ConditionTypeReconciled, openapi.ResourceConditionStatusTrue))
// INCORRECT - never do this
Eventually(func() {
ginkgo.By("checking status") // ❌ Wrong
// ...
}).Should(Succeed())Use pollers (thin functions returning current state) with custom matchers (reusable assertions). This keeps Eventually visible at the call site and avoids combinatorial helper function explosion.
Wait for a resource condition (cluster or nodepool):
Eventually(h.PollCluster(ctx, clusterID), h.Cfg.Timeouts.Cluster.Reconciled, h.Cfg.Polling.Interval).
Should(helper.HaveResourceCondition(client.ConditionTypeReconciled, openapi.ResourceConditionStatusTrue))
Eventually(h.PollNodePool(ctx, clusterID, npID), h.Cfg.Timeouts.NodePool.Reconciled, h.Cfg.Polling.Interval).
Should(helper.HaveResourceCondition(client.ConditionTypeReconciled, openapi.ResourceConditionStatusTrue))Wait for adapter conditions (works for both cluster and nodepool adapters):
Eventually(h.PollClusterAdapterStatuses(ctx, clusterID), timeout, h.Cfg.Polling.Interval).
Should(helper.HaveAllAdaptersWithCondition(h.Cfg.Adapters.Cluster, client.ConditionTypeFinalized, openapi.AdapterConditionStatusTrue))
Eventually(h.PollNodePoolAdapterStatuses(ctx, clusterID, npID), timeout, h.Cfg.Polling.Interval).
Should(helper.HaveAllAdaptersAtGeneration(h.Cfg.Adapters.NodePool, expectedGen))Wait for hard-delete (resource returns 404):
Eventually(h.PollClusterHTTPStatus(ctx, clusterID), timeout, h.Cfg.Polling.Interval).
Should(Equal(http.StatusNotFound))Wait for namespace cleanup:
Eventually(h.PollNamespacesByPrefix(ctx, clusterID), timeout, h.Cfg.Polling.Interval).
Should(BeEmpty())For one-off complex assertions, use Eventually with func(g Gomega) and g.Expect() (not Expect()):
Eventually(func(g Gomega) {
statuses, err := h.Client.GetClusterStatuses(ctx, clusterID)
g.Expect(err).NotTo(HaveOccurred())
// complex multi-field validation...
}, timeout, h.Cfg.Polling.Interval).Should(Succeed())Available pollers: PollCluster, PollNodePool, PollClusterAdapterStatuses, PollNodePoolAdapterStatuses, PollClusterHTTPStatus, PollNodePoolHTTPStatus, PollNamespacesByPrefix — see pkg/helper/pollers.go.
Available matchers: HaveResourceCondition, HaveAllAdaptersWithCondition, HaveAllAdaptersAtGeneration — see pkg/helper/matchers.go.
Do NOT create WaitFor* wrapper functions that hide Eventually inside helpers.
ALWAYS implement cleanup in AfterEach:
ginkgo.AfterEach(func(ctx context.Context) {
if h == nil || clusterID == "" {
return
}
if err := h.CleanupTestCluster(ctx, clusterID); err != nil {
ginkgo.GinkgoWriter.Printf("Warning: failed to cleanup cluster %s: %v\n", clusterID, err)
}
})Test payloads in testdata/payloads/ support Go templates for dynamic values:
{
"name": "cluster-{{.Random}}",
"labels": {
"created-at": "{{.Timestamp}}"
}
}Available variables: .Random, .Timestamp. See pkg/client/payload.go.
- Modify generated code: Never edit files in
pkg/api/openapi/- these are generated bymake generate - Use
_test.gosuffix: Test files must use.goextension - Hardcode timeouts: Use
h.Cfg.Timeouts.*values from config - Skip cleanup: Always implement
AfterEachcleanup - Commit without checks: Always run
make checkbefore committing - Use
ginkgo.By()inEventually: Only use at top-level test steps - Import test packages: Do NOT import
e2e/*packages in production code - Edit OpenAPI schema: Schema is maintained in hyperfleet-api-spec repo
- Create
WaitFor*wrapper functions: Use pollers + custom matchers instead (see Async Operations)
- Use pollers + matchers: Prefer
Eventually(h.PollCluster(...)).Should(helper.HaveResourceCondition(...))over rawEventuallywith inline closures - Use config values:
h.Cfg.Timeouts.*for timeouts,h.Cfg.Polling.*for intervals - Store resource IDs: Save IDs in variables for cleanup
- Check errors: Use
Expect(err).NotTo(HaveOccurred()) - Use context: All test functions receive
context.Context
- Create file:
e2e/{suite}/descriptive-name.go - Copy structure from existing test
- Update test name, labels, and logic
- Run validation checklist
- Test locally before submitting PR
When hyperfleet-api-spec changes:
# Bump spec module version
go get github.com/openshift-hyperfleet/hyperfleet-api-spec@vX.Y.Z
# Regenerate client code
make generate
# Verify changes compile
make build# Build and run specific tests
make build
./bin/hyperfleet-e2e test --focus "\[Suite: cluster\]"
# Run critical tests only
./bin/hyperfleet-e2e test --label-filter=tier0
# Debug mode
./bin/hyperfleet-e2e test --log-level=debugPriority (highest to lowest):
- CLI flags:
--api-url,--log-level - Environment variables:
HYPERFLEET_API_URL - Config file:
configs/config.yaml - Built-in defaults
cluster, err := h.Client.CreateClusterFromPayload(ctx, "testdata/payloads/clusters/cluster-request.json")
Expect(err).NotTo(HaveOccurred())
clusterID = *cluster.IdEventually(h.PollCluster(ctx, clusterID), h.Cfg.Timeouts.Cluster.Reconciled, h.Cfg.Polling.Interval).
Should(helper.HaveResourceCondition(client.ConditionTypeReconciled, openapi.ResourceConditionStatusTrue))Eventually(h.PollClusterAdapterStatuses(ctx, clusterID), h.Cfg.Timeouts.Adapter.Processing, h.Cfg.Polling.Interval).
Should(helper.HaveAllAdaptersAtGeneration(h.Cfg.Adapters.Cluster, expectedGen))hasReconciled := h.HasResourceCondition(cluster.Status.Conditions, client.ConditionTypeReconciled, openapi.ResourceConditionStatusTrue)
Expect(hasReconciled).To(BeTrue())- Getting Started - Run first test in 10 minutes
- Architecture - Framework design
- Development - Detailed test writing guide
- CONTRIBUTING.md - Contribution guidelines