Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ linters:
- hcpstatuspatch
- testcasename
- testfuncname
- testfuncstructure
- sippyannotation
- guestcluster
- contextbackground
Expand Down
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,11 @@ UPSTREAM_REMOTE ?= $(shell git remote -v 2>/dev/null | grep 'openshift/hypershif
PULL_BASE_SHA ?= $(if $(UPSTREAM_REMOTE),$(shell git rev-parse $(UPSTREAM_REMOTE)/main),$(shell git rev-parse main))

.PHONY: api-lint
api-lint: $(GOLANGCI_LINT) $(KUBEAPILINTER_PLUGIN)
api-lint: $(GOLANGCI_LINT) $(KUBEAPILINTER_PLUGIN) $(HYPERSHIFTLINTER_PLUGIN)
cd api && $(GOLANGCI_LINT) run --config ./.golangci.yml --modules-download-mode=readonly -v --new-from-rev=${PULL_BASE_SHA}

.PHONY: api-lint-fix
api-lint-fix: $(GOLANGCI_LINT) $(KUBEAPILINTER_PLUGIN)
api-lint-fix: $(GOLANGCI_LINT) $(KUBEAPILINTER_PLUGIN) $(HYPERSHIFTLINTER_PLUGIN)
cd api && $(GOLANGCI_LINT) run --config ./.golangci.yml --fix -v --new-from-rev=${PULL_BASE_SHA}

.PHONY: precommit-api-lint-fix
Expand Down Expand Up @@ -156,7 +156,7 @@ hypershift-lint-all: $(GOLANGCI_LINT) $(HYPERSHIFTLINTER_PLUGIN)

.PHONY: test-linter
test-linter:
cd $(TOOLS_DIR) && $(GO) test ./hypershiftlinter/analyzers/... -count=1
cd $(TOOLS_DIR) && $(GO) test ./hypershiftlinter/... -count=1

.PHONY: verify-git-clean
verify-git-clean:
Expand Down
20 changes: 19 additions & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,25 @@ func TestBuildAzureMachineTemplate(t *testing.T) { ... }

Do not use generic or disconnected test function names like `TestNodePoolFeatures`, `TestAzureIntegration`, or `TestVariousCases`.

Each test function should map 1:1 to the function under test. If a function is complex enough to warrant many test cases, use table-driven tests within a single `Test<FunctionName>` function with the "When...it should..." naming for each case.
Each production function or method must map to at most one top-level unit test function. If a function is complex enough to warrant many test cases, use table-driven tests or `t.Run` subtests within a single test function, with the "When...it should..." naming for each case.

This limit includes tests in both the production package and its external `<package>_test` package.

Prefer `Test<ReceiverType>_<MethodName>` for method tests so methods with common names such as `Reconcile`, `Validate`, and `Run` map unambiguously:

```go
// Testing (*NodePoolReconciler).Reconcile
func TestNodePoolReconciler_Reconcile(t *testing.T) { ... }
```

For compatibility, `Test<ReceiverType><MethodName>` and `Test<MethodName>` are also accepted when they resolve to exactly one production method.

The one-to-one rule applies to ordinary unit tests. It does not apply to `TestMain`, benchmarks, fuzz tests, examples, generated files, integration/e2e/envtest/request-serving entry points, or package-level behavioral tests that do not correspond to one production function. For an intentional exception that still exercises a production function, add a declaration-level suppression with a specific reason:

```go
//nolint:hypershiftlinter // testfuncstructure: validates compatibility across multiple functions
func TestSerializationCompatibility(t *testing.T) { ... }
```

## Test Placement

Expand Down
8 changes: 8 additions & 0 deletions api/.golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,19 @@ run:
allow-parallel-runners: true
linters:
enable:
- hypershiftlinter
- kubeapilinter
disable:
- staticcheck
settings:
custom:
hypershiftlinter:
path: ../hack/tools/bin/hypershiftlinter.so
description: "Enforces HyperShift unit test function structure"
settings:
analyzers:
enable:
- testfuncstructure
kubeapilinter:
path: ../hack/tools/bin/kube-api-linter.so
description: kubeapilinter is the Kube-API-Linter and lints Kube like APIs based on API conventions and best practices.
Expand Down
58 changes: 55 additions & 3 deletions hack/tools/hypershiftlinter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,15 @@ relying on reviewer memory. That matters for several reasons:

## Analyzers

The plugin ships 9 analyzers, scoped so each rule only fires where it applies.
The plugin ships 10 analyzers, scoped so each rule only fires where it applies.

### Unit test conventions (`TESTING.md`, unit tests only)

| Analyzer | Enforces |
| -------------- | ----------------------------------------------------------------------------------------- |
| `testcasename` | Test case name fields match `When <condition>, it should <expected behavior>`. |
| `testfuncname` | Test functions do not use the `Test_` prefix; use `TestFunctionName` instead. |
| `testfuncstructure` | Top-level unit tests map one-to-one to production functions and methods. |

### E2E conventions (`test/e2e/v2/` only)

Expand Down Expand Up @@ -69,12 +70,63 @@ Relevant Makefile targets:
- `make hypershift-lint-all` — run the enabled custom analyzers against the
current tree.
- `make lint` — run normal lint checks, including these analyzers.
- `make test-linter` — run the analyzers' own unit tests
(`go test ./hypershiftlinter/analyzers/...`).
- `make test-linter` — run the plugin and analyzers' unit tests
(`go test ./hypershiftlinter/...`).

Each analyzer has [`analysistest`](https://pkg.go.dev/golang.org/x/tools/go/analysis/analysistest)-based
unit tests with good/bad `testdata/` fixtures.

## Unit test function structure and exceptions

`testfuncstructure` maps ordinary `func TestXxx(*testing.T)` declarations to
production functions and methods using package type information. Package
functions use `Test<FunctionName>`. The preferred method form is
`Test<ReceiverType>_<MethodName>`; a compact receiver form or bare method name
is accepted only when it resolves unambiguously. Longer exact production names
take precedence over scenario suffixes, so `TestReconcileErrors` maps to a real
`ReconcileErrors` function before it can be treated as another `Reconcile`
scenario.

External tests are matched to the actual package-under-test import path, not
another dependency with the same package name. Analysis facts carry production
declaration provenance and internal-test mappings into the external test
variant. This excludes exported test helpers and generated methods from the
production index and enforces the one-test limit across both package variants.

The analyzer is enabled by both the root and `api/` module lint configurations.

When several top-level tests map to one production symbol, consolidate their
scenarios into table cases or `t.Run` subtests under one test function. The
analyzer does not infer runtime coverage or require tests for every production
function. It ignores test-only helpers, package-level behavioral tests without
a production-function call, special Go test entry points, generated files, and
integration-style suites.

For an intentional exception, use the golangci-lint plugin name on the test
declaration and explain why no one-function mapping is appropriate:

```go
//nolint:hypershiftlinter // testfuncstructure: validates compatibility across multiple functions
func TestSerializationCompatibility(t *testing.T) { ... }
```

`testfuncstructure` is an analyzer inside the `hypershiftlinter` plugin, not a
standalone golangci-lint linter, so `//nolint:testfuncstructure` does not work.
The suppression affects every `hypershiftlinter` diagnostic on that declaration;
keep it declaration-scoped and specific.

Existing findings present when this rule was enabled are listed exactly by
package, file, and declaration in `analyzers/testfuncstructure/legacy.go`. These
entries are migration debt, not approved patterns. New declarations remain
enforced, including a new canonical declaration added beside a baselined split
test. Remove an entry when its test is renamed or consolidated. An accepted
limitation is that an existing declaration remains exempt while its baseline
entry exists, even if its body changes.

The linter test suite checks that every baseline entry names an existing test
declaration. Delete obsolete entries when tests are moved, renamed, or removed;
otherwise `make test-linter` fails.

## Legacy E2E utility migration exceptions

`e2eutilallowlist` prevents new v2 dependencies on `test/e2e/util` and its
Expand Down
Loading