From a215f4fa84b6f545a485536ce8788268f6042cca Mon Sep 17 00:00:00 2001 From: "Kevin P. Fleming" Date: Thu, 12 Mar 2026 11:18:43 -0400 Subject: [PATCH] Proposed technique for mocking standalone functions from go-fastly This demonstrates a possible technique for mocking 'new style' API functions in go-fastly that are not methods on the Client object. The techique requires a few things: 1. an exported "hook" variable in the command module which contains a function pointer which will be invoked by the command; this variable's default value is the function in go-fastly which would normally be invoked. 2. a 'MockFactory' function which returns a lambda function that mocks the behavior of the go-fastly function, and also asserts that the "input" structure passed to the function contains the parameters which were supplied as arguments in the test scenario. 3. a new flavor of CLIScenario (and its corresponding runner function) which supports the behavior above, replacing the value of the "hook" variable at the appropriate time and then restoring it when the test scenario has concluded. 4. an argument to the scenario runner which provides the address of the "hook" variable. There is a lot of code duplication between RunCLIScenario and RunAPIHookCLIScenario; that duplication can be addressed as a follow-up project. --- .../compute/computeacl/computeacl_test.go | 48 ++-- pkg/commands/compute/computeacl/create.go | 8 +- pkg/testutil/apihookscenarios.go | 234 ++++++++++++++++++ 3 files changed, 258 insertions(+), 32 deletions(-) create mode 100644 pkg/testutil/apihookscenarios.go diff --git a/pkg/commands/compute/computeacl/computeacl_test.go b/pkg/commands/compute/computeacl/computeacl_test.go index 4207b1ce6..2421272fb 100644 --- a/pkg/commands/compute/computeacl/computeacl_test.go +++ b/pkg/commands/compute/computeacl/computeacl_test.go @@ -2,12 +2,17 @@ package computeacl_test import ( "bytes" + "context" "fmt" "io" "net/http" "strings" "testing" + "github.com/fastly/go-fastly/v13/fastly" + + "github.com/stretchr/testify/require" + root "github.com/fastly/cli/pkg/commands/compute" sub "github.com/fastly/cli/pkg/commands/compute/computeacl" fstfmt "github.com/fastly/cli/pkg/fmt" @@ -26,56 +31,37 @@ func TestComputeACLCreate(t *testing.T) { ComputeACLID: aclID, } - scenarios := []testutil.CLIScenario{ + scenarios := []testutil.APIHookCLIScenario[sub.APIFunc]{ { Name: "validate missing --name flag", Args: "", WantError: "error parsing arguments: required flag --name not provided", }, - { - Name: "validate internal server error", - Args: fmt.Sprintf("--name %s", aclName), - Client: &http.Client{ - Transport: &testutil.MockRoundTripper{ - Response: &http.Response{ - StatusCode: http.StatusInternalServerError, - Status: http.StatusText(http.StatusInternalServerError), - }, - }, - }, - WantError: "500 - Internal Server Error", - }, { Name: "validate API success", Args: fmt.Sprintf("--name %s", aclName), - Client: &http.Client{ - Transport: &testutil.MockRoundTripper{ - Response: &http.Response{ - StatusCode: http.StatusOK, - Status: http.StatusText(http.StatusOK), - Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(acl)))), - }, - }, + MockFactory: func(t *testing.T) sub.APIFunc { + return func(_ context.Context, _ *fastly.Client, i *computeacls.CreateInput) (*computeacls.ComputeACL, error) { + require.Equal(t, aclName, *i.Name, "unexpected ACL name") + + return &acl, nil + } }, WantOutput: fstfmt.Success("Created compute ACL '%s' (id: %s)", aclName, aclID), }, { Name: "validate optional --json flag", Args: fmt.Sprintf("--name %s --json", aclName), - Client: &http.Client{ - Transport: &testutil.MockRoundTripper{ - Response: &http.Response{ - StatusCode: http.StatusOK, - Status: http.StatusText(http.StatusOK), - Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(acl))), - }, - }, + MockFactory: func(_ *testing.T) sub.APIFunc { + return func(_ context.Context, _ *fastly.Client, _ *computeacls.CreateInput) (*computeacls.ComputeACL, error) { + return &acl, nil + } }, WantOutput: fstfmt.EncodeJSON(acl), }, } - testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) + testutil.RunAPIHookCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios, &sub.APIFuncHook) } func TestComputeACLDelete(t *testing.T) { diff --git a/pkg/commands/compute/computeacl/create.go b/pkg/commands/compute/computeacl/create.go index bfb18eabc..b3c8338b4 100644 --- a/pkg/commands/compute/computeacl/create.go +++ b/pkg/commands/compute/computeacl/create.go @@ -15,6 +15,12 @@ import ( "github.com/fastly/cli/pkg/text" ) +type APIFunc func(context.Context, *fastly.Client, *computeacls.CreateInput) (*computeacls.ComputeACL, error) + +// APIFuncHook provides an injection point for tests to provide a +// 'mock' function to replace the function from go-fastly. +var APIFuncHook APIFunc = computeacls.Create + // CreateCommand calls the Fastly API to create a compute ACL. type CreateCommand struct { argparser.Base @@ -54,7 +60,7 @@ func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { return errors.New("failed to convert interface to a fastly client") } - acl, err := computeacls.Create(context.TODO(), fc, &computeacls.CreateInput{ + acl, err := APIFuncHook(context.TODO(), fc, &computeacls.CreateInput{ Name: &c.name, }) if err != nil { diff --git a/pkg/testutil/apihookscenarios.go b/pkg/testutil/apihookscenarios.go new file mode 100644 index 000000000..3c30d0d12 --- /dev/null +++ b/pkg/testutil/apihookscenarios.go @@ -0,0 +1,234 @@ +package testutil + +import ( + "fmt" + "io" + "os" + "slices" + "strings" + "testing" + "time" + + "github.com/fastly/go-fastly/v13/fastly" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/threadsafe" +) + +// APIHookCLIScenario represents a CLI test case to be validated, +// where the underlying go-fastly API function will be replaced with a +// mock (via a hook declared in the implementation of the command). +// +// Most of the fields in this struct are optional; if they are not +// provided RunAPIHookCLIScenario will not apply the behavior indicated for +// those fields. +type APIHookCLIScenario[T any] struct { + // Args is the input arguments for the command to execute (not + // including the command names themselves). + Args string + // ConfigPath will be copied into global.Data.ConfigPath + ConfigPath string + // ConfigFile will be copied into global.Data.ConfigFile + ConfigFile *config.File + // DontWantOutput will cause the scenario to fail if the + // string appears in stdout + DontWantOutput string + // DontWantOutputs will cause the scenario to fail if any of + // the strings appear in stdout + DontWantOutputs []string + // EnvVars contains environment variables which will be set + // during the execution of the scenario + EnvVars map[string]string + // MockFactory is a function which will return the 'mock' to + // replace the go-fastly API function; it is structured as a + // factory so that it can accept (and capture) the 'testing.T' + // for use while the mock function runs + MockFactory func(*testing.T) T + // Name appears in output when tests are executed + Name string + PathContentFlag *PathContentFlag + // Setup function can perform additional setup before the scenario is run + Setup func(t *testing.T, scenario *APIHookCLIScenario[T], opts *global.Data) + // Stdin contains input to be read by the application + Stdin []string + // Validator function can perform additional validation on the results + // of the scenario + Validator func(t *testing.T, scenario *APIHookCLIScenario[T], opts *global.Data, stdout *threadsafe.Buffer) + // WantError will cause the scenario to fail if this string + // does not appear in an Error + WantError string + // WantRemediation will cause the scenario to fail if the + // error's RemediationError.Remediation doesn't contain this string + WantRemediation string + // WantOutput will cause the scenario to fail if this string + // does not appear in stdout + WantOutput string + // WantOutputs will cause the scenario to fail if any of the + // strings do not appear in stdout + WantOutputs []string +} + +// RunAPIHookCLIScenario executes a APIHookCLIScenario struct. +// The Arg field of the scenario is prepended with the content of the 'command' +// slice passed in to construct the complete command to be executed. +func RunAPIHookCLIScenario[T any](t *testing.T, command []string, scenario APIHookCLIScenario[T], hook *T) { + t.Run(scenario.Name, func(t *testing.T) { + var ( + err error + fullargs []string + originalFunc T + stdout threadsafe.Buffer + ) + + if len(scenario.Args) > 0 { + fullargs = slices.Concat(command, SplitArgs(scenario.Args)) + } else { + fullargs = command + } + + opts := MockGlobalData(fullargs, &stdout) + + // scenarios of this type should never actually invoke + // an APIClient, but the application's startup code + // assumes that they need one and requires a factory + // to construct one + opts.APIClientFactory = func(_, _ string, _ bool) (api.Interface, error) { + fc, err := fastly.NewClientForEndpoint("no-key", "api.example.com") + if err != nil { + return nil, fmt.Errorf("failed to mock fastly.Client: %w", err) + } + return fc, nil + } + + if len(scenario.ConfigPath) > 0 { + opts.ConfigPath = scenario.ConfigPath + } + + if scenario.ConfigFile != nil { + opts.Config = *scenario.ConfigFile + } + + if scenario.EnvVars != nil { + for key, value := range scenario.EnvVars { + if err := os.Setenv(key, value); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Unsetenv(key); err != nil { + t.Fatal(err) + } + }() + } + } + + if scenario.Setup != nil { + scenario.Setup(t, &scenario, opts) + } + + // If a MockFactory function has been provided, then + // save the original go-fastly API function so it can + // be restored later, and replace it with the + // MockFactory's generated mock function + if scenario.MockFactory != nil { + originalFunc = *hook + *hook = scenario.MockFactory(t) + } + + if len(scenario.Stdin) > 1 { + // To handle multiple prompt input from the user we need to do some + // coordination around io pipes to mimic the required user behaviour. + stdin, prompt := io.Pipe() + opts.Input = stdin + + // Wait for user input and write it to the prompt + inputc := make(chan string) + go func() { + for input := range inputc { + fmt.Fprintln(prompt, input) + } + }() + + // We need a channel so we wait for `run()` to complete + done := make(chan bool) + + // Call `app.Run()` and wait for response + go func() { + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + return opts, nil + } + err = app.Run(fullargs, nil) + done <- true + }() + + // User provides input + // + // NOTE: Must provide as much input as is expected to be waited on by `run()`. + // For example, if `run()` calls `input()` twice, then provide two messages. + // Otherwise the select statement will trigger the timeout error. + for _, input := range scenario.Stdin { + inputc <- input + } + + select { + case <-done: + // Wait for app.Run() to finish + case <-time.After(time.Second): + t.Fatalf("unexpected timeout waiting for mocked prompt inputs to be processed") + } + } else { + stdin := "" + if len(scenario.Stdin) > 0 { + stdin = scenario.Stdin[0] + } + opts.Input = strings.NewReader(stdin) + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + return opts, nil + } + err = app.Run(fullargs, nil) + } + + AssertErrorContains(t, err, scenario.WantError) + if scenario.WantRemediation != "" { + AssertRemediationErrorContains(t, err, scenario.WantRemediation) + } + AssertStringContains(t, stdout.String(), scenario.WantOutput) + + for _, want := range scenario.WantOutputs { + AssertStringContains(t, stdout.String(), want) + } + + if len(scenario.DontWantOutput) > 0 { + AssertStringDoesntContain(t, stdout.String(), scenario.DontWantOutput) + } + for _, want := range scenario.DontWantOutputs { + AssertStringDoesntContain(t, stdout.String(), want) + } + + if scenario.PathContentFlag != nil { + pcf := *scenario.PathContentFlag + AssertPathContentFlag(pcf.Flag, scenario.WantError, fullargs, pcf.Fixture, pcf.Content(), t) + } + + if scenario.Validator != nil { + scenario.Validator(t, &scenario, opts, &stdout) + } + + // If a MockFactory function has been provided, then + // restore the original go-fastly API function to the + // hook variable + if scenario.MockFactory != nil { + *hook = originalFunc + } + }) +} + +// RunAPIHookCLIScenarios executes the APIHookCLIScenario structs from +// the slice passed in. +func RunAPIHookCLIScenarios[T any](t *testing.T, command []string, scenarios []APIHookCLIScenario[T], hook *T) { + for _, scenario := range scenarios { + RunAPIHookCLIScenario(t, command, scenario, hook) + } +}