diff --git a/.travis.yml b/.travis.yml index 4119c6a..8f97b82 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,6 +22,7 @@ script: - "scripts/test-go-fmt.sh" - "gometalinter --vendor --deadline=60s --config=gometalinter.json ./..." - "go run cmd/main.go" + - "go test github.com/phase2/rig/util" notifications: flowdock: diff --git a/cli/testing/assert.go b/cli/testing/assert.go new file mode 100644 index 0000000..fe329f4 --- /dev/null +++ b/cli/testing/assert.go @@ -0,0 +1,38 @@ +// Code in this file originally copied from https://github.com/benbjohnson/testing +// @see https://medium.com/@benbjohnson/structuring-tests-in-go-46ddee7a25c +package testing + +import ( + "fmt" + "path/filepath" + "reflect" + "runtime" + "testing" +) + +// assert fails the test if the condition is false. +func Assert(tb testing.TB, condition bool, msg string, v ...interface{}) { + if !condition { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d: "+msg+"\033[39m\n\n", append([]interface{}{filepath.Base(file), line}, v...)...) + tb.FailNow() + } +} + +// ok fails the test if an err is not nil. +func Ok(tb testing.TB, err error) { + if err != nil { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d: unexpected error: %s\033[39m\n\n", filepath.Base(file), line, err.Error()) + tb.FailNow() + } +} + +// equals fails the test if exp is not equal to act. +func Equals(tb testing.TB, exp, act interface{}) { + if !reflect.DeepEqual(exp, act) { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d:\n\n\texpected: %#v\n\n\tactual: %#v\033[39m\n\n", filepath.Base(file), line, exp, act) + tb.FailNow() + } +} diff --git a/cli/testing/testing.go b/cli/testing/testing.go new file mode 100644 index 0000000..e299b1d --- /dev/null +++ b/cli/testing/testing.go @@ -0,0 +1,122 @@ +// Testing package provides helpers to facilitate rig testing. +// See additional documentation: https://gist.github.com/grayside/ffeb68fa342cecf1ec158c011cbd2ea3 +package testing + +import ( + "fmt" + "os" + "os/exec" + "strings" + "testing" +) + +// ExecMockSet provides a set of unique mocks. The key matches the remote execution script. +type ExecMockSet map[string]string + +// ExecMockCollection is a map of ExecMockSets. The key is a category of mocks, such as +// "success" or "notfound". +type ExecMockCollection map[string]ExecMockSet + +var mock ExecMockCollection + +// SetMockValues provides an easy setter that allows the TestMain implementation +// of individual test files to preload the potential values to use. +func SetMockByType(namespace string, mockSet ExecMockSet) { + if mock == nil { + mock = make(ExecMockCollection) + } + mock[namespace] = mockSet +} + +// TestMain is a special function that takes over handling the behavior of the the test runner `go test` +// generates to execute your code. I do not know if you can have one per file or one for a project's entire +// collection of tests. +// +// In the example below, we rely on an environment variable: `GO_TEST_MODE` to determine whether the +// testing process will behave normally (running all test and handling the result, done by default) or +// will behavior in a special manner because we have tailored the way an exec.Command() will execute +// to flow through this logic instead of what was originally intended. +// +// You may be wondering, why would we go to such an elaborate length to mock the result of the a shell +// execution? Well, if we directly interpolated the mocked value for the command, the resulting object +// would be a string, and not the expected structure the code might be looking for as a result of executing +// a remote command. +// +// To use this function, implement TestMain in your own class, then call: +// +// testing.MainTestProcess(m) +func MainTestProcess(m *testing.M) { + switch os.Getenv("GO_TEST_MODE") { + case "": + // Normal test mode. + os.Exit(m.Run()) + + case "echo": + // Outputs the arguments passed to the test runner. + // This will be the command that would have executed under normal runtime. + // This mode can be used to test that we can predict programmatically assembled command that would be executed. + fmt.Println(strings.Join(os.Args[1:], " ")) + + case "succeed": + os.Exit(0) + + case "fail": + os.Exit(42) + + case "mock": + if mock != nil { + // Used the command that would be executed under normal runtime as the key to our mock value map and outputs the value. + // I am still researching how to adjust this overall pattern to centralize the code as test helpers but allow individual + // test files to supply their own mock. + fmt.Printf("%s", mock["success"][strings.Join(os.Args[1:], " ")]) + } + } +} + +// MockExecCommand uses fakeExecCommand to transform the intended remote execution +// into something controlled by the test runner, then adds an environment variable to +// the command so TestMain routes it to the command "mock" functionality. +func MockExecCommand(command string, args ...string) *exec.Cmd { + cmd := fakeExecCommand(command, args...) + cmd.Env = append(cmd.Env, "GO_TEST_MODE=mock") + return cmd +} + +// EchoExecCommand uses fakeExecCommand to transform the intended remote execution +// into something controlled by the test runner, then adds an environment variable to +// the command so TestMain routes it to the command "echo" functionality. +func EchoExecCommand(command string, args ...string) *exec.Cmd { + cmd := fakeExecCommand(command, args...) + cmd.Env = append(cmd.Env, "GO_TEST_MODE=echo") + return cmd +} + +// SucceedExecCommand uses fakeExecCommand to transform the intended remote execution +// into something controlled by the test runner, then adds an environment variable to +// the command so TestMain routes it to the command "success" functionality. +func SuccessExecCommand(command string, args ...string) *exec.Cmd { + cmd := fakeExecCommand(command, args...) + cmd.Env = append(cmd.Env, "GO_TEST_MODE=success") + return cmd +} + +// FailExecCommand uses fakeExecCommand to transform the intended remote execution +// into something controlled by the test runner, then adds an environment variable to +// the command so TestMain routes it to the command "fail" functionality. +func FailExecCommand(command string, args ...string) *exec.Cmd { + cmd := fakeExecCommand(command, args...) + cmd.Env = append(cmd.Env, "GO_TEST_MODE=fail") + return cmd +} + +// fakeExecCommand creates a new reference to an exec.Cmd object which has been transformed +// to use the supplied parameters as arguments to be submitted to our test runner binary. +// It should never be used directly. +func fakeExecCommand(command string, args ...string) *exec.Cmd { + testArgs := []string{command} + testArgs = append(testArgs, args...) + cmd := exec.Command(os.Args[0], testArgs...) + cmd.Env = []string{} + + return cmd +} diff --git a/cli/util/docker_test.go b/cli/util/docker_test.go new file mode 100644 index 0000000..5da203d --- /dev/null +++ b/cli/util/docker_test.go @@ -0,0 +1,96 @@ +package util_test + +import ( + "testing" + + rigtest "github.com/phase2/rig/cli/testing" + "github.com/phase2/rig/cli/util" +) + +// mock provides mock values to use as lookup responses to functions we will execute in our production code. +// The idea is to use the command as a lookup key to the result it might generate. +// Currently it only supports a single value, in the future this may be split into multiple maps for different, +// generic classes of success and failure. We cannot use multiple values for entries in this map because each response +// is expected to be a string that an executed command would return to Stdout. +var mockSet = rigtest.ExecMockSet{ + "docker --version": "Docker version 17.09.0-ce, build afdb6d4", + "docker-machine ssh gastropod docker version --format {{.Server.APIVersion}}": "1.30", + "docker version --format {{.Client.APIVersion}}": "1.30", + "docker-machine ssh gastropod docker version --format {{.Server.MinAPIVersion}}": "1.12", + "docker inspect --format {{.Created}} outrigger/dust": "2017-09-18T21:43:00.565978065Z", +} + +func init() { + rigtest.SetMockByType("success", mockSet) +} + +// TestGetRawCurrentDockerVersion confirms successful Docker version extraction. +func TestGetRawCurrentDockerVersion(t *testing.T) { + // In case some other functionality has swapped out this value, we will store + // it explicitly rather than assume it is exec.Command. + stashCommand := util.ExecCommand + // Re-define util.ExecCommand so our runtime code executes using the mocking functionality. + // I thought util.ExecCommand would be a private variable in file scope, apparently sharing the package + // is enough to access and manipulate it. Or perhaps test functions have special scope rules? + util.ExecCommand = rigtest.MockExecCommand + // Put back the original behavior after we are done with this test function. + defer func() { util.ExecCommand = stashCommand }() + // Run the code under test. + actual := util.GetRawCurrentDockerVersion() + rigtest.Equals(t, "17.09.0-ce", actual) +} + +// TestGetCurrentDockerVersion confirms successful processing of Docker version into version object. +// For more thoroughly commented exec wrangling details see TestGetRawCurrentDockerVersion. +func TestGetCurrentDockerVersion(t *testing.T) { + stashCommand := util.ExecCommand + util.ExecCommand = rigtest.MockExecCommand + defer func() { util.ExecCommand = stashCommand }() + actual, err := util.GetDockerServerApiVersion("gastropod") + rigtest.Ok(t, err) + rigtest.Equals(t, "1.30.0", actual.String()) +} + +// TestGetDockerServerApiVersion confirms successful Docker client version extraction. +// For more thoroughly commented exec wrangling details see TestGetRawCurrentDockerVersion. +func TestGetDockerClientApiVersion(t *testing.T) { + stashCommand := util.ExecCommand + util.ExecCommand = rigtest.MockExecCommand + defer func() { util.ExecCommand = stashCommand }() + actual := util.GetDockerClientApiVersion() + rigtest.Equals(t, "1.30.0", actual.String()) +} + +// TestGetDockerServerApiVersion confirms successful Docker server version extraction. +// For more thoroughly commented exec wrangling details see TestGetRawCurrentDockerVersion. +func TestGetDockerServerApiVersion(t *testing.T) { + stashCommand := util.ExecCommand + util.ExecCommand = rigtest.MockExecCommand + defer func() { util.ExecCommand = stashCommand }() + actual, err := util.GetDockerServerApiVersion("gastropod") + rigtest.Ok(t, err) + rigtest.Equals(t, "1.30.0", actual.String()) +} + +// TestGetDockerServerMinApiVersion confirms successful Docker minimum API compatibility version. +// For more thoroughly commented exec wrangling details see TestGetRawCurrentDockerVersion. +func TestGetDockerServerMinApiVersion(t *testing.T) { + stashCommand := util.ExecCommand + util.ExecCommand = rigtest.MockExecCommand + defer func() { util.ExecCommand = stashCommand }() + actual, err := util.GetDockerServerMinApiVersion("gastropod") + rigtest.Ok(t, err) + rigtest.Equals(t, "1.12.0", actual.String()) +} + +// TestImageOlderThan confirms image age evaluation. +// For more thoroughly commented exec wrangling details see TestGetRawCurrentDockerVersion. +// @TODO identify how to mock the current time so we can test this more completely. +func TestImageOlderThan(t *testing.T) { + stashCommand := util.ExecCommand + util.ExecCommand = rigtest.MockExecCommand + defer func() { util.ExecCommand = stashCommand }() + older, _, err := util.ImageOlderThan("outrigger/dust", 86400*30) + rigtest.Ok(t, err) + rigtest.Assert(t, older, "Image is older than 30 days ago but reporting as newer.", "howdy") +} diff --git a/cli/util/logger_test.go b/cli/util/logger_test.go new file mode 100644 index 0000000..24d4ee9 --- /dev/null +++ b/cli/util/logger_test.go @@ -0,0 +1,18 @@ +package util_test + +import ( + "testing" + + rigtest "github.com/phase2/rig/cli/testing" + "github.com/phase2/rig/cli/util" +) + +func TestLoggerInit(t *testing.T) { + util.LoggerInit(false) + logger := util.Logger() + rigtest.Assert(t, !logger.IsVerbose, "Logger initialized in Verbose mode.") + + util.LoggerInit(true) + logger = util.Logger() + rigtest.Assert(t, logger.IsVerbose, "Logger initialized in non-Verbose mode.") +} diff --git a/cli/util/shell_exec_test.go b/cli/util/shell_exec_test.go new file mode 100644 index 0000000..e29e4b0 --- /dev/null +++ b/cli/util/shell_exec_test.go @@ -0,0 +1,18 @@ +package util_test + +import ( + "testing" + + rigtest "github.com/phase2/rig/cli/testing" + "github.com/phase2/rig/cli/util" +) + +// TestPassThruCommand confirms we receive the exit code. +// For more thoroughly commented exec wrangling details see docker_test.go::TestGetRawCurrentDockerVersion. +func TestPassthruCommand(t *testing.T) { + actual := util.PassthruCommand(rigtest.SuccessExecCommand("ls")) + rigtest.Equals(t, 0, actual) + + actual = util.PassthruCommand(rigtest.FailExecCommand("ls")) + rigtest.Equals(t, 42, actual) +} diff --git a/cli/util/util_test.go b/cli/util/util_test.go new file mode 100644 index 0000000..33b19fe --- /dev/null +++ b/cli/util/util_test.go @@ -0,0 +1,14 @@ +package util_test + +import ( + "testing" + + rigtest "github.com/phase2/rig/cli/testing" +) + +// Controls the test execution fo the util sub-package. +// Note that if tests were to be run for the entire package cross-package +// duplication of this function would cause it to explode. +func TestMain(m *testing.M) { + rigtest.MainTestProcess(m) +}