Skip to content

Commit 791c3a6

Browse files
vdemeesterclaude
authored andcommitted
test: implement parallel/serial test categorization system
Add a test categorization system that enables selective execution of e2e tests based on whether they can safely run in parallel or must run serially. This optimizes test execution time while preserving safety for tests that modify shared cluster state. Uses comment annotations to mark tests: - `@test:execution=parallel` - Safe for concurrent execution - `@test:execution=serial` - Must run sequentially (modifies ConfigMaps) - `@test:reason` - Documents why serial execution is required The TestMain function in init_test.go parses these annotations using Go's AST parser and orchestrates test execution: - `-category=parallel` - Run only parallel tests - `-category=serial` - Run only serial tests - `-category=all` - Run serial first, then parallel (with fail-fast) - `-show-tests` - Display test categorization - Faster local development: run parallel tests concurrently - CI optimization: separate parallel and serial test jobs - Safety: prevents test interference from ConfigMap modifications - Self-documenting: annotations explain execution requirements Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vdemeest@redhat.com>
1 parent 2299b29 commit 791c3a6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+897
-5
lines changed

DEVELOPMENT.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ First, you may want to [Ramp up](#ramp-up) on Kubernetes and Custom Resource Def
1616
1. [Set up a docker repository 'ko' can push images to](https://github.com/knative/serving/blob/4a8c859741a4454bdd62c2b60069b7d05f5468e7/docs/setting-up-a-docker-registry.md)
1717
1. [Developing and testing](#developing-and-testing) Tekton pipelines
1818
1. Learn how to [iterate](#iterating-on-code-changes) on code changes
19+
1. [Testing](#testing)
1920
1. [Managing Tekton Objects using `ko`](#managing-tekton-objects-using-ko) in Kubernetes
2021
1. [Accessing logs](#accessing-logs)
2122
1. [Adding new CRD types](#adding-new-crd-types)
@@ -392,7 +393,6 @@ While iterating on code changes to the project, you may need to:
392393
- Update your type definitions with: `./hack/update-codegen.sh`
393394
- Update your OpenAPI specs with: `./hack/update-openapigen.sh`
394395
1. Update or [add new CRD types](#adding-new-crd-types) as needed
395-
1. Update, [add and run tests](./test/README.md#tests)
396396

397397
To make changes to these CRDs, you will probably interact with:
398398

@@ -401,6 +401,10 @@ To make changes to these CRDs, you will probably interact with:
401401
- The clients are in [./pkg/client](./pkg/client) (these are generated by
402402
`./hack/update-codegen.sh`)
403403

404+
#### Testing
405+
406+
For comprehensive testing documentation, including how to run different test suites and understand test categorization, see the [Testing Guide](./test/README.md).
407+
404408
---
405409

406410
### Managing Tekton Objects using `ko`
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/*
2+
Copyright 2025 The Tekton Authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// Package annotation provides utilities for parsing and managing test annotations
18+
// that categorize e2e tests as parallel or serial execution.
19+
package annotation
20+
21+
import (
22+
"fmt"
23+
"go/ast"
24+
"go/parser"
25+
"go/token"
26+
"os"
27+
"path/filepath"
28+
"strings"
29+
)
30+
31+
// TestInfo represents metadata about a test function
32+
type TestInfo struct {
33+
Name string
34+
File string
35+
Line int
36+
Execution string // "parallel" or "serial"
37+
Reason string // Required for serial tests
38+
}
39+
40+
// TestManifest holds categorized tests
41+
type TestManifest struct {
42+
Parallel []TestInfo
43+
Serial []TestInfo
44+
}
45+
46+
// ScanOptions configures the behavior of test annotation scanning
47+
type ScanOptions struct {
48+
RequireAnnotations bool // If true, fail on unannotated tests
49+
RequireReason bool // If true, fail on serial tests without reason
50+
}
51+
52+
// DefaultScanOptions returns the default scanning options
53+
func DefaultScanOptions() ScanOptions {
54+
return ScanOptions{
55+
RequireAnnotations: true,
56+
RequireReason: true,
57+
}
58+
}
59+
60+
// ScanTestAnnotations parses all *_test.go files in a directory and extracts test metadata.
61+
// It enforces annotation requirements based on the provided options.
62+
func ScanTestAnnotations(dir string, opts ScanOptions) (*TestManifest, error) {
63+
manifest := &TestManifest{
64+
Parallel: []TestInfo{},
65+
Serial: []TestInfo{},
66+
}
67+
68+
var unannotated []TestInfo
69+
var missingReason []TestInfo
70+
71+
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
72+
if err != nil {
73+
return err
74+
}
75+
76+
// Skip non-test files
77+
if !strings.HasSuffix(path, "_test.go") {
78+
return nil
79+
}
80+
81+
tests, err := parseTestFile(path)
82+
if err != nil {
83+
return fmt.Errorf("failed to parse %s: %w", path, err)
84+
}
85+
86+
for _, test := range tests {
87+
switch test.Execution {
88+
case "parallel":
89+
manifest.Parallel = append(manifest.Parallel, test)
90+
case "serial":
91+
if opts.RequireReason && test.Reason == "" {
92+
missingReason = append(missingReason, test)
93+
}
94+
manifest.Serial = append(manifest.Serial, test)
95+
default:
96+
unannotated = append(unannotated, test)
97+
}
98+
}
99+
100+
return nil
101+
})
102+
103+
if err != nil {
104+
return nil, err
105+
}
106+
107+
// Check for violations of requirements
108+
if opts.RequireAnnotations && len(unannotated) > 0 {
109+
return nil, fmt.Errorf("found %d test(s) without @test:execution annotation:\n%s",
110+
len(unannotated), formatTestList(unannotated))
111+
}
112+
113+
if opts.RequireReason && len(missingReason) > 0 {
114+
return nil, fmt.Errorf("found %d serial test(s) without @test:reason annotation:\n%s",
115+
len(missingReason), formatTestList(missingReason))
116+
}
117+
118+
return manifest, nil
119+
}
120+
121+
// parseTestFile extracts test metadata from a single file
122+
func parseTestFile(filename string) ([]TestInfo, error) {
123+
fset := token.NewFileSet()
124+
node, err := parser.ParseFile(fset, filename, nil, parser.ParseComments)
125+
if err != nil {
126+
return nil, err
127+
}
128+
129+
var tests []TestInfo
130+
131+
for _, decl := range node.Decls {
132+
fn, ok := decl.(*ast.FuncDecl)
133+
if !ok {
134+
continue
135+
}
136+
137+
// Only process Test functions (skip TestMain)
138+
if !strings.HasPrefix(fn.Name.Name, "Test") || fn.Name.Name == "TestMain" {
139+
continue
140+
}
141+
142+
test := TestInfo{
143+
Name: fn.Name.Name,
144+
File: filename,
145+
Line: fset.Position(fn.Pos()).Line,
146+
}
147+
148+
if fn.Doc != nil {
149+
test.Execution, test.Reason = parseAnnotations(fn.Doc)
150+
}
151+
152+
tests = append(tests, test)
153+
}
154+
155+
return tests, nil
156+
}
157+
158+
// parseAnnotations extracts execution mode and reason from function doc comments
159+
func parseAnnotations(doc *ast.CommentGroup) (execution, reason string) {
160+
for _, comment := range doc.List {
161+
text := strings.TrimSpace(strings.TrimPrefix(comment.Text, "//"))
162+
text = strings.TrimSpace(strings.TrimPrefix(text, "/*"))
163+
text = strings.TrimSpace(strings.TrimSuffix(text, "*/"))
164+
165+
if !strings.HasPrefix(text, "@test:") {
166+
continue
167+
}
168+
169+
parts := strings.SplitN(strings.TrimPrefix(text, "@test:"), "=", 2)
170+
if len(parts) != 2 {
171+
continue
172+
}
173+
174+
key := strings.TrimSpace(parts[0])
175+
value := strings.TrimSpace(parts[1])
176+
177+
switch key {
178+
case "execution":
179+
execution = value
180+
case "reason":
181+
reason = value
182+
}
183+
}
184+
185+
return
186+
}
187+
188+
// formatTestList formats a slice of TestInfo for error messages
189+
func formatTestList(tests []TestInfo) string {
190+
var sb strings.Builder
191+
for _, test := range tests {
192+
sb.WriteString(fmt.Sprintf(" - %s (%s:%d)\n", test.Name, test.File, test.Line))
193+
}
194+
return sb.String()
195+
}
196+
197+
// FilterPattern creates a regex pattern to match only the specified tests
198+
func FilterPattern(tests []TestInfo) string {
199+
if len(tests) == 0 {
200+
// No tests to run - return impossible pattern
201+
return "^$"
202+
}
203+
204+
names := make([]string, len(tests))
205+
for i, t := range tests {
206+
names[i] = t.Name
207+
}
208+
209+
// Create regex pattern: ^(Test1|Test2|Test3)$
210+
return "^(" + strings.Join(names, "|") + ")$"
211+
}

0 commit comments

Comments
 (0)