github.com/jonsyu1/godel@v0.0.0-20171017211503-64567a0cf169/apps/gunit/cmd/test/test.go (about)

     1  // Copyright 2016 Palantir Technologies, Inc.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package test
    16  
    17  import (
    18  	"bufio"
    19  	"fmt"
    20  	"go/build"
    21  	"go/parser"
    22  	"go/token"
    23  	"io"
    24  	"io/ioutil"
    25  	"os"
    26  	"os/exec"
    27  	"path"
    28  	"regexp"
    29  	"sort"
    30  	"strings"
    31  
    32  	"github.com/nmiyake/pkg/dirs"
    33  	"github.com/palantir/amalgomate/amalgomated"
    34  	"github.com/palantir/pkg/cli"
    35  	"github.com/palantir/pkg/cli/cfgcli"
    36  	"github.com/palantir/pkg/cli/flag"
    37  	"github.com/palantir/pkg/matcher"
    38  	"github.com/palantir/pkg/pkgpath"
    39  	"github.com/pkg/errors"
    40  
    41  	"github.com/palantir/godel/apps/gunit/cmd"
    42  	"github.com/palantir/godel/apps/gunit/config"
    43  )
    44  
    45  const (
    46  	goTestCmdName          = "test"
    47  	goCoverCmdName         = "cover"
    48  	coverageOutputPathFlag = "coverage-output"
    49  	pkgsParamName          = "packages"
    50  )
    51  
    52  var (
    53  	goTestCmd        = cmd.Library.MustNewCmd("gotest")
    54  	goCoverCmd       = cmd.Library.MustNewCmd("gotest")
    55  	goJUnitReportCmd = cmd.Library.MustNewCmd("gojunitreport")
    56  	gtCmd            = cmd.Library.MustNewCmd("gt")
    57  )
    58  
    59  var goTestCmdGenerator = &testCmdGenerator{
    60  	cmd:          goTestCmd,
    61  	usage:        "Runs 'go test' on project packages",
    62  	runFunc:      runGoTest,
    63  	paramCreator: testParamCreator,
    64  }
    65  
    66  func GoTestCommand(supplier amalgomated.CmderSupplier) cli.Command {
    67  	cmd := goTestCmdGenerator.baseTestCmd(supplier)
    68  	cmd.Name = goTestCmdName
    69  	return cmd
    70  }
    71  
    72  func RunGoTestAction(supplier amalgomated.CmderSupplier) func(ctx cli.Context) error {
    73  	return func(ctx cli.Context) error {
    74  		testParams := testParamCreator(ctx)
    75  		wd, err := dirs.GetwdEvalSymLinks()
    76  		if err != nil {
    77  			return err
    78  		}
    79  		return goTestCmdGenerator.runTestCmdForPkgs(nil, testParams, supplier, wd, ctx.App.OnExit)
    80  	}
    81  }
    82  
    83  func GTCommand(supplier amalgomated.CmderSupplier) cli.Command {
    84  	gtCmd := &testCmdGenerator{
    85  		cmd:          gtCmd,
    86  		usage:        "Runs 'gt' on project packages",
    87  		runFunc:      runGoTest,
    88  		paramCreator: testParamCreator,
    89  	}
    90  	return gtCmd.baseTestCmd(supplier)
    91  }
    92  
    93  func GoCoverCommand(supplier amalgomated.CmderSupplier) cli.Command {
    94  	coverCmd := &testCmdGenerator{
    95  		cmd:          goCoverCmd,
    96  		usage:        "Runs 'go cover' on project packages",
    97  		runFunc:      runGoTestCover,
    98  		paramCreator: coverageParamCreator,
    99  	}
   100  	cmd := coverCmd.baseTestCmd(supplier)
   101  	cmd.Name = goCoverCmdName
   102  	cmd.Flags = append(cmd.Flags,
   103  		flag.StringFlag{
   104  			Name:  coverageOutputPathFlag,
   105  			Usage: "Path to coverage output file",
   106  		},
   107  	)
   108  	return cmd
   109  }
   110  
   111  type testCmdGenerator struct {
   112  	cmd          amalgomated.Cmd
   113  	usage        string
   114  	runFunc      runTestFunc
   115  	paramCreator paramCreatorFunc
   116  }
   117  
   118  func (g *testCmdGenerator) baseTestCmd(supplier amalgomated.CmderSupplier) cli.Command {
   119  	return cli.Command{
   120  		Name:  g.cmd.Name(),
   121  		Usage: g.usage,
   122  		Flags: []flag.Flag{
   123  			flag.StringSlice{
   124  				Name:     pkgsParamName,
   125  				Usage:    "Packages to test",
   126  				Optional: true,
   127  			},
   128  		},
   129  		Action: func(ctx cli.Context) error {
   130  			pkgsParam := ctx.Slice(pkgsParamName)
   131  			testParams := g.paramCreator(ctx)
   132  			wd, err := dirs.GetwdEvalSymLinks()
   133  			if err != nil {
   134  				return err
   135  			}
   136  			return g.runTestCmdForPkgs(pkgsParam, testParams, supplier, wd, ctx.App.OnExit)
   137  		},
   138  	}
   139  }
   140  
   141  func (g *testCmdGenerator) runTestCmdForPkgs(pkgsParam []string, testParams testCtxParams, supplier amalgomated.CmderSupplier, wd string, onExitManager cli.OnExit) error {
   142  	cfg, err := config.Load(cfgcli.ConfigPath, cfgcli.ConfigJSON)
   143  	if err != nil {
   144  		return err
   145  	}
   146  	if err := cfg.Validate(); err != nil {
   147  		return err
   148  	}
   149  
   150  	// tagsMatcher is a matcher that matches the specified tags (or nil if no tags were specified)
   151  	tagsMatcher, err := cmd.TagsMatcher(testParams.tags, cfg)
   152  	if err != nil {
   153  		return err
   154  	}
   155  	excludeMatchers := []matcher.Matcher{cfg.Exclude}
   156  	if tagsMatcher != nil {
   157  		// if tagsMatcher is non-nil, should exclude all files that do not match the tags
   158  		excludeMatchers = append(excludeMatchers, matcher.Not(tagsMatcher))
   159  	}
   160  
   161  	pkgs, err := cmd.PkgPaths(pkgsParam, wd, matcher.Any(excludeMatchers...))
   162  	if err != nil {
   163  		return err
   164  	}
   165  
   166  	if len(pkgs) == 0 {
   167  		return errors.Errorf("no packages to test")
   168  	}
   169  
   170  	placeholderFiles, err := createPlaceholderTestFiles(pkgs, wd)
   171  	if err != nil {
   172  		return errors.Wrapf(err, "failed to create placeholder files for packages %v", pkgs)
   173  	}
   174  
   175  	cleanup := func() {
   176  		for _, currFileToRemove := range placeholderFiles {
   177  			if err := os.Remove(currFileToRemove); err != nil {
   178  				fmt.Printf("%+v\n", errors.Wrapf(err, "failed to remove file %s", currFileToRemove))
   179  			}
   180  		}
   181  	}
   182  
   183  	// register cleanup task on exit
   184  	cleanupID := onExitManager.Register(cleanup)
   185  
   186  	// clean up placeholder files after function
   187  	defer func() {
   188  		// unregister cleanup task from CLI
   189  		onExitManager.Unregister(cleanupID)
   190  
   191  		// run cleanup
   192  		cleanup()
   193  	}()
   194  
   195  	return g.runTestCmd(supplier, pkgs, testParams, wd)
   196  }
   197  
   198  func (g *testCmdGenerator) runTestCmd(supplier amalgomated.CmderSupplier, pkgs []string, params testCtxParams, wd string) (rErr error) {
   199  	// if JUnit output is desired, set up temporary file to which raw output is written
   200  	var rawFile *os.File
   201  	rawWriter := ioutil.Discard
   202  	if params.junitOutputPath != "" {
   203  		var err error
   204  		rawFile, err = ioutil.TempFile("", "")
   205  		if err != nil {
   206  			return errors.Wrapf(err, "failed to create temporary file")
   207  		}
   208  		rawWriter = rawFile
   209  		defer func() {
   210  			if err := os.Remove(rawFile.Name()); err != nil && rErr == nil {
   211  				rErr = errors.Wrapf(err, "failed to remove temporary file %s in defer", rawFile.Name())
   212  			}
   213  		}()
   214  	}
   215  
   216  	// run the test function
   217  	params.verbose = params.verbose || params.junitOutputPath != ""
   218  	cmder, err := supplier(g.cmd)
   219  	if err != nil {
   220  		return errors.Wrapf(err, "failed to create command %s", g.cmd.Name())
   221  	}
   222  	failedPkgs, err := g.runFunc(cmder, pkgs, params, rawWriter, wd)
   223  
   224  	// close raw file
   225  	if rawFile != nil {
   226  		if err := rawFile.Close(); err != nil {
   227  			return errors.Wrapf(err, "failed to close file %s", rawFile.Name())
   228  		}
   229  	}
   230  
   231  	if err != nil && err.Error() != "exit status 1" {
   232  		// only re-throw if error is not "exit status 1", since those errors are generally recoverable
   233  		return err
   234  	}
   235  
   236  	if params.junitOutputPath != "" {
   237  		// open raw file for reading
   238  		var err error
   239  		rawFile, err = os.Open(rawFile.Name())
   240  		if err != nil {
   241  			return errors.Wrapf(err, "failed to open temporary file %s for reading", rawFile.Name())
   242  		}
   243  
   244  		if err := runGoJUnitReport(supplier, wd, rawFile, params.junitOutputPath); err != nil {
   245  			return err
   246  		}
   247  	}
   248  
   249  	if len(failedPkgs) > 0 {
   250  		return fmt.Errorf(failedPkgsErrorMsg(failedPkgs))
   251  	}
   252  
   253  	return nil
   254  }
   255  
   256  type paramCreatorFunc func(ctx cli.Context) testCtxParams
   257  
   258  func testParamCreator(ctx cli.Context) testCtxParams {
   259  	return testCtxParams{
   260  		junitOutputPath: cmd.JUnitOutputPath(ctx),
   261  		race:            cmd.Race(ctx),
   262  		stdout:          ctx.App.Stdout,
   263  		tags:            cmd.Tags(ctx),
   264  		verbose:         cmd.Verbose(ctx),
   265  	}
   266  }
   267  
   268  func coverageParamCreator(ctx cli.Context) testCtxParams {
   269  	param := testParamCreator(ctx)
   270  	param.coverageOutPath = ctx.String(coverageOutputPathFlag)
   271  	return param
   272  }
   273  
   274  type testCtxParams struct {
   275  	stdout          io.Writer
   276  	coverageOutPath string
   277  	junitOutputPath string
   278  	verbose         bool
   279  	race            bool
   280  	tags            []string
   281  }
   282  
   283  func longestPkgNameLen(pkgPaths []string, cmdWd string) int {
   284  	longestPkgLen := 0
   285  	for _, currPkgPath := range pkgPaths {
   286  		pkgName, err := pkgpath.NewRelPkgPath(currPkgPath, cmdWd).GoPathSrcRel()
   287  		if err == nil && len(pkgName) > longestPkgLen {
   288  			longestPkgLen = len(pkgName)
   289  		}
   290  	}
   291  	return longestPkgLen
   292  }
   293  
   294  type runTestFunc func(cmder amalgomated.Cmder, pkgs []string, params testCtxParams, w io.Writer, wd string) ([]string, error)
   295  
   296  func runGoTest(cmder amalgomated.Cmder, pkgs []string, params testCtxParams, w io.Writer, wd string) ([]string, error) {
   297  	// make test output verbose
   298  	if params.verbose {
   299  		cmder = amalgomated.CmderWithPrependedArgs(cmder, "-v")
   300  	}
   301  	if params.race {
   302  		cmder = amalgomated.CmderWithPrependedArgs(cmder, "-race")
   303  	}
   304  	return executeTestCommand(cmder.Cmd(pkgs, wd), params.stdout, w, longestPkgNameLen(pkgs, wd))
   305  }
   306  
   307  func runGoTestCover(cmder amalgomated.Cmder, pkgs []string, params testCtxParams, w io.Writer, wd string) (rFailedTests []string, rErr error) {
   308  	// create combined output file
   309  	outputFile, err := os.Create(params.coverageOutPath)
   310  	if err != nil {
   311  		return nil, errors.Wrapf(err, "failed to create specified output file for coverage at %s", params.coverageOutPath)
   312  	}
   313  	defer func() {
   314  		if err := outputFile.Close(); err != nil {
   315  			fmt.Printf("%+v\n", errors.Wrapf(err, "failed to close file %s in defer", outputFile))
   316  		}
   317  	}()
   318  
   319  	// create temporary directory for individual coverage profiles
   320  	tmpDir, err := ioutil.TempDir("", "")
   321  	if err != nil {
   322  		return nil, errors.Wrapf(err, "failed to create temporary directory for coverage output")
   323  	}
   324  	defer func() {
   325  		if err := os.RemoveAll(tmpDir); err != nil && rErr == nil {
   326  			rErr = errors.Wrapf(err, "failed to remove temporary directory %s in defer", tmpDir)
   327  		}
   328  	}()
   329  
   330  	isFirstPackage := true
   331  	var failedTests []string
   332  	// currently can only run one package at a time
   333  	for _, currPkg := range pkgs {
   334  		// if error existed, add package to failed tests
   335  		longestPkgNameLen := longestPkgNameLen(pkgs, wd)
   336  		failedPkgs, currPkgCoverageFilePath, err := coverSinglePkg(cmder, params.stdout, w, wd, params.verbose, params.race, currPkg, tmpDir, longestPkgNameLen)
   337  		if err != nil {
   338  			failedTests = append(failedTests, failedPkgs...)
   339  		}
   340  
   341  		if err := appendSingleCoverageOutputToCombined(currPkgCoverageFilePath, isFirstPackage, outputFile); err != nil {
   342  			return nil, err
   343  		}
   344  		isFirstPackage = false
   345  	}
   346  
   347  	return failedTests, err
   348  }
   349  
   350  func appendSingleCoverageOutputToCombined(singleCoverageFilePath string, isFirstPkg bool, combinedOutput io.Writer) (rErr error) {
   351  	singlePkgCoverageFile, err := os.Open(singleCoverageFilePath)
   352  	if err != nil {
   353  		return errors.Wrapf(err, "failed to open file %s", singleCoverageFilePath)
   354  	}
   355  
   356  	defer func() {
   357  		if err := singlePkgCoverageFile.Close(); err != nil && rErr == nil {
   358  			rErr = errors.Wrapf(err, "failed to close file %s in defer", singleCoverageFilePath)
   359  		}
   360  	}()
   361  
   362  	// append current output to combined output file
   363  	br := bufio.NewReader(singlePkgCoverageFile)
   364  	if !isFirstPkg {
   365  		// if this is not the first package, skip the first line (it contains the coverage mode)
   366  		if _, err := br.ReadString('\n'); err != nil {
   367  			// do nothing
   368  		}
   369  	}
   370  	if _, err := io.Copy(combinedOutput, br); err != nil {
   371  		return errors.Wrapf(err, "failed to write output to writer")
   372  	}
   373  
   374  	return nil
   375  }
   376  
   377  // coverSinglePkgs runs the cover command on a single package. The raw output of the command written to the provided
   378  // writer. The coverage profile for the file is written to a temporary file within the provided directory. The function
   379  // returns the names of any packages that failed (should be either empty or a slice containing the package name of the
   380  // package that was covered), the location that the coverage profile for this package was written and an error value.
   381  func coverSinglePkg(cmder amalgomated.Cmder, stdout io.Writer, rawWriter io.Writer, cmdWd string, verbose, race bool, currPkg, tmpDir string, longestPkgNameLen int) (rFailedPkgs []string, rTmpFile string, rErr error) {
   382  	currTmpFile, err := ioutil.TempFile(tmpDir, "")
   383  	if err != nil {
   384  		return nil, "", errors.Wrapf(err, "failed to create temporary file for coverage for package %s", currPkg)
   385  	}
   386  	defer func() {
   387  		if err := currTmpFile.Close(); err != nil && rErr == nil {
   388  			rErr = errors.Wrapf(err, "failed to close file %s in defer", currTmpFile.Name())
   389  		}
   390  	}()
   391  
   392  	// make test output verbose and enable coverage
   393  	var prependedArgs []string
   394  	if verbose {
   395  		prependedArgs = append(prependedArgs, "-v")
   396  	}
   397  	if race {
   398  		prependedArgs = append(prependedArgs, "-race")
   399  	}
   400  	prependedArgs = append(prependedArgs, "-covermode=count", "-coverprofile="+currTmpFile.Name())
   401  	wrappedCmder := amalgomated.CmderWithPrependedArgs(cmder, prependedArgs...)
   402  
   403  	// execute test for package
   404  	failedPkgs, err := executeTestCommand(wrappedCmder.Cmd([]string{currPkg}, cmdWd), stdout, rawWriter, longestPkgNameLen)
   405  	return failedPkgs, currTmpFile.Name(), err
   406  }
   407  
   408  func runGoJUnitReport(supplier amalgomated.CmderSupplier, cmdWd string, rawOutputReader io.Reader, junitOutputPath string) error {
   409  	cmder, err := supplier(goJUnitReportCmd)
   410  	if err != nil {
   411  		return errors.Wrapf(err, "failed to create runner for gojunitreport")
   412  	}
   413  
   414  	var goVersionArgs []string
   415  	if versionOutput, err := exec.Command("go", "version").CombinedOutput(); err == nil {
   416  		if parts := strings.Split(string(versionOutput), " "); len(parts) >= 3 {
   417  			// expect output to be of form "go version go1.8 darwin/amd64", so get element 2
   418  			goVersionArgs = append(goVersionArgs, fmt.Sprintf("--go-version=%s", parts[2]))
   419  		}
   420  	}
   421  
   422  	execCmd := cmder.Cmd(goVersionArgs, cmdWd)
   423  	execCmd.Stdin = bufio.NewReader(rawOutputReader)
   424  	output, err := execCmd.CombinedOutput()
   425  	if err != nil {
   426  		return errors.Wrapf(err, "failed to run gojunitreport")
   427  	}
   428  
   429  	if err := ioutil.WriteFile(junitOutputPath, output, 0644); err != nil {
   430  		return errors.Wrapf(err, "failed to write output to path %s", junitOutputPath)
   431  	}
   432  	return nil
   433  }
   434  
   435  // createPlaceholderTestFiles creates placeholder test files in any of the provided packages that do not already contain
   436  // test files and returns a slice that contains the created files. If this function returns an error, it will attempt to
   437  // remove any of the placeholder files that it created before doing so. The generated files will have the name
   438  // "tmp_placeholder_test.go" and will have a package clause that matches the name of the other go files in the
   439  // directory.
   440  func createPlaceholderTestFiles(pkgs []string, wd string) ([]string, error) {
   441  	var placeholderFiles []string
   442  
   443  	for _, currPkg := range pkgs {
   444  		currPath := path.Join(wd, currPkg)
   445  		infos, err := ioutil.ReadDir(currPath)
   446  		if err != nil {
   447  			return nil, errors.Wrapf(err, "failed to get directory information for package %s", currPath)
   448  		}
   449  
   450  		pkgHasTest := false
   451  		for _, currFileInfo := range infos {
   452  			if !currFileInfo.IsDir() && strings.HasSuffix(currFileInfo.Name(), "_test.go") {
   453  				pkgHasTest = true
   454  				break
   455  			}
   456  		}
   457  
   458  		// no test present -- get package name and write temporary placeholder file
   459  		if !pkgHasTest {
   460  			parsedPkgs, err := parser.ParseDir(token.NewFileSet(), currPath, nil, parser.PackageClauseOnly)
   461  			if err != nil {
   462  				return nil, errors.Wrapf(err, "failed to parse packages from directory %s", currPath)
   463  			}
   464  
   465  			if len(parsedPkgs) > 0 {
   466  				// get package name (should only be one since there are no tests in this directory and
   467  				// go requires one package per directory, but will work even if that is not the case)
   468  				pkgName := ""
   469  
   470  				// attempt to determine package by importing package using the default build context
   471  				// first. Useful because this will take build constraints into account (for example, if
   472  				// there are Go files in the directory with a "// +build ignore" tag, those should not
   473  				// be considered for generating the package name).
   474  				if importedPkg, err := build.Default.ImportDir(currPath, build.ImportComment); err == nil {
   475  					pkgName = importedPkg.Name
   476  				} else {
   477  					var sortedKeys []string
   478  					for k := range parsedPkgs {
   479  						sortedKeys = append(sortedKeys, k)
   480  					}
   481  					sort.Strings(sortedKeys)
   482  					for _, currName := range sortedKeys {
   483  						pkgName = currName
   484  						break
   485  					}
   486  				}
   487  
   488  				currPlaceholderFile := path.Join(currPath, "tmp_placeholder_test.go")
   489  
   490  				if err := ioutil.WriteFile(currPlaceholderFile, placeholderTestFileBytes(pkgName), 0644); err != nil {
   491  					// if write fails, clean up files that were already written before returning
   492  					for _, currFileToClean := range placeholderFiles {
   493  						if err := os.Remove(currFileToClean); err != nil {
   494  							fmt.Printf("failed to remove file %s: %v\n", currFileToClean, err)
   495  						}
   496  					}
   497  					return nil, errors.Wrapf(err, "failed to write placeholder file %s", currPlaceholderFile)
   498  				}
   499  
   500  				placeholderFiles = append(placeholderFiles, currPlaceholderFile)
   501  			}
   502  		}
   503  	}
   504  
   505  	return placeholderFiles, nil
   506  }
   507  
   508  const placeholderTemplate = `package {{package}}
   509  // temporary placeholder test file created by gunit
   510  `
   511  
   512  func placeholderTestFileBytes(pkgName string) []byte {
   513  	return []byte(strings.Replace(placeholderTemplate, "{{package}}", pkgName, -1))
   514  }
   515  
   516  // executeTestCommand executes the provided command. The output produced by the command's Stdout and Stderr calls are
   517  // processed as they are written and an aligned version of the output is written to the Stdout of the current process.
   518  // The "longestPkgNameLen" parameter specifies the longest package name (used to align the console output). This
   519  // function returns a slice that contains the packages that had test failures (output line started with "FAIL"). The
   520  // error value will contain any error that was encountered while executing the command, including if the command
   521  // executed successfully but any tests failed. In either case, the packages that encountered errors will also be
   522  // returned.
   523  func executeTestCommand(execCmd *exec.Cmd, stdout io.Writer, rawOutputWriter io.Writer, longestPkgNameLen int) (rFailedPkgs []string, rErr error) {
   524  	bw := bufio.NewWriter(rawOutputWriter)
   525  
   526  	// stream output to Stdout
   527  	multiWriter := multiWriter{
   528  		consoleWriter:     stdout,
   529  		rawOutputWriter:   bw,
   530  		failedPkgs:        []string{},
   531  		longestPkgNameLen: longestPkgNameLen,
   532  	}
   533  
   534  	// flush buffered writer at the end of the function
   535  	defer func() {
   536  		if err := bw.Flush(); err != nil && rErr == nil {
   537  			rErr = errors.Wrapf(err, "failed to flush buffered writer in defer")
   538  		}
   539  	}()
   540  
   541  	// set Stdout and Stderr of command to multiwriter
   542  	execCmd.Stdout = &multiWriter
   543  	execCmd.Stderr = &multiWriter
   544  
   545  	// run command (which will print its Stdout and Stderr to the Stdout of current process) and return output
   546  	err := execCmd.Run()
   547  	return multiWriter.failedPkgs, err
   548  }
   549  
   550  type multiWriter struct {
   551  	consoleWriter     io.Writer
   552  	rawOutputWriter   io.Writer
   553  	failedPkgs        []string
   554  	longestPkgNameLen int
   555  }
   556  
   557  var setupFailedRegexp = regexp.MustCompile(`(^FAIL\t.+) (\[setup failed\]$)`)
   558  
   559  func (w *multiWriter) Write(p []byte) (int, error) {
   560  	// write unaltered output to file
   561  	n, err := w.rawOutputWriter.Write(p)
   562  	if err != nil {
   563  		return n, err
   564  	}
   565  
   566  	lines := strings.Split(string(p), "\n")
   567  	for i, currLine := range lines {
   568  		// test output for valid case always starts with "Ok" or "FAIL"
   569  		if strings.HasPrefix(currLine, "ok") || strings.HasPrefix(currLine, "FAIL") {
   570  			if setupFailedRegexp.MatchString(currLine) {
   571  				// if line matches "setup failed" output, modify output to conform to expected style
   572  				// (namely, replace space between package name and "[setup failed]" with a tab)
   573  				currLine = setupFailedRegexp.ReplaceAllString(currLine, "$1\t$2")
   574  			}
   575  
   576  			// split into at most 4 parts
   577  			fields := strings.SplitN(currLine, "\t", 4)
   578  
   579  			// valid test lines have at least 3 parts: "[ok|FAIL]\t[pkgName]\t[time|no test files]"
   580  			if len(fields) >= 3 {
   581  				currPkgName := strings.TrimSpace(fields[1])
   582  				lines[i] = alignLine(fields, w.longestPkgNameLen)
   583  				// append package name to failures list if this was a failure
   584  				if strings.HasPrefix(currLine, "FAIL") {
   585  					w.failedPkgs = append(w.failedPkgs, currPkgName)
   586  				}
   587  			}
   588  		}
   589  	}
   590  
   591  	// write formatted version to console writer
   592  	if n, err := w.consoleWriter.Write([]byte(strings.Join(lines, "\n"))); err != nil {
   593  		return n, err
   594  	}
   595  
   596  	// n and err are from the unaltered write to the rawOutputWriter
   597  	return n, err
   598  }
   599  
   600  // alignLine returns a string where the length of the second field (fields[1]) is padded with spaces to make its length
   601  // equal to the value of maxPkgLen and the fields are joined with tab characters. Assuming that the first field is
   602  // always the same length, this method ensures that the third field will always be aligned together for any fixed value
   603  // of maxPkgLen.
   604  func alignLine(fields []string, maxPkgLen int) string {
   605  	currPkgName := fields[1]
   606  	repeat := maxPkgLen - len(currPkgName)
   607  	if repeat < 0 {
   608  		// this should not occur under normal circumstances. However, it appears that it is possible if tests
   609  		// create test packages in the directory structure while tests are already running. If such a case is
   610  		// encountered, having output that isn't aligned optimally is better than crashing, so set repeat to 0.
   611  		repeat = 0
   612  	}
   613  	fields[1] = currPkgName + strings.Repeat(" ", repeat)
   614  	return strings.Join(fields, "\t")
   615  }
   616  
   617  func failedPkgsErrorMsg(failedPkgs []string) string {
   618  	numFailedPkgs := len(failedPkgs)
   619  	outputParts := append([]string{fmt.Sprintf("%d %v had failing tests:", numFailedPkgs, plural(numFailedPkgs, "package", "packages"))}, failedPkgs...)
   620  	return strings.Join(outputParts, "\n\t")
   621  }
   622  
   623  func plural(num int, singular, plural string) string {
   624  	if num == 1 {
   625  		return singular
   626  	}
   627  	return plural
   628  }