internal/buildpacktest/buildpacktest.go (216 lines of code) (raw):

// Copyright 2021 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package buildpacktest contains utilities for testing buildpacks that // use the `gcpbuildpack` package. package buildpacktest import ( "errors" "flag" "fmt" "io/ioutil" "log" "os" "os/exec" "path" "path/filepath" "regexp" "strings" "testing" "github.com/GoogleCloudPlatform/buildpacks/internal/buildpacktestenv" "github.com/GoogleCloudPlatform/buildpacks/internal/mockprocess" "github.com/GoogleCloudPlatform/buildpacks/pkg/env" "github.com/GoogleCloudPlatform/buildpacks/pkg/fileutil" gcp "github.com/GoogleCloudPlatform/buildpacks/pkg/gcpbuildpack" ) var ( flagTestData string // Path to directory or archive containing source test data. ) // defineFlags sets up flags that control the behavior of the test runner. func defineFlags() { flag.StringVar(&flagTestData, "test-data", "", "Location of the test data files.") } func init() { defineFlags() } type buildpackPhase string const ( detectPhase buildpackPhase = "Detect" buildPhase buildpackPhase = "Build" // runTestAsHelperProcessEnv is an env variable that signals the current // golang test being run is actually a child process of the main golang // test process. The child process is used to execute the buildpack phase // under test without impacting the main test process. The env value is // the buildpackPhase to execute. // // This is similar to how the exec package tests exec.Command // (see https://golang.org/src/os/exec/exec_test.go). runTestAsHelperProcessEnv = "RUN_TEST_AS_HELPER_PROCESS" ) type config struct { buildpackPhase buildpackPhase buildFn gcp.BuildFn detectFn gcp.DetectFn testName string files map[string]string envs []string stack string want int appPath string mockProcesses []*mockprocess.Mock codeDir string } // Result encapsulates the result of a buildpack phase ran as a child process. type Result struct { // Output is the combined stdout and stderr of executing the build function // or detect function in a child process. Almost all buildpack output is // logged to stderr. Debug mode is on for tests, so all ctx.Exec commands // will be logged to stderr. Stdout and stderr from ctx.Exec calls end up // being printed to stderr by the `gcpbuildpack` package. // // Some extraneous Go test output appears in the Output here due to // re-using the main test binary as the entrypoint for the child process. Output string // ExitCode is the exit code of the child process that ran the buildpack // function. ExitCode int } // CommandExecuted returns true if the command was executed using ctx.Exec, otherwise returns false. func (r *Result) CommandExecuted(command string) bool { re := regexp.MustCompile(fmt.Sprintf(`(?s)Running.*%s.*Done`, command)) return re.FindString(r.Output) != "" } // Option is a type for buildpack test options. type Option func(cfg *config) // WithTestName specifies the test case name if a table-driven test is being // used. This is important when invoking the test binary again as a child // process to execute the buildpack phase. func WithTestName(testName string) Option { return func(cfg *config) { cfg.testName = testName } } // WithApp specifies an app, by directory name, to build from testdata. func WithApp(appName string) Option { return func(cfg *config) { cfg.appPath = appName } } // WithEnvs specifies env vars to set for the buildpack test. func WithEnvs(envs ...string) Option { return func(cfg *config) { cfg.envs = envs } } // WithFiles specifies files, by directory name and contents to generate for tests. func WithFiles(files map[string]string) Option { return func(cfg *config) { cfg.files = files } } // WithTempDir specifies a TempDir name to use instead of the default temp func WithTempDir(dir string) Option { return func(cfg *config) { cfg.codeDir = dir } } // WithExecMocks mocks the behavior of shell commands. func WithExecMocks(mocks ...*mockprocess.Mock) Option { return func(cfg *config) { if cfg.mockProcesses == nil { cfg.mockProcesses = []*mockprocess.Mock{} } cfg.mockProcesses = append(cfg.mockProcesses, mocks...) } } // TestDetect is a helper for testing a buildpack's implementation of /bin/detect. // This MUST be called from a test function with the name `func TestDetect(t *testing.T)` // A child process will be started that looks for that test name. The child // process will run a buildpack phase instead of the test again, however. func TestDetect(t *testing.T, detectFn gcp.DetectFn, testName string, files map[string]string, envs []string, want int) { TestDetectWithStack(t, detectFn, testName, files, envs, "com.stack", want) } // TestDetectWithStack is a helper for testing a buildpack's implementation of // /bin/detect which allows setting a custom stack name. This MUST be called // from a test function with the stub `func TestDetectWithStack(t *testing.T)`. // A child process will be started that looks for that test name. The child // process will run a buildpack phase instead of the test again, however. func TestDetectWithStack(t *testing.T, detectFn gcp.DetectFn, testName string, files map[string]string, envs []string, stack string, want int) { result, err := RunDetectPhaseForTest(t, detectFn, testName, files, envs, stack, want) if result.ExitCode != want { t.Errorf("unexpected exit status %d, want %d", result.ExitCode, want) t.Errorf("\ncombined stdout, stderr: %s", result.Output) } if err == nil && want != 0 { t.Errorf("unexpected exit status 0, want %d", want) t.Errorf("\ncombined stdout, stderr: %s", result.Output) } } // RunBuild is a helper for testing a buildpack's implementation of /bin/build. // This MUST be called from a test function with the stub `func TestBuild(t *testing.T)` // A child process will be started that looks for that test name. The child // process will run a buildpack phase instead of the test again, however. func RunBuild(t *testing.T, buildFn gcp.BuildFn, opts ...Option) (*Result, error) { t.Helper() cfg := &config{ buildpackPhase: buildPhase, buildFn: buildFn, } for _, o := range opts { o(cfg) } return runBuildpackPhaseForTest(t, cfg) } // RunDetectPhaseForTest runs a detect phase and reports the outcome. func RunDetectPhaseForTest(t *testing.T, detectFn gcp.DetectFn, testName string, files map[string]string, envs []string, stack string, want int) (*Result, error) { return runBuildpackPhaseForTest(t, &config{ buildpackPhase: detectPhase, detectFn: detectFn, testName: testName, files: files, envs: envs, stack: stack, want: want, }) } // runBuildpackPhaseForTest runs a buildpack phase as a separate child process. // A child process is used to avoid the test suite itself being terminated by // errant calls to os.Exit() in the buildpack. func runBuildpackPhaseForTest(t *testing.T, cfg *config) (*Result, error) { testDir, err := os.Getwd() if err != nil { t.Fatalf("getting working directory: %v", err) } if bp := os.Getenv(runTestAsHelperProcessEnv); bp != "" { runBuildpackPhaseMain(t, cfg) } else { // Invoke buildpack phase in a separate process. This is done // by executing the current tests again in a separate process and adding // the env var that signals the buildpack phase should be run (args[0] // is the current running binary). testBinary := filepath.Join(testDir, os.Args[0]) // Allows Unit Tests to work with Debug mode, // which has a different directory structure (ideally neither of these would be hardcoded) if _, err := os.Stat(testBinary); errors.Is(err, os.ErrNotExist) { testCommand := filepath.Base(os.Args[0]) testBinary = filepath.Join(testDir, "../..", testCommand) } args := []string{fmt.Sprintf("-test.run=Test%s/^%s$", cfg.buildpackPhase, strings.ReplaceAll(cfg.testName, " ", "_"))} // Forward the `buildpacktest` flags to the child process. args = append(args, os.Args[1:]...) cmd := exec.Command(testBinary, args...) cmd.Env = append(os.Environ(), fmt.Sprintf("%s=%s", runTestAsHelperProcessEnv, cfg.buildpackPhase)) for _, e := range cfg.envs { cmd.Env = append(cmd.Env, e) } t.Logf("running command %v", cmd) output, err := cmd.CombinedOutput() exitCode := 0 if e, ok := err.(*exec.ExitError); ok { exitCode = e.ExitCode() } result := &Result{ // Almost all buildpack output is relogged to Stderr Output: string(output), ExitCode: exitCode, } return result, err } return &Result{}, nil } // runBuildpackPhaseMain runs a buildpack phase. It is the equivalent // of `func main()` for a helper process. To avoid confusion, it is written // like the main of a standard Go app, using "log.Fatalf" in place of // "t.Fatalf". func runBuildpackPhaseMain(t *testing.T, cfg *config) { phasePassed, err := runBuildpackPhase(t, cfg) if err != nil { log.Fatalf("buildpack error: %v", err) } if cfg.buildpackPhase == detectPhase && !phasePassed { // mimic the libcnb exit code for when /bin/detect runs but does // not detect anything. os.Exit(100) } // Do not allow any other Go test validation to continue in the child // process. os.Exit(0) } func runBuildpackPhase(t *testing.T, cfg *config) (bool, error) { temps := buildpacktestenv.SetUpTempDirs(t, cfg.codeDir) opts := []gcp.ContextOption{gcp.WithApplicationRoot(temps.CodeDir), gcp.WithBuildpackRoot(temps.BuildpackDir)} // Mock out calls to ctx.Exec, if specified if len(cfg.mockProcesses) > 0 { eCmd, err := mockprocess.NewExecCmd(cfg.mockProcesses...) if err != nil { t.Fatalf("error creating mock exec command: %v", err) } opts = append(opts, gcp.WithExecCmd(eCmd)) } // Logs all ctx.Exec commands to stderr os.Setenv(env.DebugMode, "true") ctx := gcp.NewContext(opts...) if cfg.appPath != "" { // Copy apps from test data into temp code dir if err := fileutil.MaybeCopyPathContents(temps.CodeDir, filepath.Join(flagTestData, cfg.appPath), fileutil.AllPaths); err != nil { return false, fmt.Errorf("unable to copy app directory %q to %q: %v", cfg.appPath, temps.CodeDir, err) } } for f, c := range cfg.files { fn := filepath.Join(temps.CodeDir, f) if dir := path.Dir(fn); dir != "" { if err := os.MkdirAll(dir, 0744); err != nil { return false, fmt.Errorf("creating directory tree %s: %v", dir, err) } } if err := ioutil.WriteFile(fn, []byte(c), 0644); err != nil { return false, fmt.Errorf("writing file %s: %v", fn, err) } } if err := os.Chdir(temps.CodeDir); err != nil { return false, fmt.Errorf("changing to code dir %q: %v", temps.CodeDir, err) } if cfg.buildpackPhase == buildPhase { if err := cfg.buildFn(ctx); err != nil { return false, fmt.Errorf("build error: %v", err) } } else { detect, err := cfg.detectFn(ctx) if err != nil { return false, fmt.Errorf("detect error: %v", err) } // Mimics the exit code of libcnb library when the detect function // succeeds but does not pass detect. if !detect.Result().Pass { return false, nil } } return true, nil }