Skip to content

Commit c5f36dd

Browse files
authored
Improve test quality: pkg/cli/health_command_test.go (#28622)
1 parent 3714aaa commit c5f36dd

2 files changed

Lines changed: 206 additions & 37 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# ADR-28622: Test Validation Logic via Real Function Invocation Rather Than Inline Re-implementation
2+
3+
**Date**: 2026-04-26
4+
**Status**: Draft
5+
**Deciders**: pelikhan, Copilot
6+
7+
---
8+
9+
## Part 1 — Narrative (Human-Friendly)
10+
11+
### Context
12+
13+
The `pkg/cli/health_command_test.go` test suite contained a `TestHealthConfigValidation` function that duplicated the days-validation logic inline rather than exercising the real `RunHealth` function. Additionally, `TestHealthCommand` used `assert.NotNil` for flag object lookups, which would cause a confusing nil-pointer panic if the assertion failed instead of stopping the test cleanly. Coverage of the JSON output path of `displayDetailedHealth` and of edge-case days values (0, -1, 91, 365) was absent. These gaps meant tests could pass even when the production validation path was broken.
14+
15+
### Decision
16+
17+
We will test the health command's validation behavior by calling `RunHealth` directly rather than re-implementing the validation predicate inside the test. We will use `require.NotNil` (and `require.NoError`) for any nil- or error-check whose failure would make subsequent assertions meaningless or panicky. New table-driven tests will cover all boundary values of the `days` parameter and the JSON output path of `displayDetailedHealth`.
18+
19+
### Alternatives Considered
20+
21+
#### Alternative 1: Keep inline validation reimplementation in tests
22+
23+
The existing approach checked `tt.config.Days != 7 && tt.config.Days != 30 && tt.config.Days != 90` directly in the test body. This keeps tests independent of `RunHealth` internals, but it duplicates the production validation predicate and will silently pass even if `RunHealth` loses its validation entirely, providing false confidence.
24+
25+
#### Alternative 2: Introduce a mock or stub for RunHealth to isolate validation
26+
27+
A dedicated `ValidateDays(days int) error` function could be extracted and tested independently without invoking `RunHealth`. This provides tighter unit isolation, but it requires production-code refactoring beyond the scope of improving existing test quality and postpones the coverage gains indefinitely.
28+
29+
### Consequences
30+
31+
#### Positive
32+
- Tests now exercise the actual `RunHealth` validation path; a regression in production validation will break tests immediately.
33+
- `require.NotNil` halts the test on nil rather than causing a confusing panic-in-assert, making failures easier to diagnose.
34+
- JSON output path of `displayDetailedHealth` gains explicit coverage, including the nil-runs and empty-runs boundary cases.
35+
36+
#### Negative
37+
- Tests now depend on `RunHealth`'s exact error message format (`"invalid days value: %d"`, `"Must be 7, 30, or 90"`), coupling test assertions to production string literals that could change independently.
38+
- Valid-days test cases cannot assert a clean nil error because `RunHealth` may return a GitHub API error in CI environments without credentials; tests must tolerate that path.
39+
40+
#### Neutral
41+
- The `require` package is introduced as an additional import alongside `assert`; both remain from the same `testify` library.
42+
- Test file line count increases significantly (37 deletions → 141 additions), which will register as a code-volume change in automated gates.
43+
44+
---
45+
46+
## Part 2 — Normative Specification (RFC 2119)
47+
48+
> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).
49+
50+
### Test Implementation
51+
52+
1. Tests for `RunHealth` **MUST** invoke `RunHealth` directly rather than re-implementing its validation predicates inline.
53+
2. Test assertions that dereference a pointer or use a value from a previous call **MUST** use `require.NotNil` or `require.NoError` (not `assert.NotNil` / `assert.NoError`) so the test halts before a nil-pointer panic can occur.
54+
3. Tests for `days` validation **MUST** cover at least the following boundary values: `0`, any negative value, `15` (a plausible but invalid value), `91`, and `365`, in addition to the three valid values `7`, `30`, `90`.
55+
4. Tests for `days` validation **MUST** assert that the returned error message contains both the invalid value (e.g., `"invalid days value: 0"`) and the set of valid options (e.g., `"Must be 7, 30, or 90"`).
56+
5. Tests that exercise JSON output paths (e.g., `displayDetailedHealth` with `JSONOutput: true`) **MUST** capture stdout, unmarshal the output as the declared JSON type, and assert at minimum that the top-level named fields match the input configuration.
57+
6. Tests **SHOULD NOT** assert a nil error for `RunHealth` calls with valid `days` values when the test environment may lack GitHub API credentials; instead they **SHOULD** assert only that the error (if any) does not contain `"invalid days value"`.
58+
59+
### Conformance
60+
61+
An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance.
62+
63+
---
64+
65+
*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/24966509091) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*

pkg/cli/health_command_test.go

Lines changed: 141 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,84 +3,188 @@
33
package cli
44

55
import (
6+
"encoding/json"
7+
"io"
8+
"os"
69
"testing"
710

811
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
913
)
1014

1115
func TestHealthConfigValidation(t *testing.T) {
1216
tests := []struct {
13-
name string
14-
config HealthConfig
15-
shouldErr bool
17+
name string
18+
config HealthConfig
19+
wantDaysErr bool
20+
errContains string
1621
}{
1722
{
18-
name: "valid 7 days",
19-
config: HealthConfig{
20-
Days: 7,
21-
Threshold: 80.0,
22-
},
23-
shouldErr: false,
23+
name: "valid 7 days",
24+
config: HealthConfig{Days: 7, Threshold: 80.0},
25+
wantDaysErr: false,
2426
},
2527
{
26-
name: "valid 30 days",
27-
config: HealthConfig{
28-
Days: 30,
29-
Threshold: 80.0,
30-
},
31-
shouldErr: false,
28+
name: "valid 30 days",
29+
config: HealthConfig{Days: 30, Threshold: 80.0},
30+
wantDaysErr: false,
3231
},
3332
{
34-
name: "valid 90 days",
35-
config: HealthConfig{
36-
Days: 90,
37-
Threshold: 80.0,
38-
},
39-
shouldErr: false,
33+
name: "valid 90 days",
34+
config: HealthConfig{Days: 90, Threshold: 80.0},
35+
wantDaysErr: false,
4036
},
4137
{
42-
name: "invalid days value",
43-
config: HealthConfig{
44-
Days: 15,
45-
Threshold: 80.0,
46-
},
47-
shouldErr: true,
38+
name: "zero days - validation error",
39+
config: HealthConfig{Days: 0, Threshold: 80.0},
40+
wantDaysErr: true,
41+
errContains: "invalid days value: 0",
42+
},
43+
{
44+
name: "negative days - validation error",
45+
config: HealthConfig{Days: -1, Threshold: 80.0},
46+
wantDaysErr: true,
47+
errContains: "invalid days value: -1",
48+
},
49+
{
50+
name: "days 15 - validation error",
51+
config: HealthConfig{Days: 15, Threshold: 80.0},
52+
wantDaysErr: true,
53+
errContains: "invalid days value: 15",
54+
},
55+
{
56+
name: "days 91 - validation error",
57+
config: HealthConfig{Days: 91, Threshold: 80.0},
58+
wantDaysErr: true,
59+
errContains: "invalid days value: 91",
60+
},
61+
{
62+
name: "days 365 - validation error",
63+
config: HealthConfig{Days: 365, Threshold: 80.0},
64+
wantDaysErr: true,
65+
errContains: "invalid days value: 365",
4866
},
4967
}
5068

5169
for _, tt := range tests {
5270
t.Run(tt.name, func(t *testing.T) {
53-
// For now, just validate the days parameter directly
54-
// since the full RunHealth needs GitHub API access
55-
if tt.config.Days != 7 && tt.config.Days != 30 && tt.config.Days != 90 {
56-
assert.True(t, tt.shouldErr, "Should error for invalid days value")
71+
err := RunHealth(tt.config)
72+
if tt.wantDaysErr {
73+
require.Error(t, err, "RunHealth should return a validation error for: %s", tt.name)
74+
assert.Contains(t, err.Error(), tt.errContains, "Error message should describe the validation failure")
5775
} else {
58-
assert.False(t, tt.shouldErr, "Should not error for valid days value")
76+
// Valid days values pass days validation; any error comes from GitHub API access
77+
if err != nil {
78+
assert.NotContains(t, err.Error(), "invalid days value", "Valid days should not produce a days validation error")
79+
}
5980
}
6081
})
6182
}
6283
}
6384

85+
func TestRunHealthInvalidDays(t *testing.T) {
86+
tests := []struct {
87+
name string
88+
days int
89+
errContains string
90+
}{
91+
{name: "zero", days: 0, errContains: "invalid days value: 0"},
92+
{name: "negative", days: -1, errContains: "invalid days value: -1"},
93+
{name: "too large 91", days: 91, errContains: "invalid days value: 91"},
94+
{name: "too large 365", days: 365, errContains: "invalid days value: 365"},
95+
{name: "not a valid option 15", days: 15, errContains: "invalid days value: 15"},
96+
{name: "not a valid option 8", days: 8, errContains: "invalid days value: 8"},
97+
}
98+
99+
for _, tt := range tests {
100+
t.Run(tt.name, func(t *testing.T) {
101+
config := HealthConfig{Days: tt.days, Threshold: 80.0}
102+
err := RunHealth(config)
103+
require.Error(t, err, "RunHealth should return an error for days=%d", tt.days)
104+
assert.Contains(t, err.Error(), tt.errContains, "Error should describe the invalid days value")
105+
assert.Contains(t, err.Error(), "Must be 7, 30, or 90", "Error should list the valid days options")
106+
})
107+
}
108+
}
109+
64110
func TestHealthCommand(t *testing.T) {
65111
cmd := NewHealthCommand()
66112

67-
assert.NotNil(t, cmd, "Health command should be created")
113+
require.NotNil(t, cmd, "Health command should be created")
68114
assert.Equal(t, "health", cmd.Name(), "Command name should be 'health'")
69115
assert.True(t, cmd.HasAvailableFlags(), "Command should have flags")
70116
assert.Contains(t, cmd.Long, "Warnings when success rate drops below threshold", "Health help should consistently use warnings terminology")
71117

72118
// Check that required flags are registered
73119
daysFlag := cmd.Flags().Lookup("days")
74-
assert.NotNil(t, daysFlag, "Should have --days flag")
120+
require.NotNil(t, daysFlag, "Should have --days flag")
75121
assert.Equal(t, "7", daysFlag.DefValue, "Default days should be 7")
76122

77123
thresholdFlag := cmd.Flags().Lookup("threshold")
78-
assert.NotNil(t, thresholdFlag, "Should have --threshold flag")
124+
require.NotNil(t, thresholdFlag, "Should have --threshold flag")
79125
assert.Equal(t, "80", thresholdFlag.DefValue, "Default threshold should be 80")
80126

81127
jsonFlag := cmd.Flags().Lookup("json")
82-
assert.NotNil(t, jsonFlag, "Should have --json flag")
128+
require.NotNil(t, jsonFlag, "Should have --json flag")
83129

84130
repoFlag := cmd.Flags().Lookup("repo")
85-
assert.NotNil(t, repoFlag, "Should have --repo flag")
131+
require.NotNil(t, repoFlag, "Should have --repo flag")
132+
}
133+
134+
func TestDisplayDetailedHealthJSON(t *testing.T) {
135+
tests := []struct {
136+
name string
137+
runs []WorkflowRun
138+
wantZeroRuns bool
139+
}{
140+
{
141+
name: "nil runs - empty JSON structure",
142+
runs: nil,
143+
wantZeroRuns: true,
144+
},
145+
{
146+
name: "empty runs slice - empty JSON structure",
147+
runs: []WorkflowRun{},
148+
wantZeroRuns: true,
149+
},
150+
}
151+
152+
for _, tt := range tests {
153+
t.Run(tt.name, func(t *testing.T) {
154+
config := HealthConfig{
155+
WorkflowName: "test-workflow",
156+
Days: 7,
157+
Threshold: 80.0,
158+
JSONOutput: true,
159+
}
160+
161+
// Redirect stdout to capture JSON output
162+
oldStdout := os.Stdout
163+
r, w, err := os.Pipe()
164+
require.NoError(t, err, "os.Pipe should not fail")
165+
os.Stdout = w
166+
167+
runErr := displayDetailedHealth(tt.runs, config)
168+
169+
w.Close()
170+
os.Stdout = oldStdout
171+
172+
require.NoError(t, runErr, "displayDetailedHealth should not return an error for %s", tt.name)
173+
174+
outputBytes, readErr := io.ReadAll(r)
175+
require.NoError(t, readErr, "Reading captured output should not fail")
176+
output := string(outputBytes)
177+
178+
require.NotEmpty(t, output, "JSON output should not be empty")
179+
180+
var health WorkflowHealth
181+
require.NoError(t, json.Unmarshal([]byte(output), &health), "Output should be valid JSON")
182+
183+
assert.Equal(t, "test-workflow", health.WorkflowName, "WorkflowName should match config")
184+
if tt.wantZeroRuns {
185+
assert.Equal(t, 0, health.TotalRuns, "TotalRuns should be zero for empty input")
186+
assert.Equal(t, 0, health.SuccessCount, "SuccessCount should be zero for empty input")
187+
}
188+
})
189+
}
86190
}

0 commit comments

Comments
 (0)