Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions tests/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pub use backend::MockLLMBackend;
pub use bus::MockAgentBus;
pub use clock::{Clock, MockClock, SystemClock};
pub use report::{
JsonFormatter, ReportFormatter, TestCaseResult, TestReport, TestReportBuilder, TestStatus,
TextFormatter,
JUnitFormatter, JsonFormatter, ReportFormatter, TestCaseResult, TestReport, TestReportBuilder,
TestStatus, TextFormatter,
};
pub use tools::MockTool;
101 changes: 101 additions & 0 deletions tests/src/report/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ pub trait ReportFormatter: Send + Sync {
fn format(&self, report: &TestReport) -> String;
}

fn escape_xml(input: &str) -> String {
input
.replace('&', "&")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}

/// Renders a report as a JSON object.
pub struct JsonFormatter;

Expand Down Expand Up @@ -102,3 +111,95 @@ impl ReportFormatter for TextFormatter {
buf
}
}

/// Renders a report as a JUnit XML testsuite.
pub struct JUnitFormatter;

impl ReportFormatter for JUnitFormatter {
fn format(&self, report: &TestReport) -> String {
let mut buf = String::new();
let tests = report.total();
let failures = report.failed();
let skipped = report.skipped();
let time_secs = report.total_duration.as_secs_f64();

buf.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
buf.push_str(&format!(
"<testsuite name=\"{}\" tests=\"{}\" failures=\"{}\" skipped=\"{}\" time=\"{:.3}\" timestamp=\"{}\">\n",
escape_xml(&report.suite_name),
tests,
failures,
skipped,
time_secs,
report.timestamp
));

for result in &report.results {
buf.push_str(&format!(
" <testcase name=\"{}\" time=\"{:.3}\">",
escape_xml(&result.name),
result.duration.as_secs_f64()
));

match result.status {
TestStatus::Passed => {
if !result.metadata.is_empty() {
buf.push('\n');
buf.push_str(" <properties>\n");
for (key, value) in &result.metadata {
buf.push_str(&format!(
" <property name=\"{}\" value=\"{}\"/>\n",
escape_xml(key),
escape_xml(value)
));
}
buf.push_str(" </properties>\n");
buf.push_str(" </testcase>\n");
} else {
buf.push_str("</testcase>\n");
}
}
TestStatus::Failed => {
buf.push('\n');
if !result.metadata.is_empty() {
buf.push_str(" <properties>\n");
for (key, value) in &result.metadata {
buf.push_str(&format!(
" <property name=\"{}\" value=\"{}\"/>\n",
escape_xml(key),
escape_xml(value)
));
}
buf.push_str(" </properties>\n");
}
let message = result.error.as_deref().unwrap_or("test failed");
buf.push_str(&format!(
" <failure message=\"{}\">{}</failure>\n",
escape_xml(message),
escape_xml(message)
));
buf.push_str(" </testcase>\n");
}
TestStatus::Skipped => {
buf.push('\n');
if !result.metadata.is_empty() {
buf.push_str(" <properties>\n");
for (key, value) in &result.metadata {
buf.push_str(&format!(
" <property name=\"{}\" value=\"{}\"/>\n",
escape_xml(key),
escape_xml(value)
));
}
buf.push_str(" </properties>\n");
}
buf.push_str(" <skipped/>\n");
buf.push_str(" </testcase>\n");
}
}
}

buf.push_str("</testsuite>\n");
buf
}
}
2 changes: 1 addition & 1 deletion tests/src/report/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ mod format;
mod types;

pub use builder::TestReportBuilder;
pub use format::{JsonFormatter, ReportFormatter, TextFormatter};
pub use format::{JUnitFormatter, JsonFormatter, ReportFormatter, TextFormatter};
pub use types::{TestCaseResult, TestReport, TestStatus};
84 changes: 82 additions & 2 deletions tests/tests/report_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ use std::sync::Arc;
use std::time::Duration;

use mofa_testing::{
JsonFormatter, MockClock, ReportFormatter, TestCaseResult, TestReport, TestReportBuilder,
TestStatus, TextFormatter,
JUnitFormatter, JsonFormatter, MockClock, ReportFormatter, TestCaseResult, TestReport,
TestReportBuilder, TestStatus, TextFormatter,
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -319,3 +319,83 @@ fn text_formatter_empty_report() {
assert!(output.contains("Total: 0"));
assert!(output.contains("Pass rate: 100.0%"));
}

// ===========================================================================
// JUnitFormatter
// ===========================================================================

#[test]
fn junit_formatter_all_passing_suite() {
let report = TestReport {
suite_name: "passing".into(),
results: vec![
make_result("alpha", TestStatus::Passed, 10, None),
make_result("beta", TestStatus::Passed, 20, None),
],
total_duration: Duration::from_millis(30),
timestamp: 123,
};

let output = JUnitFormatter.format(&report);
assert!(output.contains("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
assert!(output.contains("<testsuite name=\"passing\" tests=\"2\" failures=\"0\" skipped=\"0\" time=\"0.030\" timestamp=\"123\">"));
assert!(output.contains("<testcase name=\"alpha\" time=\"0.010\"></testcase>"));
assert!(output.contains("<testcase name=\"beta\" time=\"0.020\"></testcase>"));
}

#[test]
fn junit_formatter_mixed_status_suite() {
let report = mixed_report();
let output = JUnitFormatter.format(&report);

assert!(output.contains("<testsuite name=\"mixed\" tests=\"5\" failures=\"2\" skipped=\"1\" time=\"0.110\" timestamp=\"1000\">"));
assert!(output.contains("<failure message=\"boom\">boom</failure>"));
assert!(output.contains("<failure message=\"oops\">oops</failure>"));
assert!(output.contains("<skipped/>"));
}

#[test]
fn junit_formatter_escapes_error_payload() {
let report = TestReport {
suite_name: "xml".into(),
results: vec![make_result(
"needs<escape>",
TestStatus::Failed,
1,
Some("bad <xml> & \"quotes\""),
)],
total_duration: Duration::from_millis(1),
timestamp: 1,
};

let output = JUnitFormatter.format(&report);
assert!(output.contains("name=\"needs&lt;escape&gt;\""));
assert!(output.contains("message=\"bad &lt;xml&gt; &amp; &quot;quotes&quot;\""));
assert!(output.contains(">bad &lt;xml&gt; &amp; &quot;quotes&quot;</failure>"));
}

#[test]
fn junit_formatter_includes_metadata_as_properties() {
let mut tc = make_result("meta_case", TestStatus::Passed, 5, None);
tc.metadata.push(("browser".into(), "webkit".into()));
tc.metadata.push(("env".into(), "ci".into()));
let report = TestReport {
suite_name: "meta".into(),
results: vec![tc],
total_duration: Duration::from_millis(5),
timestamp: 9,
};

let output = JUnitFormatter.format(&report);
assert!(output.contains("<properties>"));
assert!(output.contains("<property name=\"browser\" value=\"webkit\"/>"));
assert!(output.contains("<property name=\"env\" value=\"ci\"/>"));
}

#[test]
fn junit_formatter_is_deterministic() {
let report = mixed_report();
let first = JUnitFormatter.format(&report);
let second = JUnitFormatter.format(&report);
assert_eq!(first, second);
}
Loading