Add jUnit XML output formatter

This commit is contained in:
Cameron Villers 2021-03-28 21:49:59 -04:00
parent a5a34675c0
commit e6e9eb8698
16 changed files with 3031 additions and 6 deletions

View file

@ -70,11 +70,11 @@ func FromFlags(progName string, args []string) (Config, string, error) {
flags.BoolVar(&c.ExitOnError, "exit-on-error", false, "immediately stop execution when the first error is encountered")
flags.BoolVar(&c.IgnoreMissingSchemas, "ignore-missing-schemas", false, "skip files with missing schemas instead of failing")
flags.Var(&ignoreFilenamePatterns, "ignore-filename-pattern", "regular expression specifying paths to ignore (can be specified multiple times)")
flags.BoolVar(&c.Summary, "summary", false, "print a summary at the end")
flags.BoolVar(&c.Summary, "summary", false, "print a summary at the end (ignored for junit output)")
flags.IntVar(&c.NumberOfWorkers, "n", 4, "number of goroutines to run concurrently")
flags.BoolVar(&c.Strict, "strict", false, "disallow additional properties not in schema")
flags.StringVar(&c.OutputFormat, "output", "text", "output format - json, tap, text")
flags.BoolVar(&c.Verbose, "verbose", false, "print results for all resources (ignored for tap output)")
flags.StringVar(&c.OutputFormat, "output", "text", "output format - json, junit, tap, text")
flags.BoolVar(&c.Verbose, "verbose", false, "print results for all resources (ignored for tap and junit output)")
flags.BoolVar(&c.SkipTLS, "insecure-skip-tls-verify", false, "disable verification of the server's SSL certificate. This will make your HTTPS connections insecure")
flags.StringVar(&c.Cache, "cache", "", "cache schemas downloaded via HTTP to this folder")
flags.StringVar(&c.CPUProfileFile, "cpu-prof", "", "debug - log CPU profiling to file")

176
pkg/output/junit.go Normal file
View file

@ -0,0 +1,176 @@
package output
// References:
// https://github.com/windyroad/JUnit-Schema/blob/master/JUnit.xsd
// https://llg.cubic.org/docs/junit/
// https://github.com/jstemmer/go-junit-report/blob/master/formatter/formatter.go
// https://github.com/junit-team/junit5/blob/main/platform-tests/src/test/resources/jenkins-junit.xsd
import (
"bufio"
"encoding/xml"
"fmt"
"github.com/yannh/kubeconform/pkg/validator"
"io"
"time"
)
type TestSuiteCollection struct {
XMLName xml.Name `xml:"testsuites"`
Name string `xml:"name,attr"`
Time float64 `xml:"time,attr"`
Tests int `xml:"tests,attr"`
Failures int `xml:"failures,attr"`
Disabled int `xml:"disabled,attr"`
Errors int `xml:"errors,attr"`
Suites []TestSuite `xml:"testsuite"`
}
type Property struct {
Name string `xml:"name,attr"`
Value string `xml:"value,attr"`
}
type TestSuite struct {
XMLName xml.Name `xml:"testsuite"`
Properties []*Property `xml:"properties>property,omitempty"`
Cases []TestCase `xml:"testcase"`
Name string `xml:"name,attr"`
Id int `xml:"id,attr"`
Tests int `xml:"tests,attr"`
Failures int `xml:"failures,attr"`
Errors int `xml:"errors,attr"`
Disabled int `xml:"disabled,attr"`
Skipped int `xml:"skipped,attr"`
}
type TestCase struct {
XMLName xml.Name `xml:"testcase"`
Name string `xml:"name,attr"`
ClassName string `xml:"classname,attr"`
Skipped *TestCaseSkipped `xml:"skipped,omitempty"`
Error *TestCaseError `xml:"error,omitempty"`
Failure []TestCaseError `xml:"failure,omitempty"`
}
type TestCaseSkipped struct {
Message string `xml:"message,attr"`
}
type TestCaseError struct {
Message string `xml:"message,attr"`
Type string `xml:"type,attr"`
Content string `xml:",chardata"`
}
type junito struct {
id int
w io.Writer
withSummary bool
verbose bool
suites map[string]*TestSuite // map filename to corresponding suite
nValid, nInvalid, nErrors, nSkipped int
startTime time.Time
}
func junitOutput(w io.Writer, withSummary bool, isStdin, verbose bool) Output {
return &junito{
id: 0,
w: w,
withSummary: withSummary,
verbose: verbose,
suites: make(map[string]*TestSuite),
nValid: 0,
nInvalid: 0,
nErrors: 0,
nSkipped: 0,
startTime: time.Now(),
}
}
// Write adds a result to the report.
func (o *junito) Write(result validator.Result) error {
var suite *TestSuite
suite, found := o.suites[result.Resource.Path]
if !found {
o.id++
suite = &TestSuite{
Name: result.Resource.Path,
Id: o.id,
Tests: 0, Failures: 0, Errors: 0, Disabled: 0, Skipped: 0,
Cases: make([]TestCase, 0),
Properties: make([]*Property, 0),
}
o.suites[result.Resource.Path] = suite
}
suite.Tests++
sig, _ := result.Resource.Signature()
var objectName string
if len(sig.Namespace) > 0 {
objectName = fmt.Sprintf("%s/%s", sig.Namespace, sig.Name)
} else {
objectName = sig.Name
}
typeName := fmt.Sprintf("%s@%s", sig.Kind, sig.Version)
testCase := TestCase{ClassName: typeName, Name: objectName}
switch result.Status {
case validator.Valid:
o.nValid++
case validator.Invalid:
suite.Failures++
o.nInvalid++
failure := TestCaseError{Message: result.Err.Error()}
testCase.Failure = append(testCase.Failure, failure)
case validator.Error:
suite.Errors++
o.nErrors++
testCase.Error = &TestCaseError{Message: result.Err.Error()}
case validator.Skipped:
suite.Skipped++
testCase.Skipped = &TestCaseSkipped{}
o.nSkipped++
case validator.Empty:
}
suite.Cases = append(suite.Cases, testCase)
return nil
}
// Flush outputs the results as XML
func (o *junito) Flush() error {
runtime := time.Now().Sub(o.startTime)
var suites = make([]TestSuite, 0)
for _, suite := range o.suites {
suites = append(suites, *suite)
}
root := TestSuiteCollection{
Name: "kubeconform",
Time: runtime.Seconds(),
Tests: o.nValid + o.nInvalid + o.nErrors + o.nSkipped,
Failures: o.nInvalid,
Errors: o.nErrors,
Disabled: o.nSkipped,
Suites: suites,
}
// 2-space indentation
content, err := xml.MarshalIndent(root, "", " ")
if err != nil {
return err
}
writer := bufio.NewWriter(o.w)
writer.Write(content)
writer.WriteByte('\n')
writer.Flush()
return nil
}

168
pkg/output/junit_test.go Normal file
View file

@ -0,0 +1,168 @@
package output
import (
"bytes"
"github.com/yannh/kubeconform/pkg/resource"
"regexp"
"testing"
"github.com/yannh/kubeconform/pkg/validator"
"github.com/beevik/etree"
)
func isNumeric(s string) bool {
matched, _ := regexp.MatchString("^\\d+(\\.\\d+)?$", s)
return matched
}
func TestJUnitWrite(t *testing.T) {
for _, testCase := range []struct {
name string
withSummary bool
isStdin bool
verbose bool
results []validator.Result
evaluate func(d *etree.Document)
}{
{
"empty document",
false,
false,
false,
[]validator.Result{},
func(d *etree.Document) {
root := d.FindElement("/testsuites")
if root == nil {
t.Errorf("Can't find root testsuite element")
return
}
for _, attr := range root.Attr {
switch attr.Key {
case "time":
case "tests":
case "failures":
case "disabled":
case "errors":
if !isNumeric(attr.Value) {
t.Errorf("Expected a number for /testsuites/@%s", attr.Key)
}
continue
case "name":
if attr.Value != "kubeconform" {
t.Errorf("Expected 'kubeconform' for /testsuites/@name")
}
continue
default:
t.Errorf("Unknown attribute /testsuites/@%s", attr.Key)
continue
}
}
suites := root.SelectElements("testsuite")
if len(suites) != 0 {
t.Errorf("No testsuite elements should be generated when there are no resources")
}
},
},
{
"a single deployment, verbose, with summary",
true,
false,
true,
[]validator.Result{
{
Resource: resource.Resource{
Path: "deployment.yml",
Bytes: []byte(`apiVersion: apps/v1
kind: Deployment
metadata:
name: "my-app"
namespace: "my-namespace"
`),
},
Status: validator.Valid,
Err: nil,
},
},
func(d *etree.Document) {
suites := d.FindElements("//testsuites/testsuite")
if len(suites) != 1 {
t.Errorf("Expected exactly 1 testsuite element, got %d", len(suites))
return
}
suite := suites[0]
for _, attr := range suite.Attr {
switch attr.Key {
case "name":
if attr.Value != "deployment.yml" {
t.Errorf("Test suite name should be the resource path")
}
continue
case "tests":
if attr.Value != "1" {
t.Errorf("testsuite/@tests should be 1")
}
continue
case "failures":
if attr.Value != "0" {
t.Errorf("testsuite/@failures should be 0")
}
continue
case "errors":
if attr.Value != "0" {
t.Errorf("testsuite/@errors should be 0")
}
continue
case "disabled":
if attr.Value != "0" {
t.Errorf("testsuite/@disabled should be 0")
}
continue
case "skipped":
if attr.Value != "0" {
t.Errorf("testsuite/@skipped should be 0")
}
continue
default:
t.Errorf("Unknown testsuite attribute %s", attr.Key)
continue
}
}
testcases := suite.SelectElements("testcase")
if len(testcases) != 1 {
t.Errorf("Expected exactly 1 testcase, got %d", len(testcases))
return
}
testcase := testcases[0]
if testcase.SelectAttrValue("name", "") != "my-namespace/my-app" {
t.Errorf("Test case name should be namespace / name")
}
if testcase.SelectAttrValue("classname", "") != "Deployment@apps/v1" {
t.Errorf("Test case class name should be resource kind @ api version")
}
if testcase.SelectElement("skipped") != nil {
t.Errorf("skipped element should not be generated if the kind was not skipped")
}
if testcase.SelectElement("error") != nil {
t.Errorf("error element should not be generated if there was no error")
}
if len(testcase.SelectElements("failure")) != 0 {
t.Errorf("failure elements should not be generated if there were no failures")
}
},
},
} {
w := new(bytes.Buffer)
o := junitOutput(w, testCase.withSummary, testCase.isStdin, testCase.verbose)
for _, res := range testCase.results {
o.Write(res)
}
o.Flush()
doc := etree.NewDocument()
doc.ReadFromString(w.String())
testCase.evaluate(doc)
}
}

View file

@ -18,6 +18,8 @@ func New(outputFormat string, printSummary, isStdin, verbose bool) (Output, erro
switch {
case outputFormat == "json":
return jsonOutput(w, printSummary, isStdin, verbose), nil
case outputFormat == "junit":
return junitOutput(w, printSummary, isStdin, verbose), nil
case outputFormat == "tap":
return tapOutput(w, printSummary, isStdin, verbose), nil
case outputFormat == "text":