package parser import ( "bufio" "io" "regexp" "strconv" "strings" "time" ) // Result represents a test result. type Result int // Test result constants const ( PASS Result = iota FAIL SKIP ) // Report is a collection of package tests. type Report struct { Packages []Package } // Package contains the test results of a single package. type Package struct { Name string Duration time.Duration Tests []*Test Benchmarks []*Benchmark CoveragePct string // Time is deprecated, use Duration instead. Time int // in milliseconds } // Test contains the results of a single test. type Test struct { Name string Duration time.Duration Result Result Output []string SubtestIndent string // Time is deprecated, use Duration instead. Time int // in milliseconds } // Benchmark contains the results of a single benchmark. type Benchmark struct { Name string Duration time.Duration // number of B/op Bytes int // number of allocs/op Allocs int } var ( regexStatus = regexp.MustCompile(`--- (PASS|FAIL|SKIP): (.+) \((\d+\.\d+)(?: seconds|s)\)`) regexIndent = regexp.MustCompile(`^([ \t]+)---`) regexCoverage = regexp.MustCompile(`^coverage:\s+(\d+\.\d+)%\s+of\s+statements(?:\sin\s.+)?$`) regexResult = regexp.MustCompile(`^(ok|FAIL)\s+([^ ]+)\s+(?:(\d+\.\d+)s|\(cached\)|(\[\w+ failed]))(?:\s+coverage:\s+(\d+\.\d+)%\sof\sstatements(?:\sin\s.+)?)?$`) // regexBenchmark captures 3-5 groups: benchmark name, number of times ran, ns/op (with or without decimal), B/op (optional), and allocs/op (optional). regexBenchmark = regexp.MustCompile(`^(Benchmark[^ -]+)(?:-\d+\s+|\s+)(\d+)\s+(\d+|\d+\.\d+)\sns/op(?:\s+(\d+)\sB/op)?(?:\s+(\d+)\sallocs/op)?`) regexOutput = regexp.MustCompile(`( )*\t(.*)`) regexSummary = regexp.MustCompile(`^(PASS|FAIL|SKIP)$`) regexPackageWithTest = regexp.MustCompile(`^# ([^\[\]]+) \[[^\]]+\]$`) ) // Parse parses go test output from reader r and returns a report with the // results. An optional pkgName can be given, which is used in case a package // result line is missing. func Parse(r io.Reader, pkgName string) (*Report, error) { reader := bufio.NewReader(r) report := &Report{make([]Package, 0)} // keep track of tests we find var tests []*Test // keep track of benchmarks we find var benchmarks []*Benchmark // sum of tests' time, use this if current test has no result line (when it is compiled test) var testsTime time.Duration // current test var cur string // coverage percentage report for current package var coveragePct string // stores mapping between package name and output of build failures var packageCaptures = map[string][]string{} // the name of the package which it's build failure output is being captured var capturedPackage string // capture any non-test output var buffers = map[string][]string{} // parse lines for { l, _, err := reader.ReadLine() if err != nil && err == io.EOF { break } else if err != nil { return nil, err } line := string(l) if strings.HasPrefix(line, "=== RUN ") { // new test cur = strings.TrimSpace(line[8:]) tests = append(tests, &Test{ Name: cur, Result: FAIL, Output: make([]string, 0), }) // clear the current build package, so output lines won't be added to that build capturedPackage = "" } else if matches := regexBenchmark.FindStringSubmatch(line); len(matches) == 6 { bytes, _ := strconv.Atoi(matches[4]) allocs, _ := strconv.Atoi(matches[5]) benchmarks = append(benchmarks, &Benchmark{ Name: matches[1], Duration: parseNanoseconds(matches[3]), Bytes: bytes, Allocs: allocs, }) } else if strings.HasPrefix(line, "=== PAUSE ") { continue } else if strings.HasPrefix(line, "=== CONT ") { cur = strings.TrimSpace(line[8:]) continue } else if matches := regexResult.FindStringSubmatch(line); len(matches) == 6 { if matches[5] != "" { coveragePct = matches[5] } if strings.HasSuffix(matches[4], "failed]") { // the build of the package failed, inject a dummy test into the package // which indicate about the failure and contain the failure description. tests = append(tests, &Test{ Name: matches[4], Result: FAIL, Output: packageCaptures[matches[2]], }) } else if matches[1] == "FAIL" && !containsFailures(tests) && len(buffers[cur]) > 0 { // This package didn't have any failing tests, but still it // failed with some output. Create a dummy test with the // output. tests = append(tests, &Test{ Name: "Failure", Result: FAIL, Output: buffers[cur], }) buffers[cur] = buffers[cur][0:0] } // all tests in this package are finished report.Packages = append(report.Packages, Package{ Name: matches[2], Duration: parseSeconds(matches[3]), Tests: tests, Benchmarks: benchmarks, CoveragePct: coveragePct, Time: int(parseSeconds(matches[3]) / time.Millisecond), // deprecated }) buffers[cur] = buffers[cur][0:0] tests = make([]*Test, 0) benchmarks = make([]*Benchmark, 0) coveragePct = "" cur = "" testsTime = 0 } else if matches := regexStatus.FindStringSubmatch(line); len(matches) == 4 { cur = matches[2] test := findTest(tests, cur) if test == nil { continue } // test status if matches[1] == "PASS" { test.Result = PASS } else if matches[1] == "SKIP" { test.Result = SKIP } else { test.Result = FAIL } if matches := regexIndent.FindStringSubmatch(line); len(matches) == 2 { test.SubtestIndent = matches[1] } test.Output = buffers[cur] test.Name = matches[2] test.Duration = parseSeconds(matches[3]) testsTime += test.Duration test.Time = int(test.Duration / time.Millisecond) // deprecated } else if matches := regexCoverage.FindStringSubmatch(line); len(matches) == 2 { coveragePct = matches[1] } else if matches := regexOutput.FindStringSubmatch(line); capturedPackage == "" && len(matches) == 3 { // Sub-tests start with one or more series of 4-space indents, followed by a hard tab, // followed by the test output // Top-level tests start with a hard tab. test := findTest(tests, cur) if test == nil { continue } test.Output = append(test.Output, matches[2]) } else if strings.HasPrefix(line, "# ") { // indicates a capture of build output of a package. set the current build package. packageWithTestBinary := regexPackageWithTest.FindStringSubmatch(line) if packageWithTestBinary != nil { // Sometimes, the text after "# " shows the name of the test binary // (".test") in addition to the package // e.g.: "# package/name [package/name.test]" capturedPackage = packageWithTestBinary[1] } else { capturedPackage = line[2:] } } else if capturedPackage != "" { // current line is build failure capture for the current built package packageCaptures[capturedPackage] = append(packageCaptures[capturedPackage], line) } else if regexSummary.MatchString(line) { // unset current test name so any additional output after the // summary is captured separately. cur = "" } else { // buffer anything else that we didn't recognize buffers[cur] = append(buffers[cur], line) // if we have a current test, also append to its output test := findTest(tests, cur) if test != nil { if strings.HasPrefix(line, test.SubtestIndent+" ") { test.Output = append(test.Output, strings.TrimPrefix(line, test.SubtestIndent+" ")) } } } } if len(tests) > 0 { // no result line found report.Packages = append(report.Packages, Package{ Name: pkgName, Duration: testsTime, Time: int(testsTime / time.Millisecond), Tests: tests, Benchmarks: benchmarks, CoveragePct: coveragePct, }) } return report, nil } func parseSeconds(t string) time.Duration { if t == "" { return time.Duration(0) } // ignore error d, _ := time.ParseDuration(t + "s") return d } func parseNanoseconds(t string) time.Duration { // note: if input < 1 ns precision, result will be 0s. if t == "" { return time.Duration(0) } // ignore error d, _ := time.ParseDuration(t + "ns") return d } func findTest(tests []*Test, name string) *Test { for i := len(tests) - 1; i >= 0; i-- { if tests[i].Name == name { return tests[i] } } return nil } func containsFailures(tests []*Test) bool { for _, test := range tests { if test.Result == FAIL { return true } } return false } // Failures counts the number of failed tests in this report func (r *Report) Failures() int { count := 0 for _, p := range r.Packages { for _, t := range p.Tests { if t.Result == FAIL { count++ } } } return count }