Skip to content

Commit adec92d

Browse files
dtfranzclaude
andcommitted
Concurrent Test Execution
Takes advantage of changes made to isolate test runs to execute as many tests in parallel as possible. For tests that must be run serially, the @serial tag has been added to the beginning of relevant feature file(s). Signed-off-by: Daniel Franz <dfranz@redhat.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent c8585e9 commit adec92d

3 files changed

Lines changed: 158 additions & 30 deletions

File tree

test/e2e/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,10 @@ Use these variables in YAML templates:
207207

208208
### 5. Feature Tags
209209

210+
Tags can be used for different purposes in the test suite:
211+
212+
#### Feature Gate Tags
213+
210214
Use tags to conditionally run scenarios based on feature gates:
211215

212216
```gherkin
@@ -216,6 +220,28 @@ Scenario: Install operator having webhooks
216220

217221
Scenarios are skipped if the feature gate is not enabled on the deployed controller.
218222

223+
#### Serial Execution Tag
224+
225+
By default, scenarios run concurrently (up to 100 parallel scenarios). However, some tests must run serially, typically because they:
226+
- Modify shared cluster resources (e.g., cluster-wide TLS configuration)
227+
- Have resource constraints that prevent parallel execution
228+
- Require exclusive access to a resource
229+
230+
To mark a test for serial execution, add the `@Serial` tag:
231+
232+
```gherkin
233+
@Serial
234+
Feature: TLS profile enforcement on metrics endpoints
235+
236+
Scenario: Test TLS configuration
237+
Given the "catalogd" deployment is configured with custom TLS settings
238+
...
239+
```
240+
241+
The test runner automatically separates scenarios:
242+
- Scenarios **without** `@Serial` run concurrently in the first test phase
243+
- Scenarios **with** `@Serial` run sequentially in a separate serial test phase
244+
219245
## Running Tests
220246

221247
### Run All Tests
@@ -243,6 +269,12 @@ go test test/e2e/features_test.go -- features/install.feature
243269
go test test/e2e/features_test.go --godog.tags="@WebhookProviderCertManager"
244270
```
245271

272+
Note that setting the tags in this way will disable the automatic test parallelization. If running in parallel with custom tags is desired, set `--godog.concurrency=100` for instance to re-enable. If this is done adding `&& ~@Serial` to the tags as well is highly recommended:
273+
274+
```bash
275+
go test test/e2e/features_test.go --godog.tags="@WebhookProviderCertManager && ~@Serial" --godog.concurrency=100
276+
```
277+
246278
### Run with Debug Logging
247279

248280
```bash

test/e2e/features/tls.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
@Serial
12
Feature: TLS profile enforcement on metrics endpoints
23

34
Background:

test/e2e/features_test.go

Lines changed: 125 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package e2e
22

33
import (
4+
"bytes"
45
"fmt"
6+
"io"
57
"log"
68
"os"
9+
"strings"
710
"testing"
811

912
"github.com/cucumber/godog"
@@ -14,52 +17,144 @@ import (
1417
"github.com/operator-framework/operator-controller/test/e2e/steps"
1518
)
1619

17-
var opts = godog.Options{
18-
Format: "pretty",
19-
Paths: []string{"features"},
20-
Output: colors.Colored(os.Stdout),
21-
Concurrency: 1,
22-
NoColors: true,
20+
var cliOpts = godog.Options{
21+
Format: "pretty",
22+
Paths: []string{"features"},
23+
Output: colors.Colored(os.Stdout),
24+
NoColors: true,
2325
}
2426

2527
func init() {
26-
godog.BindCommandLineFlags("godog.", &opts)
28+
godog.BindCommandLineFlags("godog.", &cliOpts)
2729
}
2830

2931
func TestMain(m *testing.M) {
3032
// parse CLI arguments
3133
pflag.Parse()
32-
opts.Paths = pflag.Args()
34+
cliOpts.Paths = pflag.Args()
3335

34-
// run tests
35-
sc := godog.TestSuite{
36+
if cliOpts.Tags != "" {
37+
fmt.Println("Note: Custom feature tags provided - disabling automatic test parallelization")
38+
// run tests explicitly as requested by CLI
39+
sc := godog.TestSuite{
40+
TestSuiteInitializer: InitializeSuite,
41+
ScenarioInitializer: InitializeScenario,
42+
Options: &cliOpts,
43+
}.Run()
44+
45+
if sc != 0 {
46+
// 1 - failed
47+
// 2 - command line usage error
48+
// 128 - or higher, os signal related error exit codes
49+
log.Fatalf("non-zero status returned: (%d), failed to run feature tests", sc)
50+
}
51+
} else {
52+
executeTestsParallel()
53+
}
54+
55+
path := os.Getenv("E2E_SUMMARY_OUTPUT")
56+
if path == "" {
57+
fmt.Println("Note: E2E_SUMMARY_OUTPUT is unset; skipping summary generation")
58+
} else {
59+
if err := testutil.PrintSummary(path); err != nil {
60+
// Fail the run if alerts are found
61+
fmt.Printf("%v", err)
62+
os.Exit(1)
63+
}
64+
}
65+
}
66+
67+
func executeTestsParallel() {
68+
// Create buffers to capture output for final summary
69+
var parallelBuf, serialBuf bytes.Buffer
70+
71+
parallelOpts := cliOpts
72+
if parallelOpts.Concurrency == 1 {
73+
// Override default concurrency value with 100; otherwise use whatever was provided by CLI
74+
parallelOpts.Concurrency = 100
75+
}
76+
parallelOpts.Tags = "~@Serial"
77+
// Write to both specified output (live to stdout, by default) and buffer (for summary)
78+
parallelOpts.Output = io.MultiWriter(parallelOpts.Output, &parallelBuf)
79+
// run tests concurrently
80+
scParallel := godog.TestSuite{
81+
TestSuiteInitializer: InitializeSuite,
82+
ScenarioInitializer: InitializeScenario,
83+
Options: &parallelOpts,
84+
}.Run()
85+
86+
fmt.Println("End of parallel run - beginning serial tests")
87+
88+
serialOpts := cliOpts
89+
serialOpts.Concurrency = 1
90+
serialOpts.Tags = "@Serial"
91+
// Write to both specified output (live to stdout, by default) and buffer (for summary)
92+
serialOpts.Output = io.MultiWriter(serialOpts.Output, &serialBuf)
93+
// run tests serially
94+
scSerial := godog.TestSuite{
3695
TestSuiteInitializer: InitializeSuite,
3796
ScenarioInitializer: InitializeScenario,
38-
Options: &opts,
97+
Options: &serialOpts,
3998
}.Run()
4099

41-
switch sc {
42-
// 0 - success
43-
case 0:
44-
45-
path := os.Getenv("E2E_SUMMARY_OUTPUT")
46-
if path == "" {
47-
fmt.Println("Note: E2E_SUMMARY_OUTPUT is unset; skipping summary generation")
48-
} else {
49-
if err := testutil.PrintSummary(path); err != nil {
50-
// Fail the run if alerts are found
51-
fmt.Printf("%v", err)
52-
os.Exit(1)
53-
}
100+
// TODO We re-print the output of any failed steps here for easier debugging. However, it would be
101+
// better to combine this with the E2E_SUMMARY_OUTPUT and show pass/fail + performance in one
102+
// markdown output then preserve the console output for local testing.
103+
104+
// Print aggregated summary
105+
fmt.Println("\n" + strings.Repeat("=", 80))
106+
fmt.Println("TEST EXECUTION SUMMARY")
107+
fmt.Println(strings.Repeat("=", 80))
108+
109+
fmt.Printf("\nParallel Tests Exit Code: %d\n", scParallel)
110+
if scParallel != 0 {
111+
failedSteps := extractFailedSteps(parallelBuf.String())
112+
if failedSteps != "" {
113+
fmt.Println("\nParallel Test Failures:")
114+
fmt.Println(strings.Repeat("-", 80))
115+
fmt.Println(failedSteps)
116+
}
117+
}
118+
119+
fmt.Printf("\nSerial Tests Exit Code: %d\n", scSerial)
120+
if scSerial != 0 {
121+
failedSteps := extractFailedSteps(serialBuf.String())
122+
if failedSteps != "" {
123+
fmt.Println("\nSerial Test Failures:")
124+
fmt.Println(strings.Repeat("-", 80))
125+
fmt.Println(failedSteps)
54126
}
55-
return
127+
}
128+
129+
fmt.Println(strings.Repeat("=", 80))
130+
131+
if scParallel != 0 || scSerial != 0 {
132+
// 1 - failed
133+
// 2 - command line usage error
134+
// 128 - or higher, os signal related error exit codes
135+
log.Fatalf("non-zero status returned; parallel: (%d), serial: (%d), failed to run feature tests", scParallel, scSerial)
136+
}
137+
}
138+
139+
// extractFailedSteps extracts the "--- Failed steps:" section from godog output
140+
func extractFailedSteps(output string) string {
141+
lines := strings.Split(output, "\n")
142+
var failedSection []string
143+
capturing := false
144+
145+
for _, line := range lines {
146+
if strings.Contains(line, "--- Failed steps:") {
147+
capturing = true
148+
}
149+
if capturing {
150+
failedSection = append(failedSection, line)
151+
}
152+
}
56153

57-
// 1 - failed
58-
// 2 - command line usage error
59-
// 128 - or higher, os signal related error exit codes
60-
default:
61-
log.Fatalf("non-zero status returned (%d), failed to run feature tests", sc)
154+
if len(failedSection) == 0 {
155+
return ""
62156
}
157+
return strings.Join(failedSection, "\n")
63158
}
64159

65160
func InitializeSuite(tc *godog.TestSuiteContext) {

0 commit comments

Comments
 (0)