execmock

package
v0.0.0-...-01cd1ea Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 21, 2024 License: Apache-2.0 Imports: 21 Imported by: 0

Documentation

Overview

Package execmock allows mocking exec commands using the go.chromium.org/luci/common/exec ("luci exec") library, which is nearly a drop-in replacement for the "os/exec" stdlib library.

The way this package works is by registering `RunnerFunction`s, and then adding references to those inside of the Context ('mocks'). The LUCI exec library will then detect these in any constructed Command's and run the RunnerFunction in a real sub-process.

The sub-process is always an instance of the test binary itself; You hook execution of these RunnerFunctions by calling the Intercept() function in your TestMain function. TestMain will then have two modes. For the main test execution, the test binary will run an http server to serve and retrieve data from 'invocations'. For the mock sub-processes, a special environment variable will be set, and the Intercept() function will effectively hijack the test execution before the `testing.Main` function. The hijack will reach out to the http server and retrieve information about this specific invocation, including inputs tot he RunnerFunction, and execute the RunnerFunction with the provided input. Once the function is done, its output will be POST'd back to the http server, and the process will exit with the return code from the runner function.

Running these as real sub-processes has a number of advantages; In particular, application code which is written to interact with a real sub-process (e.g. passing file descriptors, sending signals, reading/writing from Stdio pipes, calling system calls like Wait() on the process, etc.) will be interacting with a 100% real process, not an emulation in a goroutine. Additionally, the RunnerFunction will get to use all `os` level functions (to read environment, working directory, look for parent process ID, etc.) and they will work correctly.

As a convenience, this library includes a `Simple` mock which can cover many very basic execution scenarios.

By default, if the Intercept() function has been called in the process, ALL commands using the luci exec library will need to have a matching mock. You can explicitly enable 'passthrough' for some executions (with the Passthrough mock).

Finally, you can simulate any error from the Start function using the StartError mock; This will not run a sub-process, but will instead produce an error from Start and other functions which call Start.

Debugging Runners

Occassionally you have a Runner which is sufficiently complex that you would need to debug it (e.g. with delve). execmock supports this by doing the following:

First, run `go test . -execmock.list`, which will print out all registered runners at the point that your test calls Intercept().

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrProcessNotStarted = errors.New("sub-process did not start yet")
View Source
var Simple = Register(simpleMocker)

Simple implements a very basic mock for executables which can read from stdin, write to stdout and stderr, and emit an exit code.

Each of the I/O operations operates in a totally independent goroutine, and all of them must complete for the Runner to exit.

Omitting the SimpleInput will result in a mock which just exits 0.

The Output for this is a string which is the stdin which the process consumed (if ConsumeStdin was set).

Functions

func Init

func Init(ctx context.Context) context.Context

Init adds a mockState to the context, which indicates that this context should mock new exec calls using it.

If `testing.Verbose()` is true (i.e. `go test -v`), this turns on "chatty mode", which will emit a log line for every exec this library intercepts, and will also copy and dump any stdout/stderr. This allows easier debugging of your RunnerFunctions.

Panics if `ctx` has already been initialized for execmock.

Example
package main

import (
	"bytes"
	"context"
	"flag"
	"fmt"
	"os"
	"path/filepath"

	"go.chromium.org/luci/common/errors"
	"go.chromium.org/luci/common/exec"
	"go.chromium.org/luci/common/exec/execmock"
)

// CustomInput is data that the TEST wants to communicate to CustomRunner.
//
// This should typically be things which affect the behavior of the Runner, e.g.
// to simulate different outputs, errors, etc., or to change the data that the
// Runner responds with.
type CustomInput struct {
	OutputFile []byte

	CollectInput bool
}

type CustomOutput struct {
	InputData []byte
}

var CustomRunner = execmock.Register(func(in *CustomInput) (*CustomOutput, int, error) {
	input := flag.String("input", "", "")
	_ = flag.String("random", "", "")
	output := flag.String("output", "", "")
	flag.Parse()

	ret := &CustomOutput{}

	if in.CollectInput {
		if *input == "" {
			return nil, 1, errors.New("input was expected")
		}
		data, err := os.ReadFile(*input)
		if err != nil {
			return nil, 1, errors.Annotate(err, "reading input file").Err()
		}
		ret.InputData = data
	}

	if in.OutputFile != nil {
		if err := os.WriteFile(*output, in.OutputFile, 0777); err != nil {
			return nil, 1, errors.Annotate(err, "writing output file").Err()
		}
	}

	return ret, 0, nil
})

func must(err error) {
	if err != nil {
		panic(err)
	}
}

func main() {
	// See this file for the definition of `CustomRunner`

	// BEGIN: TEST CODE
	ctx := execmock.Init(context.Background())

	outputUses := CustomRunner.WithArgs("--output").Mock(ctx, &CustomInput{
		OutputFile: []byte("hello I am Mx. Catopolous"),
	})
	allOtherUses := CustomRunner.Mock(ctx)
	inputUses := CustomRunner.WithArgs("--input").Mock(ctx, &CustomInput{
		CollectInput: true,
	})
	tmpDir, err := os.MkdirTemp("", "")
	must(errors.Annotate(err, "failed to create tmpDir").Err())

	defer func() {
		must(os.RemoveAll(tmpDir))
	}()
	// END: TEST CODE

	// BEGIN: APPLICATION CODE
	// Your program would then get `ctx` passed to it from the test, and it would
	// run commands as usual:

	// this should be intercepted by `allOtherUses`
	must(exec.Command(ctx, "some_prog", "--random", "argument").Run())
	fmt.Println("[some_prog --random argument]: OK")

	// this should be intercepted by `inputUses`
	inputFile := filepath.Join(tmpDir, "input_file")
	must(os.WriteFile(inputFile, []byte("hello world"), 0777))
	must(exec.Command(ctx, "another_program", "--input", inputFile).Run())
	fmt.Println("[another_program --input inputFile]: OK")

	// this should be intercepted by `outputUses`.
	outputFile := filepath.Join(tmpDir, "output_file")
	outputCall := exec.Command(ctx, "another_program", "--output", outputFile)
	outputCall.Stdout = os.Stdout
	outputCall.Stderr = os.Stderr
	must(outputCall.Run())

	outputFileData, err := os.ReadFile(outputFile)
	must(errors.Annotate(err, "our mock failed to write %q", outputFile).Err())
	if !bytes.Equal(outputFileData, []byte("hello I am Mx. Catopolous")) {
		panic(errors.New("our mock failed to write the expected data"))
	}
	fmt.Printf("[another_program --output outputFile]: %q\n", outputFileData)
	// END: APPLICATION CODE

	// BEGIN: TEST CODE
	// Back in the test after running the application code. Now we can look and
	// see what our mocks caught.
	fmt.Printf("allOtherUses: got %d calls\n", len(allOtherUses.Snapshot()))
	fmt.Printf("outputUses: got %d calls\n", len(outputUses.Snapshot()))

	incallMock, _, err := inputUses.Snapshot()[0].GetOutput(ctx)
	must(errors.Annotate(err, "could not get output from --input call").Err())
	fmt.Printf("incallMock: saw %q written to the mock program\n", incallMock.InputData)
	// END: TEST CODE

}
Output:

[some_prog --random argument]: OK
[another_program --input inputFile]: OK
[another_program --output outputFile]: "hello I am Mx. Catopolous"
allOtherUses: got 1 calls
outputUses: got 1 calls
incallMock: saw "hello world" written to the mock program

func Intercept

func Intercept()

Intercept must be called from TestMain like:

func TestMain(m *testing.M) {
	execmock.Intercept()
	os.Exit(m.Run())
}

If process flags have not yet been parsed, this will call flag.Parse().

Types

type MockCriteria

type MockCriteria struct {
	Args []string
	Env  environ.Env
}

MockCriteria are the parameters from a Command used to match an ExecMocker

func ResetState

func ResetState(ctx context.Context) []*MockCriteria

ResetState returns the list of missed MockCriteria (i.e. commands which didn't match any Mocks) and resets the state in `ctx` (wipes all existing Mock entries, misses, etc.)

This also `unseals` the state, allowing Mocker.Mock to be called on this context again.

type Mocker

type Mocker[In any, Out any] interface {
	// WithArgs will return a new Mocker which only applies to commands whose
	// argument list matches `argPattern`.
	//
	// Using this multiple times will require a command to match ALL given patterns.
	//
	// `argPattern` follows the same rules that
	// go.chromium.org/luci/common/data/text/sequence.NewPattern uses, namely:
	//
	// Tokens can be:
	//   - "/a regex/" - A regular expression surrounded by slashes.
	//   - "..." - An Ellipsis which matches any number of sequence entries.
	//   - "^" at index 0 - Zero-width matches at the beginning of the sequence.
	//   - "$" at index -1 - Zero-width matches at the end of the sequence.
	//   - "=string" - Literally match anything after the "=". Allows escaping
	//     special strings, e.g. "=/regex/", "=...", "=^", "=$", "==something".
	//   - "any other string" - Literally match without escaping.
	//
	// Panics if `argPattern` is invalid.
	WithArgs(argPattern ...string) Mocker[In, Out]

	// WithEnv will return a new Mocker which only applies to commands which
	// include an environment variable `varName` which matches `valuePattern`.
	//
	// Using this multiple times will require a command to match ALL the given
	// restrictions (even within the same `varName`).
	//
	// varName is the environment variable name (which will be matched exactly), and
	// valuePattern is either:
	//   - "/a regex/" - A regular expression surrounded by slashes.
	//   - "!" - An indicator that this envvar should be unset.
	//   - "=string" - Literally match anything after the "=". Allows escaping
	//     regex strings, e.g. "=/regex/", "==something".
	//   - "any other string" - Literally match without escaping.
	WithEnv(varName, valuePattern string) Mocker[In, Out]

	// WithLimit will restrict the number of times a Mock from this Mocker can
	// be used.
	//
	// After that many usages, the Mock will become inactive and won't match any
	// new executions.
	//
	// Calling WithLimit replaces the current limit.
	// Calling WithLimit(0) will remove the limit.
	WithLimit(limit uint64) Mocker[In, Out]

	// Mock adds a new mocker in the context, and returns the corresponding
	// Uses struct which will allow your test to see how many times this mock
	// was used, and what outputs it produced.
	//
	// Any execution which matches this Entry will run this Mocker's associated
	// RunnerFunction in a subprocess with the data `indata`.
	//
	// Supplying multiple input values will panic.
	// Supplying no input values will use a default-constructed In (especially
	// useful for None{}).
	//
	// Mocks are ordered based on the filter (i.e. what WithXXX calls in the chain
	// prior to calling Mock) once the first exec.Command/CommandContext is
	// Start()'d. The ordering criteria is as follows:
	//   * Number of LiteralMatchers in WithArgs patterns (more LiteralMatchers
	//     will be tried earlier).
	//   * Number of all matchers in WithArgs patterns (more matchers will be
	//     tried earlier).
	//   * Mocks with lower limits are tried before mocks with higher limits.
	//   * Mocks with more WithEnv entries are tried before mocks with fewer
	//     WithEnv.
	//   * Finally, mocks are tried in the order they are created (i.e. the order
	//     that Mock() here is called.
	//
	// Lastly, once `ctx` has been used to start executing commands (e.g.
	// `exec.Command(ctx, ...)`), the state in that context is `sealed`, and
	// calling Mock on that context again will panic. The state can be unsealed by
	// calling ResetState on the context.
	Mock(ctx context.Context, indata ...In) *Uses[Out]
}

A Mocker allows you to create mocks for a RunnerFunction, or to create mocks for a 'Start error' (via StartError).

Mockers are immutable, and by default a Mock would apply to ALL commands. You can create derivative Mockers using the With methods, which will only apply to commands matching that criteria.

Once you have constructed the filter you want by chaining With calls, call Mock to actually add a filtered mock into the context, supplying any input data your RunnerFunction needs (in the case of a Start error, this would be the error that will be returned from Command.Start()).

var Passthrough Mocker[None, None] = &passthroughMocker{}

Passthrough will make any matching commands just do normal, un-mocked, execution.

var StartError Mocker[error, None] = &startErrorMocker{}

StartError allows you to mock any execution with an error which will be returned from Start() (i.e. no process will actually run).

This can be used to return exec.ErrNotFound from a given invocation, or any other error (e.g. bad executable file, or whatever you like).

func Register

func Register[In any, Out any](runnerFn RunnerFunction[In, Out]) Mocker[In, Out]

Register must be called for all Runner functions in order to allow their inputs and outputs to be GoB-encoded.

Should be called in an `init()` function, or as a module level assignment in the module where the function is implemented.

In particular this must be called in such a way that other instances of this executable register the same types.

Example:

func exitCoder(retcode int) (None, int) {
  return None{}, retcode
}

var gitMocker := Register(exitCoder)
gitMocker.WithArgs("^", "git").Mock(ctx, 100)

type None

type None struct{}

None can be used as an In or Out type for a runner function which doesn't need an input or an output.

type RunnerFunction

type RunnerFunction[In any, Out any] func(indata In) (outdata Out, exitcode int, err error)

RunnerFunction is the implementation for your mocked execution; It will be invoked with `indata` value as input, and can return an `outdata` value, along with the exit code for the process.

When your code under test does `exec` and matches a MockMockor using this function, a subprocess will be spawned to run this function. When this function returns, the `outdata` and `err` will be communicated back to the ExecMocker, and then the process will exit with the returned `exitcode`.

If the function panics, execmock will generate an exit code of 1 and an error of ErrPanicedRunner.

Typically, `err` should be used when your runner detects that it was invoked incorrectly (e.g. asserting that certain arguments were present, or an input file exists, etc.). The error is only passed back from the application as a string.

All standard `os` functions and variables (e.g. environ, args, standard IO handles, etc.) will reflect the that the code under test set when running the mocked executable. Additionally `flag.CommandLine` is reset to a pristine state during the invocation to make it easier to parse input flags without picking up global flag registration from the Go "testing" library, which could interfere with flags that the RunnerFunction needs to parse.

type SimpleInput

type SimpleInput struct {
	// If true, fire off a goroutine to consume (and discard) all of Stdin.
	ConsumeStdin bool

	// If non-empty, fire off a goroutine to write this string to Stdout.
	Stdout string

	// If non-empty, fire off a goroutine to write this string to Stderr.
	Stderr string

	// Exit with this return code.
	ExitCode int

	// Emit this error back to the Usage.
	Error string
}

SimpleInput is the options you can provide to Simple.Mock.

type Usage

type Usage[Out any] struct {
	// Args and Env are the exact Args and Env of the Cmd which matched this mock.
	Args []string
	Env  environ.Env
	// contains filtered or unexported fields
}

Usage represents a single `hit` for a given mock.

func (*Usage[Out]) GetOutput

func (u *Usage[Out]) GetOutput(ctx context.Context) (value Out, panicStack string, err error)

GetOutput will block until the process writes output data (or until the provided context ends), and return the Out value written by the sub-process (if any).

If this is Usage[None] then this returns (nil, nil)

Possible errors:

  • ctx.Err() if `ctx` is Done.
  • errors which occured when reading the output from the sub-process.
  • errors which the mock itself (i.e. the RunnerFunction) returned.

func (*Usage[Out]) GetPID

func (u *Usage[Out]) GetPID() int

GetPID returns the process ID associated with this usage (i.e. the mock Process)

NOTE: This is NOT thread-safe; Due to the way that the stdlib exec library works with regard to populating Cmd.Process, you must ensure that you only call this from a thread which was sequential with the thread which called Cmd.Start() (or Run() or CombinedOutput()).

If this Usage is from a MockError invocation, this will always return nil.

Returns 0 if the process is not started.

func (*Usage[Out]) Kill

func (u *Usage[Out]) Kill() error

Kill kills mock process (usually by sending SIGKILL).

NOTE: This is NOT thread-safe; Due to the way that the stdlib exec library works with regard to populating Cmd.Process, you must ensure that you only call this from a thread which was sequential with the thread which called Cmd.Start() (or Run() or CombinedOutput()).

If this Usage is from a MockError invocation, this will always return an error.

func (*Usage[Out]) Signal

func (u *Usage[Out]) Signal(sig os.Signal) error

Signal sends a signal to the mock process.

NOTE: This is NOT thread-safe; Due to the way that the stdlib exec library works with regard to populating Cmd.Process, you must ensure that you only call this from a thread which was sequential with the thread which called Cmd.Start() (or Run() or CombinedOutput()).

If this Usage is from a MockError invocation, this will always return an error.

type Uses

type Uses[Out any] struct {
	// contains filtered or unexported fields
}

Uses is used to collect uses of a particular mock (Runner + input data).

Your test code can interrogate this object after running your code-under-test to determine how many times the corresponding mock entry was used, what sub-processes were actually launched (and handles to those processes for your test to signal/read/etc.), and, if those sub-processes finished, what `Out` data did they return.

func (*Uses[Out]) Snapshot

func (u *Uses[Out]) Snapshot() []*Usage[Out]

Snapshot retrieves a snapshot of the current Usages.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL