Implement Isolated, Deterministic Test Suite for ruletype CLI Subcommands#6286
Implement Isolated, Deterministic Test Suite for ruletype CLI Subcommands#6286DharunMR wants to merge 11 commits intomindersec:mainfrom
Conversation
39a2ab5 to
59733e1
Compare
evankanderson
left a comment
There was a problem hiding this comment.
This is very cool! A few comments -- I'd like to iterate on this a little bit, but I think you picked about the right "size" of example to catch interesting cases.
This seems a bit like WireMock, but I'm not sure pulling in that dependency is worthwhile.
| assert.Equal(t, string(expected), actual, "Output does not match golden file") | ||
| } | ||
|
|
||
| //nolint:unparam // filename is currently always the same, but kept generic for architectural consistency |
There was a problem hiding this comment.
This comment no longer appears to be correct.
There was a problem hiding this comment.
You're right, my previous comment was a bit unclear. Since make lint is still flagging this as unparam in this specific file, I've updated the comment to clarify that I'm keeping the signature generic for architectural consistency across the ruletype package's test suite
There was a problem hiding this comment.
You can use "_" as the name for an unparam rather than needing a nolint directive.
There was a problem hiding this comment.
Thanks for the tip about using _ The only catch is that filename is actually being used inside the function body (filepath.Join("fixture", filename)).
If I change it to the blank identifier, I'll have to hardcode "mock_ruletypes_response.json" directly inside the function. That perfectly fixes the linter for our current tests, but if we ever add a new test case that requires a different JSON fixture, we'll have to revert it back to a parameter anyway. Anyways when refracting whole test to common file will solve it
59733e1 to
7b33423
Compare
|
@evankanderson |
evankanderson
left a comment
There was a problem hiding this comment.
Thinking more about the payload-in-JSON approach, it occurs to me that would probably be easier with an actual server implementation (which simply used a lookup from the file) than it would be using a gomock-generated mock, so that may be where things get complicated.
I'm fine with the current use and file pattern, FIWIW -- I just like to consider / explore alternatives when a larger architectural change comes in.
One other thought -- do we have an easy way to regenerate these golden files if needed? It might be nice to have a flag to the test or something which caused it to write the golden files for updates if needed.
| oldStdout := os.Stdout | ||
| r, w, _ := os.Pipe() | ||
| os.Stdout = w |
There was a problem hiding this comment.
Why are we doing this when we're already calling cmd.SetOut?
(We should probably fix places where we're leaking output not to cmd.Out)
There was a problem hiding this comment.
Good point on cmd.SetOut. I used the os.Stdout capture because there are some parts of the execution flow (specifically loggers and some underlying util functions) that bypass the Cobra output buffer and write directly to Stdout. If I only use cmd.SetOut the golden files miss that output.
I totally agree on the safety issue I'll update the test to use t.Cleanup() to ensure os.Stdout is restored correctly even if a test fails. Alternatively, if I can guarantee that the command and its dependencies only use cmd.Printf even in future, I'd prefer to remove the pipe logic entirely to keep the test clean
There was a problem hiding this comment.
Can you highlight (comment) on the output that gets missed? I'd rather fix the code to use cmd.Out properly, rather than work around it in tests -- the tests are highlighting a real issue.
There was a problem hiding this comment.
every rendered table is currently bypassing Cobra's buffer because table.go hardcodes fmt.Println(l.table.Render()). I noticed there's actually a TODO around line 27 in table.go to make this output configurable that should solve the leak globally?
| w.Close() | ||
| os.Stdout = oldStdout | ||
| var capturedStdout bytes.Buffer | ||
| _, _ = io.Copy(&capturedStdout, r) | ||
| r.Close() |
There was a problem hiding this comment.
If we actually need to do this, we should use a t.Cleanup() to undo the replacement. But I'm hoping we can simply capture cmd.Out instead.
There was a problem hiding this comment.
#6291 this pr fixes the leak , after merge i'll remove those os.Stdout
| viper.Reset() | ||
|
|
||
| createCmd.Flags().VisitAll(func(f *pflag.Flag) { | ||
| if slice, ok := f.Value.(pflag.SliceValue); ok { | ||
| _ = slice.Replace([]string{}) | ||
| } else { | ||
| _ = f.Value.Set(f.DefValue) | ||
| } | ||
| f.Changed = false | ||
| }) | ||
|
|
||
| ctrl := gomock.NewController(t) | ||
| defer ctrl.Finish() | ||
|
|
||
| mockClient := mockv1.NewMockRuleTypeServiceClient(ctrl) | ||
| tt.mockSetup(mockClient) | ||
|
|
||
| ctx := cli.WithRPCClient(context.Background(), mockClient) | ||
|
|
||
| cmd := createCmd | ||
| cmd.SetContext(ctx) | ||
|
|
||
| err := cmd.Flags().Parse(tt.args) | ||
| require.NoError(t, err) | ||
|
|
||
| _ = viper.BindPFlags(cmd.Flags()) | ||
| viper.Set("project", zeroUUID) | ||
|
|
||
| buf := new(bytes.Buffer) | ||
| cmd.SetOut(buf) | ||
| cmd.SetErr(buf) | ||
|
|
||
| oldStdout := os.Stdout | ||
| r, w, _ := os.Pipe() | ||
| os.Stdout = w |
There was a problem hiding this comment.
Can we extract a lot of these into a common setup function which each test can call? There may be a few lines (e.g. creating the mock) that might need changes, but right now this looks fairly identical to ruletype_apply_test.go.
If you're building up these common functions, I'd put them in a common_test.go, so they don't look like they are part of just one test file but then are used by other tests as well.
There was a problem hiding this comment.
Oh, and if there's cleanup work to be done afterwards (if we can't get rid of the Stdout capture), you can return a "cleanup" function as part of the setup, and then t.Cleanup(cleanup) right after calling the common setup.
There was a problem hiding this comment.
Refractored to common file in my recent commit
| } | ||
|
|
||
| //nolint:unparam // keep generic signature to match helper patterns in other test files | ||
| func loadFixture(t *testing.T, filename string, target protoreflect.ProtoMessage) { |
There was a problem hiding this comment.
protoreflect.ProtoMessage can be spelled proto.Message from google.golang.org/protobuf/proto. We don't do that as often as we should, but since this is a new file, I'm going to encourage it here.
internal/util/cli/context.go
Outdated
| func WithRPCClient(ctx context.Context, client any) context.Context { | ||
| return context.WithValue(ctx, rpcClientKey, client) | ||
| } |
There was a problem hiding this comment.
Can we store multiple RPC clients in the context using a map keyed by the proto type? Either:
| func WithRPCClient(ctx context.Context, client any) context.Context { | |
| return context.WithValue(ctx, rpcClientKey, client) | |
| } | |
| func WithRPCClient(ctx context.Context, client any) context.Context { | |
| rpcClients := cmp.Or(ctx.Value(rpcClientKey), make(map[string]any{})) | |
| rpcClients[reflect.TypeOf(client).String()] = client | |
| return context.WithValue(ctx, rpcClientKey, rpcClients) | |
| } |
or
| func WithRPCClient(ctx context.Context, client any) context.Context { | |
| return context.WithValue(ctx, rpcClientKey, client) | |
| } | |
| func WithRPCClient(ctx context.Context, client any) context.Context { | |
| return context.WithValue(ctx, reflect.TypeOf(client), client) | |
| } |
|
@evankanderson Regarding the golden files I actually already have a |
Thanks! I think I missed that with the boilerplate in the tests and elsewhere. Overall, I really like this as a way to validate and refine the output from the CLI, thank you! |
bf6e61a to
52360b7
Compare
Signed-off-by: DharunMR <maddharun56@gmail.com>
Signed-off-by: DharunMR <maddharun56@gmail.com>
Signed-off-by: DharunMR <maddharun56@gmail.com>
Signed-off-by: DharunMR <maddharun56@gmail.com>
Signed-off-by: DharunMR <maddharun56@gmail.com>
Signed-off-by: DharunMR <maddharun56@gmail.com>
Signed-off-by: DharunMR <maddharun56@gmail.com>
Signed-off-by: DharunMR <maddharun56@gmail.com>
Signed-off-by: DharunMR <maddharun56@gmail.com>
52360b7 to
c895561
Compare
Summary
This PR introduces a robust, table driven testing architecture for the ruletype CLI command lifecycle (create, apply, get, list, delete). Historically, CLI commands can be brittle to test due to tightly coupled network calls and unpredictable standard output. This implementation establishes a new pilot pattern for CLI testing in Minder by enforcing strict network boundaries (via gRPC mocking) and deterministic output verification (via Golden Files and I/O interception).
Dependency Injection & Mocking Strategy
To isolate the CLI logic from the actual Minder control plane, this test suite leverages gomock to stub the RuleTypeServiceClient.
Deterministic Output Verification (Golden Files)
Testing CLI tools requires validating not just the exit codes, but the exact string formatting (Tables, JSON, YAML) presented to the user.
Testing
go test -v ./cmd/cli/app/ruletype(100% pass rate on new logic).make lintconfirming zero regressions or stylistic violations.Proposed Future Roadmap & Refactoring
If this isolated mock driven testing approach aligns with the maintainer's vision for the CLI, I propose the following follow-up work: