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

// Licensed to Elasticsearch B.V. under one or more contributor // license agreements. See the NOTICE file distributed with // this work for additional information regarding copyright // ownership. Elasticsearch B.V. licenses this file to you 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 mage import ( "context" "errors" "fmt" "io" "log" "os" "os/exec" "path" "path/filepath" "slices" "strings" "time" "github.com/magefile/mage/mg" "github.com/magefile/mage/sh" "golang.org/x/sys/execabs" "github.com/elastic/beats/v7/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 { TestName string // Test name used in logging. 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 ExtraFlags []string // Extra flags to pass to 'go test'. } func makeGoTestArgs(name string) GoTestArgs { fileName := fmt.Sprintf("build/TEST-go-%s", strings.ReplaceAll(strings.ToLower(name), " ", "_")) params := GoTestArgs{ TestName: name, Race: RaceDetector, Packages: []string{"./..."}, Env: make(map[string]string), OutputFile: fileName + ".out", JUnitReportFile: fileName + ".xml", Tags: testTagsFromEnv(), } if TestCoverage { params.CoverageProfileFile = fileName + ".cov" } return params } func makeGoTestArgsForPackage(name, pkg string) GoTestArgs { fileName := fmt.Sprintf( "build/TEST-go-%s-%s", strings.ReplaceAll(strings.ToLower(name), " ", "_"), strings.ReplaceAll(strings.ToLower(pkg), " ", "_")) params := GoTestArgs{ TestName: fmt.Sprintf("%s-%s", name, pkg), Race: RaceDetector, Packages: []string{fmt.Sprintf("./module/%s", pkg)}, OutputFile: fileName + ".out", JUnitReportFile: fileName + ".xml", Tags: testTagsFromEnv(), } if TestCoverage { params.CoverageProfileFile = fileName + ".cov" } return params } // fetchGoPackages retrieves all Go packages for a beats module. It uses // "go list -tags integration" to obtain the list of packages. // Example: for the "kafka" module inside "metricbeat/module", it'll return: // // [kafka kafka/broker kafka/consumer kafka/consumergroup kafka/partition kafka/producer] func fetchGoPackages(module string) ([]string, error) { cmd := execabs.Command( "go", "list", "-tags", "integration", fmt.Sprintf("./%s/...", module)) output, err := cmd.Output() if err != nil { return nil, err } rawPackages := strings.Split(strings.TrimSpace(string(output)), "\n") var pkgs []string for _, pkg := range rawPackages { tmp := strings.Split(pkg, "/module/") if len(tmp) != 2 { continue } pkgs = append(pkgs, tmp[1]) } return pkgs, nil } // testTagsFromEnv gets a list of comma-separated tags from the TEST_TAGS // environment variables, e.g: TEST_TAGS=aws,azure. // If the FIPS env var is set to true, the requirefips and ms_tls13kdf tags are injected. 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") } // DefaultGoFIPSOnlyTestArgs returns a default set of arguments for running // fips140=only unit tests. func DefaultGoFIPSOnlyTestArgs() GoTestArgs { args := makeGoTestArgs("Unit-FIPS-only") args.Env["GODEBUG"] = "fips140=only" return args } // 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") synth := exec.Command("npx", "@elastic/synthetics", "-h") if synth.Run() == nil { // Run an empty journey to ensure playwright can be loaded // catches situations like missing playwright deps cmd := exec.Command("sh", "-c", "echo 'step(\"t\", () => { })' | elastic-synthetics --inline") var out strings.Builder cmd.Stdout = &out cmd.Stderr = &out err := cmd.Run() if err != nil || cmd.ProcessState.ExitCode() != 0 { fmt.Printf("synthetics is available, but not invokable, command exited with bad code: %s\n", out.String()) } fmt.Println("npx @elastic/synthetics found, will run with synthetics tags") os.Setenv("ELASTIC_SYNTHETICS_CAPABLE", "true") args.Tags = append(args.Tags, "synthetics") } // Use the non-cachable -count=1 flag to disable test caching when running integration tests. // There are reasons to re-run tests even if the code is unchanged (e.g. Dockerfile changes). args.ExtraFlags = append(args.ExtraFlags, "-count=1") return args } // DefaultGoTestIntegrationFromHostArgs returns a default set of arguments for running // all integration tests from the host system (outside the docker network). func DefaultGoTestIntegrationFromHostArgs() GoTestArgs { args := DefaultGoTestIntegrationArgs() args.Env = WithGoIntegTestHostEnv(args.Env) return args } // GoTestIntegrationArgsForPackage returns a default set of arguments for running // module integration tests. We tag integration test files with 'integration'. func GoTestIntegrationArgsForPackage(pkg string) GoTestArgs { args := makeGoTestArgsForPackage("Integration", pkg) 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 for each Go // package within a module 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 { modules := EnvOr("MODULE", "") if modules == "" { log.Printf("Warning: environment variable MODULE is empty: [%s]\n", modules) } moduleArr := strings.Split(modules, ",") for _, module := range moduleArr { err := goTestIntegrationForSingleModule(ctx, module) if err != nil { return err } } return nil } // goTestIntegrationForSingleModule sequentially executes the tests every Go // packages within a module. func goTestIntegrationForSingleModule(ctx context.Context, module string) error { modulesFileInfo, err := os.ReadDir("./module") if err != nil { return err } foundModule := false failedModules := make([]string, 0, len(modulesFileInfo)) for _, fi := range modulesFileInfo { // skip the ones that are not directories or with suffix @tmp, which are created by Jenkins build job if !fi.IsDir() || strings.HasSuffix(fi.Name(), "@tmp") { 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 { pkgs, err := fetchGoPackages("module/" + fi.Name()) if err != nil { return fmt.Errorf("could not list packages for module %s: %w", fi.Name(), err) } var errs []error for _, pkg := range pkgs { err := GoTest(ctx, GoTestIntegrationArgsForPackage(pkg)) if err != nil { errs = append(errs, err) } } return errors.Join(errs...) }) if err != nil { fmt.Printf("Error: failed to run integration tests for module %s:\n%v\n", fi.Name(), err) // 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"), ) } // 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.TestName, "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 { // Enable the race detector for supported platforms. // This is an intersection of the supported platforms for Beats and Go. // // See https://go.dev/doc/articles/race_detector#Requirements. devOS := os.Getenv("DEV_OS") devArch := os.Getenv("DEV_ARCH") raceAmd64 := devArch == "amd64" raceArm64 := devArch == "arm64" && slices.Contains([]string{"linux", "darwin"}, devOS) if raceAmd64 || raceArm64 { testArgs = append(testArgs, "-race") } else { log.Printf("Warning: skipping -race flag for unsupported platform %s/%s\n", devOS, devArch) } } 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, ) } testArgs = append(testArgs, params.ExtraFlags...) testArgs = append(testArgs, params.Packages...) args := append(gotestsumArgs, append([]string{"--"}, testArgs...)...) 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.TestName, "Test Failed") return fmt.Errorf("go test returned a non-zero value: %w", goTestErr) } fmt.Println(">> go test:", params.TestName, "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 DevBuild { // Disable optimizations (-N) and inlining (-l) for debugging. args = append(args, `-gcflags=all=-N -l`) } if TestCoverage { args = append(args, "-coverpkg", "./...") } args = append(args, binArgs.ExtraFlags...) 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...) }