Implement JUnit formatter

This commit is contained in:
Michael Miller 2021-05-30 15:02:30 -06:00
parent e30d5c1981
commit fa3e9dd34d
No known key found for this signature in database
GPG Key ID: FB9F12F7C646A4AD
6 changed files with 277 additions and 2 deletions

View File

@ -1,4 +1,4 @@
require "./components/*"
require "./components/**"
module Spectator::Formatting
# Namespace for snippets of text displayed in console output.

View File

@ -0,0 +1,39 @@
require "./test_suite"
module Spectator::Formatting::Components::JUnit
# Root node of the JUnit XML document.
# This is the "testsuites" element and all of its children.
struct Root
# Creates the root element.
def initialize(@runtime : Time::Span, @suites : Array(TestSuite), *,
@total : Int32, @failures : Int32, @errors : Int32)
end
# Constructs the element from a report.
def self.from_report(report)
hostname = System.hostname
counts = report.counts
suites = report.examples.group_by(&.location.file)
suites = suites.map do |file, examples|
TestSuite.from_examples(file, examples, hostname)
end
new(report.runtime, suites,
total: counts.total,
failures: counts.fail,
errors: counts.error)
end
# Produces the XML fragment.
def to_xml(xml)
xml.element("testsuites",
tests: @total,
failures: @failures,
errors: @errors,
time: @runtime.total_seconds,
name: "Spec") do
@suites.each(&.to_xml(xml))
end
end
end
end

View File

@ -0,0 +1,86 @@
require "xml"
require "../../../example"
module Spectator::Formatting::Components::JUnit
# Test case node of the JUnit XML document.
# This is the "testsuite" element and all of its children.
struct TestCase
# Creates the test case element.
def initialize(@class_name : String, @example : Example)
end
# Produces the XML fragment.
def to_xml(xml)
result = @example.result
xml.element("testcase",
name: @example,
assertions: result.expectations.size,
classname: @class_name,
status: result.accept(StatusVisitor),
time: result.elapsed.total_seconds) do
visitor = ElementVisitor.new(xml)
result.accept(visitor)
end
end
# Picks the status string for a result.
private module StatusVisitor
extend self
# Returns "PASS".
def pass(_result)
"PASS"
end
# Returns "FAIL".
def fail(_result)
"FAIL"
end
# :ditto:
def error(result)
fail(result)
end
# Returns "TODO".
def pending(_result)
"TODO"
end
end
# Result visitor that adds elements to the test case node depending on the result.
private struct ElementVisitor
# Creates the visitor.
def initialize(@xml : XML::Builder)
end
# Does nothing.
def pass(_result)
# ...
end
# Adds a failure element to the test case node.
def fail(result)
error = result.error
@xml.element("failure", message: error.message, type: error.class) do
# TODO: Add match-data as text to node.
end
end
# Adds an error element to the test case node.
def error(result)
error = result.error
@xml.element("error", message: error.message, type: error.class) do
if backtrace = error.backtrace
@xml.text(backtrace.join("\n"))
end
end
end
# Adds a skipped element to the test case node.
def pending(_result)
@xml.element("skipped") # TODO: Populate message attribute with reason from result.
end
end
end
end

View File

@ -0,0 +1,104 @@
require "./test_case"
module Spectator::Formatting::Components::JUnit
# Test suite node of the JUnit XML document.
# This is the "testsuite" element and all of its children.
struct TestSuite
# Amounts for each type of test result.
record Counts, total : Int32, failures : Int32, errors : Int32, skipped : Int32
# Creates the test suite element.
def initialize(@package : String, @name : String, @cases : Array(TestCase),
@time : Time::Span, @counts : Counts, @hostname : String)
end
# Constructs the test suite element from a collection of tests.
# The *examples* should all come from the same *file*.
def self.from_examples(file, examples, hostname)
package, name = package_name_from_file(file)
counts = count_examples(examples)
time = examples.sum(&.result.elapsed)
cases = examples.map { |example| TestCase.new(name, example) }
new(package, name, cases, time, counts, hostname)
end
# Constructs a package and suite name from a file path.
private def self.package_name_from_file(file)
path = Path.new(file.to_s)
name = path.stem
directory = path.relative_to(Dir.current).dirname
package = directory.gsub(File::SEPARATOR, '.')
{package, name}
end
# Counts the number of examples for each result type.
private def self.count_examples(examples)
visitor = CountVisitor.new
# Iterate through each example and count the number of each type of result.
# Don't count examples that haven't run (indicated by `Node#finished?`).
# This typically happens in fail-fast mode.
examples.each do |example|
example.result.accept(visitor) if example.finished?
end
visitor.counts
end
# Produces the XML fragment.
def to_xml(xml)
xml.element("testsuite",
package: @package,
name: @name,
tests: @counts.total,
failures: @counts.failures,
errors: @counts.errors,
skipped: @counts.skipped,
time: @time.total_seconds,
hostname: @hostname) do
@cases.each(&.to_xml(xml))
end
end
# Totals up the number of each type of result.
# Defines methods for the different types of results.
# Call `#counts` to retrieve the `Counts` instance.
private class CountVisitor
@pass = 0
@failures = 0
@errors = 0
@skipped = 0
# Increments the number of passing examples.
def pass(_result)
@pass += 1
end
# Increments the number of failing (non-error) examples.
def fail(_result)
@failures += 1
end
# Increments the number of error (and failed) examples.
def error(result)
fail(result)
@errors += 1
end
# Increments the number of pending (skipped) examples.
def pending(_result)
@skipped += 1
end
# Produces the total counts.
def counts
Counts.new(
total: @pass + @failures + @skipped,
failures: @failures,
errors: @errors,
skipped: @skipped
)
end
end
end
end

View File

@ -1,8 +1,51 @@
require "xml"
require "./formatter"
module Spectator::Formatting
# Produces a JUnit compatible XML file containing the test results.
class JUnitFormatter < Formatter
def initialize(output_dir)
# Default XML file name.
private OUTPUT_FILE = "output.xml"
# XML builder for the entire document.
private getter! xml : XML::Builder
# Output stream for the XML file.
private getter! io : IO
# Creates the formatter.
# The *output_path* can be a directory or path of an XML file.
# If the former, then an "output.xml" file will be generated in the specified directory.
def initialize(output_path = OUTPUT_FILE)
@output_path = if output_path.ends_with?(".xml")
output_path
else
File.join(output_path, OUTPUT_FILE)
end
end
# Prepares the formatter for writing.
def start(_notification)
@io = io = File.open(@output_path, "w")
@xml = xml = XML::Builder.new(io)
xml.start_document("1.0", "UTF-8")
end
# Invoked after testing completes with summarized information from the test suite.
# Unfortunately, the JUnit specification is not conducive to streaming data.
# All results are gathered at the end, then the report is generated.
def dump_summary(notification)
report = notification.report
root = Components::JUnit::Root.from_report(report)
root.to_xml(xml)
end
# Invoked at the end of the program.
# Allows the formatter to perform any cleanup and teardown.
def close
xml.end_document
xml.flush
io.close
end
end
end

View File

@ -22,6 +22,9 @@ module Spectator
end
end
# Retrieves all examples that were planned to run as part of the suite.
getter examples : Array(Example)
# Total length of time it took to execute the test suite.
# This includes examples, hooks, and framework processes.
getter runtime : Time::Span