dev-tools/mage/gotest.go (285 lines of code) (raw):

// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one // or more contributor license agreements. Licensed under the Elastic License 2.0; // you may not use this file except in compliance with the Elastic License 2.0. package mage import ( "context" "errors" "fmt" "io" "log" "os" "os/exec" "path" "path/filepath" "strings" "time" "github.com/magefile/mage/mg" "github.com/magefile/mage/sh" "github.com/elastic/elastic-agent/dev-tools/mage/gotool" ) // GoTestArgs are the arguments used for the "go*Test" targets and they define // how "go test" is invoked. "go test" is always invoked with -v for verbose. type GoTestArgs struct { LogName string // Test name used in logging. RunExpr string // Expression to pass to the -run argument of go test. Race bool // Enable race detector. Tags []string // Build tags to enable. ExtraFlags []string // Extra flags to pass to 'go test'. Packages []string // Packages to test. Env map[string]string // Env vars to add to the current env. OutputFile string // File to write verbose test output to. JUnitReportFile string // File to write a JUnit XML test report to. CoverageProfileFile string // Test coverage profile file (enables -cover). Output io.Writer // Write stderr and stdout to Output if set } // TestBinaryArgs are the arguments used when building binary for testing. type TestBinaryArgs struct { Name string // Name of the binary to build InputFiles []string } func makeGoTestArgs(name string) GoTestArgs { fileName := fmt.Sprintf("build/TEST-go-%s", strings.Replace(strings.ToLower(name), " ", "_", -1)) params := GoTestArgs{ LogName: name, Race: RaceDetector, Packages: []string{"./..."}, OutputFile: fileName + ".out", JUnitReportFile: fileName + ".xml", Tags: testTagsFromEnv(), Env: make(map[string]string), } if TestCoverage { params.CoverageProfileFile = fileName + ".cov" } return params } func makeGoTestArgsForModule(name, module string) GoTestArgs { fileName := fmt.Sprintf("build/TEST-go-%s-%s", strings.Replace(strings.ToLower(name), " ", "_", -1), strings.Replace(strings.ToLower(module), " ", "_", -1), ) params := GoTestArgs{ LogName: fmt.Sprintf("%s-%s", name, module), Race: RaceDetector, Packages: []string{fmt.Sprintf("./module/%s/...", module)}, OutputFile: fileName + ".out", JUnitReportFile: fileName + ".xml", Tags: testTagsFromEnv(), } if TestCoverage { params.CoverageProfileFile = fileName + ".cov" } return params } // testTagsFromEnv gets a list of comma-separated tags from the TEST_TAGS // environment variables, e.g: TEST_TAGS=aws,azure. func testTagsFromEnv() []string { tags := strings.Split(strings.Trim(os.Getenv("TEST_TAGS"), ", "), ",") if FIPSBuild { tags = append(tags, "requirefips", "ms_tls13kdf") } return tags } // DefaultGoTestUnitArgs returns a default set of arguments for running // all unit tests. We tag unit test files with '!integration'. func DefaultGoTestUnitArgs() GoTestArgs { return makeGoTestArgs("Unit") } // DefaultGoTestIntegrationArgs returns a default set of arguments for running // all integration tests. We tag integration test files with 'integration'. func DefaultGoTestIntegrationArgs() GoTestArgs { args := makeGoTestArgs("Integration") args.Tags = append(args.Tags, "integration") return args } // GoTestIntegrationArgsForModule returns a default set of arguments for running // module integration tests. We tag integration test files with 'integration'. func GoTestIntegrationArgsForModule(module string) GoTestArgs { args := makeGoTestArgsForModule("Integration", module) args.Tags = append(args.Tags, "integration") return args } // DefaultTestBinaryArgs returns the default arguments for building // a binary for testing. func DefaultTestBinaryArgs() TestBinaryArgs { return TestBinaryArgs{ Name: BeatName, } } // GoTestIntegrationForModule executes the Go integration tests sequentially. // Currently all test cases must be present under "./module" directory. // // Motivation: previous implementation executed all integration tests at once, // causing high CPU load, high memory usage and resulted in timeouts. // // This method executes integration tests for a single module at a time. // Use TEST_COVERAGE=true to enable code coverage profiling. // Use RACE_DETECTOR=true to enable the race detector. // Use MODULE=module to run only tests for `module`. func GoTestIntegrationForModule(ctx context.Context) error { module := EnvOr("MODULE", "") modulesDirEntry, err := os.ReadDir("./module") if err != nil { return err } foundModule := false failedModules := []string{} for _, fi := range modulesDirEntry { if !fi.IsDir() { continue } if module != "" && module != fi.Name() { continue } foundModule = true // Set MODULE because only want that modules tests to run inside the testing environment. env := map[string]string{"MODULE": fi.Name()} passThroughEnvs(env, IntegrationTestEnvVars()...) runners, err := NewIntegrationRunners(path.Join("./module", fi.Name()), env) if err != nil { return fmt.Errorf("test setup failed for module %s: %w", fi.Name(), err) } err = runners.Test("goIntegTest", func() error { err := GoTest(ctx, GoTestIntegrationArgsForModule(fi.Name())) if err != nil { return err } return nil }) if err != nil { // err will already be report to stdout, collect failed module to report at end failedModules = append(failedModules, fi.Name()) } } if module != "" && !foundModule { return fmt.Errorf("no module %s", module) } if len(failedModules) > 0 { return fmt.Errorf("failed modules: %s", strings.Join(failedModules, ", ")) } return nil } // InstallGoTestTools installs additional tools that are required to run unit and integration tests. func InstallGoTestTools() error { return gotool.Install( gotool.Install.Package("gotest.tools/gotestsum"), ) } func GoTestBuild(ctx context.Context, params GoTestArgs) error { if params.OutputFile == "" { return fmt.Errorf("missing output file") } fmt.Println(">> go test:", params.LogName, "Building Test Binary") args := []string{"test", "-c", "-o", params.OutputFile} if len(params.Tags) > 0 { params := strings.Join(params.Tags, ",") if params != "" { args = append(args, "-tags="+params) } } args = append(args, params.Packages...) goTestBuild := makeCommand(ctx, params.Env, "go", args...) err := goTestBuild.Run() if err != nil { return err } return nil } // GoTest invokes "go test" and reports the results to stdout. It returns an // error if there was any failure executing the tests or if there were any // test failures. func GoTest(ctx context.Context, params GoTestArgs) error { mg.Deps(InstallGoTestTools) fmt.Println(">> go test:", params.LogName, "Testing") // We use gotestsum to drive the tests and produce a junit report. // The tool runs `go test -json` in order to produce a structured log which makes it easier // to parse the actual test output. // Of OutputFile is given the original JSON file will be written as well. // // The runner needs to set CLI flags for gotestsum and for "go test". We track the different // CLI flags in the gotestsumArgs and testArgs variables, such that we can finally produce command like: // $ gotestsum <gotestsum args> -- <go test args> // // The additional arguments given via GoTestArgs are applied to `go test` only. Callers can not // modify any of the gotestsum arguments. gotestsumArgs := []string{"--no-color"} if mg.Verbose() { gotestsumArgs = append(gotestsumArgs, "-f", "standard-verbose") } else { gotestsumArgs = append(gotestsumArgs, "-f", "standard-quiet") } if params.JUnitReportFile != "" { CreateDir(params.JUnitReportFile) gotestsumArgs = append(gotestsumArgs, "--junitfile", params.JUnitReportFile) } if params.OutputFile != "" { CreateDir(params.OutputFile) gotestsumArgs = append(gotestsumArgs, "--jsonfile", params.OutputFile+".json") } var testArgs []string if params.Race { testArgs = append(testArgs, "-race") } if len(params.Tags) > 0 { params := strings.Join(params.Tags, " ") if params != "" { testArgs = append(testArgs, "-tags", params) } } if params.CoverageProfileFile != "" { params.CoverageProfileFile = createDir(filepath.Clean(params.CoverageProfileFile)) testArgs = append(testArgs, "-covermode=atomic", "-coverprofile="+params.CoverageProfileFile, ) } // Pass the go test extra flags BEFORE the RunExpr. // TL;DR: This is needed to make sure that a -test.run flag specified in the GOTEST_FLAGS environment variable does // not interfere with the batching done by the framework. // // Full explanation: // The integration test framework runs the tests twice: // - the first time we pass a special tag that make all the define statements in the tests skip the test and dump the requirements. // This output is processed by the integration test framework to discover the tests and the set of environments/machines // we will need to spawn and allocate the tests to the various machines. (see batch.go for details) // - the second time we run the tests (here) the integration test framework adds a -test.run flag when launching go test // on the remote machine to make sure that only the tests corresponding to that batch are executed. // // By specifying the extra flags before the -test.run for the batch we make sure that the last flag definition "wins" // (have a look at the unit test in batch_test.go), so that whatever run constraint is specified in GOTEST_FLAGS // participates in the discovery and batching (1st go test execution) but doesn't override the actual execution on // the remote machine (2nd go test execution). testArgs = append(testArgs, params.ExtraFlags...) if params.RunExpr != "" { testArgs = append(testArgs, "-run", params.RunExpr) } testArgs = append(testArgs, params.Packages...) args := append(gotestsumArgs, append([]string{"--"}, testArgs...)...) fmt.Println(">> ARGS:", params.LogName, "Command:", "gotestsum", strings.Join(args, " ")) goTest := makeCommand(ctx, params.Env, "gotestsum", args...) // Wire up the outputs. var outputs []io.Writer if params.Output != nil { outputs = append(outputs, params.Output) } if params.OutputFile != "" { fileOutput, err := os.Create(createDir(params.OutputFile)) if err != nil { return fmt.Errorf("failed to create go test output file: %w", err) } defer fileOutput.Close() outputs = append(outputs, fileOutput) } output := io.MultiWriter(outputs...) if params.Output == nil { goTest.Stdout = io.MultiWriter(output, os.Stdout) goTest.Stderr = io.MultiWriter(output, os.Stderr) } else { goTest.Stdout = output goTest.Stderr = output } err := goTest.Run() var goTestErr *exec.ExitError if err != nil { // Command ran. var exitErr *exec.ExitError if !errors.As(err, &exitErr) { return fmt.Errorf("failed to execute go: %w", err) } // Command ran but failed. Process the output. goTestErr = exitErr } if goTestErr != nil { // No packages were tested. Probably the code didn't compile. return fmt.Errorf("go test returned a non-zero value: %w", goTestErr) } // Generate a HTML code coverage report. var htmlCoverReport string if params.CoverageProfileFile != "" { htmlCoverReport = strings.TrimSuffix(params.CoverageProfileFile, filepath.Ext(params.CoverageProfileFile)) + ".html" coverToHTML := sh.RunCmd("go", "tool", "cover", "-html="+params.CoverageProfileFile, "-o", htmlCoverReport) if err = coverToHTML(); err != nil { return fmt.Errorf("failed to write HTML code coverage report: %w", err) } } // Return an error indicating that testing failed. if goTestErr != nil { fmt.Println(">> go test:", params.LogName, "Test Failed") return fmt.Errorf("go test returned a non-zero value: %w", goTestErr) } fmt.Println(">> go test:", params.LogName, "Test Passed") return nil } func makeCommand(ctx context.Context, env map[string]string, cmd string, args ...string) *exec.Cmd { c := exec.CommandContext(ctx, cmd, args...) c.Env = os.Environ() for k, v := range env { c.Env = append(c.Env, k+"="+v) } c.Stdout = io.Discard if mg.Verbose() { c.Stdout = os.Stdout } c.Stderr = os.Stderr c.Stdin = os.Stdin log.Println("exec:", cmd, strings.Join(args, " ")) fmt.Println("exec:", cmd, strings.Join(args, " ")) return c } // BuildSystemTestBinary runs BuildSystemTestGoBinary with default values. func BuildSystemTestBinary() error { return BuildSystemTestGoBinary(DefaultTestBinaryArgs()) } // BuildSystemTestGoBinary build a binary for testing that is instrumented for // testing and measuring code coverage. The binary is only instrumented for // coverage when TEST_COVERAGE=true (default is false). func BuildSystemTestGoBinary(binArgs TestBinaryArgs) error { args := []string{ "test", "-c", "-o", binArgs.Name + ".test", } if TestCoverage { args = append(args, "-coverpkg", "./...") } if len(binArgs.InputFiles) > 0 { args = append(args, binArgs.InputFiles...) } start := time.Now() defer func() { log.Printf("BuildSystemTestGoBinary (go %v) took %v.", strings.Join(args, " "), time.Since(start)) }() return sh.RunV("go", args...) }