Skip to content
Draft
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
48 changes: 17 additions & 31 deletions pkg/commands/compute/computeacl/computeacl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -26,56 +31,37 @@ func TestComputeACLCreate(t *testing.T) {
ComputeACLID: aclID,
}

scenarios := []testutil.CLIScenario{
scenarios := []testutil.APIHookCLIScenario[sub.APIFunc]{
Copy link
Contributor Author

@kpfleming kpfleming Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generic type parameter is needed here so that the type of theMockFactory field can be based on the signature of the go-fastly API function that is being mocked. This also provides that type information for RunAPIHookCLIScenarios to use in its hook parameter.

{
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) {
Expand Down
8 changes: 7 additions & 1 deletion pkg/commands/compute/computeacl/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import (
"github.com/fastly/cli/pkg/text"
)

type APIFunc func(context.Context, *fastly.Client, *computeacls.CreateInput) (*computeacls.ComputeACL, error)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches the signature of the Create function from the computeacls package in go-fastly.


// APIFuncHook provides an injection point for tests to provide a
// 'mock' function to replace the function from go-fastly.
var APIFuncHook APIFunc = computeacls.Create
Comment on lines +20 to +22
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid this global, is there a way to make the CreateCommand take an APIFunc as a dependency? Like if you had something like

type CreateCommand struct {
	argparser.Base
	argparser.JSONOutput

	name string
	createFunc APIFunc
}

func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand {
	c := CreateCommand{
		Base: argparser.Base{
			Globals: g,
		},
		createFunc: computeacls.Create,
	}
	// ...
}

Or even making CreateCommand.CreateFunc public. Then you're using a form of dependency injection where the tests don't have to modify global state, and each test can have its own implementation of computeacls.Create that returns whatever it needs

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The object returned by NewCreateCommand is not accessible to the tests, it's stored inside Kingpin (the command-line parser we use). The interface from the tests into that is supplying a string which represents an array of command-line arguments which are then parsed and handled as if a user had typed them. It's really not a unit test, even though that's how we think of it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i run into this all the time when trying to genericize inputs for similar function groups, it's a real pain


// CreateCommand calls the Fastly API to create a compute ACL.
type CreateCommand struct {
argparser.Base
Expand Down Expand Up @@ -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 {
Expand Down
234 changes: 234 additions & 0 deletions pkg/testutil/apihookscenarios.go
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +45 to +49
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new field (compared to CLIScenario) in this structure.

// 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)
}
Comment on lines +131 to +138
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creation of the 'mock' function when needed, and storage of the existing function from the hook variable.


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
}
Comment on lines +219 to +224
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restoration of the original function to the hook variable.

})
}

// 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)
}
}
Loading