Migrating from V1 to V2
The Fundamental Shift
The v1 and v2 test frameworks differ fundamentally in how they manage cluster lifecycle, not just in syntax or test organization.
In v1, each test owns its cluster lifecycle. e2eutil.NewHypershiftTest(...).Execute(...) creates a fresh cluster, runs the test function, then tears down the cluster. Tests are self-contained units that manage their own infrastructure:
func TestMyFeature(t *testing.T) {
ctx, cancel := context.WithCancel(testContext)
defer cancel()
clusterOpts := globalOpts.DefaultClusterOptions(t)
e2eutil.NewHypershiftTest(t, ctx, func(t *testing.T, g Gomega, mgtClient crclient.Client, hostedCluster *hyperv1.HostedCluster) {
// Test logic here - cluster is created, live, and will be destroyed after
}).Execute(&clusterOpts, globalOpts.Platform, globalOpts.ArtifactDir, "my-feature", globalOpts.ServiceAccountSigningKey)
}
In v2, clusters are pre-created infrastructure shared across tests. The create-guests binary provisions clusters before any tests run, and tests consume them as read-only resources via TestContext. Tests do not create or destroy clusters:
var _ = Describe("My Feature", Label("my-feature"), func() {
var testCtx *internal.TestContext
BeforeEach(func() {
testCtx = internal.GetTestContext()
Expect(testCtx).NotTo(BeNil(), "test context should be set up in BeforeSuite")
testCtx.ValidateHostedCluster()
})
It("should work correctly", func() {
// Test logic here - cluster already exists, test is read-only
})
})
Simplified example
This example omits the RegisterXxxTests + TestContextGetter pattern used by real tests. See the canonical test pattern for the full structure.
This architectural change has several implications for migration:
- Remove all cluster creation/teardown logic -
e2eutil.NewHypershiftTest,Execute(), andglobalOptsare v1-only constructs. - Tests that customized cluster creation need new infrastructure - If your v1 test created clusters with special NodePool configurations, specific release images, or non-default platform settings, you need to define a new
ClusterSpecvariant in the platform'sPlatformConfig(see Adding a New ClusterSpec). - Test setup shifts from imperative to declarative - Instead of building a test object with options, you acquire a
TestContextpointing to pre-existing infrastructure.
V1 vs V2 at a Glance
| Aspect | V1 | V2 |
|---|---|---|
| Framework | testing.T + Gomega |
Ginkgo v2 + Gomega |
| Test structure | func TestFoo(t *testing.T) + e2eutil.NewHypershiftTest |
Describe/It blocks + TestContext |
| Subtests | t.Run() |
Nested It blocks |
| Test selection | Separate binaries / build tags | Ginkgo labels + --label-filter |
| Cluster lifecycle | Per-test creation via e2eutil.NewHypershiftTest(...).Execute(...) |
Pre-created by create-guests, shared via TestContext |
| CI scripts | Inline bash in release repo | Compiled Go binaries in hypershift repo |
| Reporting | Ad-hoc (single failure → 4-5 reported failures) | Structured JUnit, one entry per It (Sippy/CR compatible) |
| Build tag | e2e |
e2ev2 (or e2ev2 && backuprestore) |
Refactoring Shared Helpers (Prerequisite)
Before porting a test, check if it calls shared helper functions in test/e2e/util/. Many helpers were originally written to accept *testing.T, not testing.TB. Since Ginkgo's GinkgoTB() returns testing.TB, these helpers cannot be called directly from v2 tests.
Recommended workflow:
- Identify all shared helpers your test calls (e.g.,
util.EnsureNoCrashingPods,util.EnsureNodeCountMatchesNodePoolReplicas) - Check each helper's signature - does it take
*testing.Tortesting.TB? - If it takes
*testing.T, refactor the helper to accepttesting.TBin a separate PR - Then port the test
Example: Lifecycle Test Helper Refactoring
PR openshift/hypershift#8527 widened 12 shared helpers from *testing.T to testing.TB as part of migrating the lifecycle tests. This pattern ensures helpers work with both v1 (*testing.T) and v2 (GinkgoTB()) tests during the transition period.
Helpers that call t.Run() need deeper refactoring
Widening *testing.T to testing.TB is not sufficient for helpers that call t.Run() internally. The testing.TB interface does not include the Run() method, so these helpers will fail to compile after the signature change. You must restructure such helpers to remove the internal t.Run() calls -- for example, by having the helper return results that the caller asserts in separate It blocks, or by splitting the helper into smaller functions that do not need subtests.
Porting Step-by-Step
Follow this checklist when migrating a test from v1 to v2:
-
Remove cluster lifecycle management - Delete
e2eutil.NewHypershiftTest,Execute(), andglobalOptscalls. Your test no longer creates or destroys clusters. -
Replace test function with
Describeblock - Change from:to:func TestFoo(t *testing.T) { ... }var _ = Describe("Feature", Label("my-label"), func() { ... }) -
Add
BeforeEachsetup - Acquire and validateTestContext:var testCtx *internal.TestContext BeforeEach(func() { testCtx = internal.GetTestContext() Expect(testCtx).NotTo(BeNil(), "test context should be set up in BeforeSuite") testCtx.ValidateHostedCluster() }) -
Replace
t.Run()subtests withItblocks - Nestedt.Run()calls become separateItspecs:// V1 t.Run("subtest A", func(t *testing.T) { ... }) t.Run("subtest B", func(t *testing.T) { ... }) // V2 It("should pass subtest A", func() { ... }) It("should pass subtest B", func() { ... }) -
Replace
t.Fatal/t.Errorfwith Gomega assertions - If your v1 test still uses rawtesting.Tassertions (not Gomega), convert toExpect().To()andEventually():// V1 if err != nil { t.Fatalf("operation failed: %v", err) } // V2 Expect(err).NotTo(HaveOccurred(), "operation failed") -
Add appropriate labels - Every
DescribeandItblock should have labels for filtering. See writing tests: labels for the label taxonomy. -
Change build tag - Update from:
to://go:build e2e(or//go:build e2ev2//go:build e2ev2 && backuprestorefor backup-restore tests) -
Add
Register*Tests()export function - Follow the canonical test pattern to make your test suite discoverable:func RegisterMyFeatureTests(getTestCtx internal.TestContextGetter) { It("should do something", func() { tc := getTestCtx() // test specs using tc }) }
Real-World Examples
PR openshift/hypershift#8527 demonstrates all these steps in practice, porting the lifecycle tests from v1 to v2.
Common Pitfalls
testing.TB lacks Run()
The testing.TB interface does not include the Run() method, so t.Run() subtests cannot be called from Ginkgo tests. You must restructure subtest logic as separate It blocks:
// WRONG - GinkgoTB() does not support Run()
t := GinkgoTB()
t.Run("subtest", func(t *testing.T) { ... })
// RIGHT - use separate It blocks
It("should pass subtest", func() { ... })
BeforeEach runs per It
BeforeEach hooks execute before every It spec. If you need one-time setup that should not repeat, use BeforeAll inside an Ordered container:
var _ = Describe("Feature", Ordered, func() {
var expensiveResource *SomeResource
BeforeAll(func() {
expensiveResource = createExpensiveResource()
})
It("test A", func() { /* uses expensiveResource */ })
It("test B", func() { /* uses expensiveResource */ })
})
Eventually needs explicit timeouts
Always pass .WithTimeout() and .WithPolling() to Eventually assertions. Without them, Gomega uses short defaults that may not suit cluster operations:
// WRONG - relies on default 1s timeout
Eventually(func() error { return checkCondition() }).Should(Succeed())
// RIGHT - explicit timeouts for cluster ops
Eventually(func() error { return checkCondition() }).
WithTimeout(5*time.Minute).
WithPolling(10*time.Second).
Should(Succeed())
Build tag
Do not forget to change the build tag. Tests with the wrong tag will not be compiled by the v2 test binaries:
// WRONG - v1 tag
//go:build e2e
// RIGHT - v2 tag
//go:build e2ev2
For backup-restore tests, use the combined tag:
//go:build e2ev2 && backuprestore
What Stays in V1
V1 tests still exist and are actively running in CI. Not all tests have been migrated to v2, and some may remain in v1 indefinitely.
Tests remain in v1 when:
- They depend heavily on
t.Run()subtest trees - Complex nested subtest logic that does not map cleanly to GinkgoItblocks. - They call helper functions that still require
*testing.T- Helpers that have not yet been widened to accepttesting.TB. - They require unique cluster configurations - Tests that need custom NodePool settings, specific platform configurations, or particular release images for which no
ClusterSpecvariant has been defined yet in the platform'sPlatformConfig.
See test/e2e/ for current v1 test locations. There is no exhaustive backlog of tests to migrate - tests are ported to v2 as platforms transition to the v2 framework and as test maintainers see value in the migration.